1use rmcp::{
2 handler::server::wrapper::Parameters,
3 model::{CallToolResult, Content, Implementation, ServerCapabilities, ServerInfo},
4 schemars, tool, tool_handler, tool_router,
5};
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use std::path::PathBuf;
9use std::sync::Arc;
10use std::sync::atomic::{AtomicU64, Ordering};
11use surrealdb::{Surreal, engine::local::Mem};
12use tokio::sync::RwLock;
13
14pub(crate) mod file_ops;
15pub(crate) mod validation;
16
17use file_ops::categorize_files;
18pub use file_ops::inject_overwrite;
19#[cfg(test)]
20pub(crate) use file_ops::{FileCategory, classify_file};
21pub(crate) use validation::error_result;
22use validation::{is_valid_surql_identifier, validate_path_against};
23
24#[derive(Debug, Serialize, Deserialize, JsonSchema)]
25pub struct ExecArgs {
26 #[schemars(description = "SurrealQL query to run")]
27 pub query: String,
28}
29
30#[derive(Debug, Serialize, Deserialize, JsonSchema)]
31pub struct LoadProjectArgs {
32 #[schemars(description = "Path to directory containing .surql files")]
33 pub path: String,
34 #[schemars(description = "Reset database before loading (default: true)")]
35 pub clean: Option<bool>,
36}
37
38#[derive(Debug, Serialize, Deserialize, JsonSchema)]
39pub struct LoadFileArgs {
40 #[schemars(description = "Path to a single .surql file to run")]
41 pub path: String,
42}
43
44#[derive(Debug, Serialize, Deserialize, JsonSchema)]
45pub struct DescribeArgs {
46 #[schemars(description = "Table name to describe")]
47 pub table: String,
48}
49
50#[derive(Debug, Serialize, Deserialize, JsonSchema)]
51pub struct ManifestArgs {
52 #[schemars(description = "Path to directory containing manifest.toml (overshift project)")]
53 pub path: String,
54}
55
56#[derive(Debug, Serialize, Deserialize, JsonSchema)]
57pub struct CompareArgs {
58 #[schemars(
59 description = "JSON string from INFO FOR DB on the target database (expected state)"
60 )]
61 pub expected_json: String,
62}
63
64#[derive(Debug, Serialize, Deserialize, JsonSchema)]
65pub struct VerifyArgs {
66 #[schemars(
67 description = "Path to overshift project directory containing manifest.toml. \
68 Loads the manifest, applies schema+migrations to both the playground and a \
69 fresh shadow DB, then compares INFO FOR DB from both to detect drift."
70 )]
71 pub path: String,
72 #[schemars(
73 description = "Read-only mode: only build shadow DB and compare with playground, \
74 do NOT apply anything to the playground. Use to safely verify without writes."
75 )]
76 pub verify_only: Option<bool>,
77}
78
79#[derive(Debug, Serialize, Deserialize, JsonSchema)]
80pub struct RollbackArgs {
81 #[schemars(description = "Path to directory containing manifest.toml (overshift project)")]
82 pub path: String,
83 #[schemars(
84 description = "Target version to roll back to (migrations above this version are reversed)"
85 )]
86 pub target_version: u32,
87}
88
89#[derive(Debug, Serialize, Deserialize, JsonSchema)]
90pub struct CheckArgs {
91 #[schemars(description = "Path to a .surql file or directory containing .surql files")]
92 pub path: String,
93 #[schemars(description = "Recurse into subdirectories (default: true)")]
94 pub recursive: Option<bool>,
95}
96
97#[derive(Debug, Serialize, Deserialize, JsonSchema)]
98pub struct GraphAffectedArgs {
99 #[schemars(description = "Table name to check for reverse dependencies")]
100 pub table: String,
101 #[schemars(
102 description = "Path to directory containing .surql files (required for static analysis)"
103 )]
104 pub schema_path: String,
105}
106
107#[derive(Debug, Serialize, Deserialize, JsonSchema)]
108pub struct GraphTraverseArgs {
109 #[schemars(description = "Starting table name")]
110 pub table: String,
111 #[schemars(
112 description = "Path to directory containing .surql files (required for static analysis)"
113 )]
114 pub schema_path: String,
115 #[schemars(description = "Maximum traversal depth (default: 10)")]
116 pub depth: Option<u32>,
117 #[schemars(description = "Traversal direction: 'forward' (default) or 'reverse'")]
118 pub direction: Option<String>,
119}
120
121#[derive(Debug, Serialize, Deserialize, JsonSchema)]
122pub struct GraphSiblingsArgs {
123 #[schemars(description = "Table name to find siblings for")]
124 pub table: String,
125 #[schemars(
126 description = "Path to directory containing .surql files (required for static analysis)"
127 )]
128 pub schema_path: String,
129}
130
131#[derive(Clone)]
132pub struct SurqlMcp {
133 db: Arc<RwLock<Surreal<surrealdb::engine::local::Db>>>,
134 query_count: Arc<AtomicU64>,
135 workspace_root: Arc<PathBuf>,
136 tool_router: rmcp::handler::server::router::tool::ToolRouter<Self>,
137}
138
139const QUERY_WARNING_THRESHOLDS: &[u64] = &[1000, 5000, 10000];
140
141#[tool_router]
142impl SurqlMcp {
143 pub async fn new() -> anyhow::Result<Self> {
144 let cwd = std::env::current_dir()?;
145 Self::with_workspace_root(cwd).await
146 }
147
148 pub async fn with_workspace_root(root: PathBuf) -> anyhow::Result<Self> {
149 let db = Surreal::new::<Mem>(()).await?;
150 db.use_ns("default").use_db("default").await?;
151 tracing::info!("SurrealDB playground started (root: {})", root.display());
152 Ok(Self {
153 db: Arc::new(RwLock::new(db)),
154 query_count: Arc::new(AtomicU64::new(0)),
155 workspace_root: Arc::new(root),
156 tool_router: Self::tool_router(),
157 })
158 }
159
160 fn increment_query_count(&self) {
161 let count = self.query_count.fetch_add(1, Ordering::Relaxed) + 1;
162 if QUERY_WARNING_THRESHOLDS.contains(&count) {
163 tracing::warn!(
164 "In-memory DB has processed {count} queries — \
165 consider resetting with the 'reset' tool if performance degrades"
166 );
167 }
168 }
169
170 #[tool(
171 name = "exec",
172 description = "Run a SurrealQL query and return the result as JSON"
173 )]
174 pub async fn run_query(
175 &self,
176 Parameters(args): Parameters<ExecArgs>,
177 ) -> Result<CallToolResult, rmcp::ErrorData> {
178 self.increment_query_count();
179 let db = self.db.read().await;
180 match db.query(&args.query).await {
181 Ok(response) => match response.check() {
182 Ok(mut checked) => {
183 let result: Result<Vec<serde_json::Value>, _> = checked.take(0);
184 match result {
185 Ok(rows) => {
186 let json = serde_json::to_string_pretty(&rows)
187 .unwrap_or_else(|_| "[]".to_string());
188 let summary = if rows.is_empty() {
189 "(empty result)".to_string()
190 } else {
191 format!(
192 "{} row{}",
193 rows.len(),
194 if rows.len() == 1 { "" } else { "s" }
195 )
196 };
197 Ok(CallToolResult::success(vec![Content::text(format!(
198 "{summary}\n\n```json\n{json}\n```"
199 ))]))
200 }
201 Err(e) => Ok(CallToolResult::success(vec![Content::text(format!(
202 "Query ran but result extraction failed: {e}"
203 ))])),
204 }
205 }
206 Err(e) => error_result(format!("Query error: {e}")),
207 },
208 Err(e) => error_result(format!("Query failed: {e}")),
209 }
210 }
211
212 #[tool(
213 name = "load_project",
214 description = "Load .surql files from a directory into the database. Resets DB first \
215 by default. Files are categorized by directory: schema/ files get OVERWRITE \
216 injected, migrations/ run in version order, examples/ errors are warnings."
217 )]
218 pub async fn load_project(
219 &self,
220 Parameters(args): Parameters<LoadProjectArgs>,
221 ) -> Result<CallToolResult, rmcp::ErrorData> {
222 let dir = match validate_path_against(&args.path, &self.workspace_root) {
223 Ok(p) => p,
224 Err(e) => return error_result(e),
225 };
226 if !dir.is_dir() {
227 return error_result(format!("Not a directory: {}", args.path));
228 }
229
230 let clean = args.clean.unwrap_or(true);
231 if clean {
232 let db = self.db.read().await;
233 if let Err(e) = db.use_ns("default").use_db("default").await {
235 return error_result(format!("Failed to reset namespace: {e}"));
236 }
237 db.query("REMOVE DATABASE IF EXISTS default").await.ok();
238 if let Err(e) = db.use_ns("default").use_db("default").await {
240 return error_result(format!("Failed to re-create default database: {e}"));
241 }
242 }
243
244 let mut surql_files = Vec::new();
245 surql_parser::collect_surql_files(&dir, &mut surql_files);
246
247 if surql_files.is_empty() {
248 return Ok(CallToolResult::success(vec![Content::text(
249 "No .surql files found",
250 )]));
251 }
252
253 let categorized = categorize_files(&surql_files);
254
255 let db = self.db.read().await;
256 let mut schema_count = 0usize;
257 let mut migration_count = 0usize;
258 let mut function_count = 0usize;
259 let mut example_count = 0usize;
260 let mut errors = Vec::new();
261 let mut warnings = Vec::new();
262
263 for path in &categorized.schema {
265 let content = match surql_parser::read_surql_file(path) {
266 Ok(c) => inject_overwrite(&c),
267 Err(e) => {
268 errors.push(e);
269 continue;
270 }
271 };
272 match db.query(&content).await {
273 Ok(response) => match response.check() {
274 Ok(_) => schema_count += 1,
275 Err(e) => errors.push(format!("{}: {e}", path.display())),
276 },
277 Err(e) => errors.push(format!("{}: {e}", path.display())),
278 }
279 }
280
281 for path in &categorized.functions {
283 let content = match surql_parser::read_surql_file(path) {
284 Ok(c) => inject_overwrite(&c),
285 Err(e) => {
286 errors.push(e);
287 continue;
288 }
289 };
290 match db.query(&content).await {
291 Ok(response) => match response.check() {
292 Ok(_) => function_count += 1,
293 Err(e) => errors.push(format!("{}: {e}", path.display())),
294 },
295 Err(e) => errors.push(format!("{}: {e}", path.display())),
296 }
297 }
298
299 let mut migrations = categorized.migrations.clone();
301 migrations.sort();
302 for path in &migrations {
303 let content = match surql_parser::read_surql_file(path) {
304 Ok(c) => c,
305 Err(e) => {
306 errors.push(e);
307 continue;
308 }
309 };
310 match db.query(&content).await {
311 Ok(response) => match response.check() {
312 Ok(_) => migration_count += 1,
313 Err(e) => errors.push(format!("{}: {e}", path.display())),
314 },
315 Err(e) => errors.push(format!("{}: {e}", path.display())),
316 }
317 }
318
319 for path in &categorized.general {
321 let content = match surql_parser::read_surql_file(path) {
322 Ok(c) => c,
323 Err(e) => {
324 errors.push(e);
325 continue;
326 }
327 };
328 match db.query(&content).await {
329 Ok(response) => match response.check() {
330 Ok(_) => {}
331 Err(e) => errors.push(format!("{}: {e}", path.display())),
332 },
333 Err(e) => errors.push(format!("{}: {e}", path.display())),
334 }
335 }
336
337 for path in &categorized.examples {
339 let content = match surql_parser::read_surql_file(path) {
340 Ok(c) => c,
341 Err(e) => {
342 warnings.push(e);
343 continue;
344 }
345 };
346 match db.query(&content).await {
347 Ok(response) => match response.check() {
348 Ok(_) => example_count += 1,
349 Err(e) => {
350 warnings.push(format!("{}: {e}", path.display()));
351 }
352 },
353 Err(e) => {
354 warnings.push(format!("{}: {e}", path.display()));
355 }
356 }
357 }
358
359 let mut output = format!(
360 "Loaded {} schema, {} migrations, {} functions, {} examples ({} warnings) from `{}`{}",
361 schema_count,
362 migration_count,
363 function_count,
364 example_count,
365 warnings.len(),
366 args.path,
367 if clean { " (clean)" } else { "" }
368 );
369 if !errors.is_empty() {
370 output.push_str(&format!(
371 "\n\n**Errors ({}):**\n{}",
372 errors.len(),
373 errors.join("\n")
374 ));
375 }
376 if !warnings.is_empty() {
377 output.push_str(&format!(
378 "\n\n**Warnings ({}):**\n{}",
379 warnings.len(),
380 warnings.join("\n")
381 ));
382 }
383 Ok(CallToolResult::success(vec![Content::text(output)]))
384 }
385
386 #[tool(
387 name = "load_file",
388 description = "Run a single .surql file against the database"
389 )]
390 pub async fn load_file(
391 &self,
392 Parameters(args): Parameters<LoadFileArgs>,
393 ) -> Result<CallToolResult, rmcp::ErrorData> {
394 self.increment_query_count();
395 let path = match validate_path_against(&args.path, &self.workspace_root) {
396 Ok(p) => p,
397 Err(e) => return error_result(e),
398 };
399 let content = match surql_parser::read_surql_file(&path) {
400 Ok(c) => c,
401 Err(e) => return error_result(e),
402 };
403 let db = self.db.read().await;
404 match db.query(&content).await {
405 Ok(response) => match response.check() {
406 Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!(
407 "Applied `{}`",
408 path.file_name()
409 .and_then(|n| n.to_str())
410 .unwrap_or(&args.path)
411 ))])),
412 Err(e) => error_result(format!("{e}")),
413 },
414 Err(e) => error_result(format!("{e}")),
415 }
416 }
417
418 #[tool(
419 name = "schema",
420 description = "Show all tables, fields, indexes, and events in the current database"
421 )]
422 pub async fn schema(&self) -> Result<CallToolResult, rmcp::ErrorData> {
423 let db = self.db.read().await;
424 let mut response = match db.query("INFO FOR DB").await {
425 Ok(r) => r,
426 Err(e) => return error_result(format!("Failed: {e}")),
427 };
428 let info: Result<Option<serde_json::Value>, _> = response.take(0);
429 match info {
430 Ok(Some(val)) => {
431 let json = serde_json::to_string_pretty(&val).unwrap_or_else(|_| "{}".to_string());
432 Ok(CallToolResult::success(vec![Content::text(format!(
433 "```json\n{json}\n```"
434 ))]))
435 }
436 Ok(None) => Ok(CallToolResult::success(vec![Content::text(
437 "(empty database)",
438 )])),
439 Err(e) => error_result(format!("Failed to read schema: {e}")),
440 }
441 }
442
443 #[tool(
444 name = "describe",
445 description = "Show detailed info about a specific table (fields, indexes, events)"
446 )]
447 pub async fn describe(
448 &self,
449 Parameters(args): Parameters<DescribeArgs>,
450 ) -> Result<CallToolResult, rmcp::ErrorData> {
451 if args.table.contains('`') {
452 return error_result("Table name must not contain backticks".into());
453 }
454 if !is_valid_surql_identifier(&args.table) {
455 return error_result(format!("Invalid table name: {}", args.table));
456 }
457 let db = self.db.read().await;
458 let query = format!("INFO FOR TABLE `{}`", args.table);
459 let mut response = match db.query(&query).await {
460 Ok(r) => r,
461 Err(e) => return error_result(format!("Failed: {e}")),
462 };
463 let info: Result<Option<serde_json::Value>, _> = response.take(0);
464 match info {
465 Ok(Some(val)) => {
466 let json = serde_json::to_string_pretty(&val).unwrap_or_else(|_| "{}".to_string());
467 Ok(CallToolResult::success(vec![Content::text(format!(
468 "**Table `{}`**\n\n```json\n{json}\n```",
469 args.table
470 ))]))
471 }
472 Ok(None) => error_result(format!("Table '{}' not found", args.table)),
473 Err(e) => error_result(format!("Failed: {e}")),
474 }
475 }
476
477 #[tool(
478 name = "manifest",
479 description = "Read an overshift manifest.toml and show project configuration \
480 (namespace, database, modules, migrations)"
481 )]
482 pub async fn manifest(
483 &self,
484 Parameters(args): Parameters<ManifestArgs>,
485 ) -> Result<CallToolResult, rmcp::ErrorData> {
486 let validated = match validate_path_against(&args.path, &self.workspace_root) {
487 Ok(p) => p,
488 Err(e) => return error_result(e),
489 };
490 let manifest = match overshift::Manifest::load(&validated) {
491 Ok(m) => m,
492 Err(e) => return error_result(format!("Cannot load manifest: {e}")),
493 };
494
495 let mut output = format!(
496 "**overshift manifest** from `{}`\n\n\
497 - **Namespace:** `{}`\n\
498 - **Database:** `{}`\n\
499 - **System DB:** `{}`\n",
500 args.path, manifest.meta.ns, manifest.meta.db, manifest.meta.system_db
501 );
502 if let Some(ver) = &manifest.meta.surrealdb {
503 output.push_str(&format!("- **SurrealDB:** `{ver}`\n"));
504 }
505
506 if !manifest.modules.is_empty() {
507 output.push_str(&format!("\n**{} module(s):**\n", manifest.modules.len()));
508 for m in &manifest.modules {
509 let deps = if m.depends_on.is_empty() {
510 String::new()
511 } else {
512 format!(" (depends: {})", m.depends_on.join(", "))
513 };
514 output.push_str(&format!("- `{}` \u{2192} `{}`{deps}\n", m.name, m.path));
515 }
516 }
517
518 let migrations = overshift::migration::discover_migrations(
520 manifest.root_path().unwrap_or(std::path::Path::new(".")),
521 );
522 match migrations {
523 Ok(migs) if !migs.is_empty() => {
524 output.push_str(&format!("\n**{} migration(s):**\n", migs.len()));
525 for m in &migs {
526 output.push_str(&format!("- `{}` ({})\n", m.name, &m.checksum[..8]));
527 }
528 }
529 _ => {}
530 }
531
532 Ok(CallToolResult::success(vec![Content::text(output)]))
533 }
534
535 #[tool(
536 name = "load_manifest",
537 description = "Load an overshift project into the playground DB: applies schema \
538 modules then migrations in order"
539 )]
540 pub async fn load_manifest(
541 &self,
542 Parameters(args): Parameters<ManifestArgs>,
543 ) -> Result<CallToolResult, rmcp::ErrorData> {
544 let validated = match validate_path_against(&args.path, &self.workspace_root) {
545 Ok(p) => p,
546 Err(e) => return error_result(e),
547 };
548 let manifest = match overshift::Manifest::load(&validated) {
549 Ok(m) => m,
550 Err(e) => return error_result(format!("Cannot load manifest: {e}")),
551 };
552
553 let db = self.db.read().await;
555 db.query("REMOVE DATABASE default").await.ok();
557 if let Err(e) = db.use_ns(&manifest.meta.ns).use_db(&manifest.meta.db).await {
558 return error_result(format!(
559 "Failed to switch to NS={}/DB={}: {e}",
560 manifest.meta.ns, manifest.meta.db
561 ));
562 }
563
564 let mut applied = 0;
565 let mut errors = Vec::new();
566
567 let modules = match overshift::schema::load_schema_modules(&manifest) {
569 Ok(m) => m,
570 Err(e) => return error_result(format!("Failed to load schema modules: {e}")),
571 };
572 for module in &modules {
573 match db.query(&module.content).await {
574 Ok(r) => match r.check() {
575 Ok(_) => applied += 1,
576 Err(e) => errors.push(format!("schema/{}: {e}", module.name)),
577 },
578 Err(e) => errors.push(format!("schema/{}: {e}", module.name)),
579 }
580 }
581
582 let migrations = match overshift::migration::discover_migrations(
584 manifest.root_path().unwrap_or(std::path::Path::new(".")),
585 ) {
586 Ok(m) => m,
587 Err(e) => return error_result(format!("Failed to discover migrations: {e}")),
588 };
589 for mig in &migrations {
590 match db.query(mig.content.as_str()).await {
591 Ok(r) => match r.check() {
592 Ok(_) => applied += 1,
593 Err(e) => errors.push(format!("{}: {e}", mig.name)),
594 },
595 Err(e) => errors.push(format!("{}: {e}", mig.name)),
596 }
597 }
598
599 let mut output = format!(
600 "Loaded overshift project `{}` (NS={}, DB={})\n\
601 {} schema module(s) + {} migration(s) = {applied} applied",
602 args.path,
603 manifest.meta.ns,
604 manifest.meta.db,
605 modules.len(),
606 migrations.len()
607 );
608 if !errors.is_empty() {
609 output.push_str(&format!(
610 "\n\n**Errors ({}):**\n{}",
611 errors.len(),
612 errors.join("\n")
613 ));
614 }
615 Ok(CallToolResult::success(vec![Content::text(output)]))
616 }
617
618 #[tool(
619 name = "compare",
620 description = "Compare the playground DB schema against an expected INFO FOR DB \
621 JSON response. Returns a diff of missing/extra tables and functions."
622 )]
623 pub async fn compare(
624 &self,
625 Parameters(args): Parameters<CompareArgs>,
626 ) -> Result<CallToolResult, rmcp::ErrorData> {
627 let expected: serde_json::Value = match serde_json::from_str(&args.expected_json) {
628 Ok(v) => v,
629 Err(e) => return error_result(format!("Invalid expected_json: {e}")),
630 };
631
632 let db = self.db.read().await;
633 let mut response = match db.query("INFO FOR DB").await {
634 Ok(r) => r,
635 Err(e) => return error_result(format!("Failed to query playground: {e}")),
636 };
637 let actual: Option<serde_json::Value> = match response.take(0) {
638 Ok(v) => v,
639 Err(e) => return error_result(format!("Failed to read playground schema: {e}")),
640 };
641 let actual = match actual {
642 Some(v) => v,
643 None => return error_result("INFO FOR DB returned no data".into()),
644 };
645
646 let diff = overshift::validate::compare_db_info(&expected, &actual);
647 let output = diff.to_string();
648 Ok(CallToolResult::success(vec![Content::text(output)]))
649 }
650
651 #[tool(
652 name = "verify",
653 description = "Verify an overshift project by applying it to both the playground \
654 DB and a fresh shadow in-memory DB, then comparing their schemas via INFO FOR \
655 DB. Detects drift between the two environments."
656 )]
657 pub async fn verify(
658 &self,
659 Parameters(args): Parameters<VerifyArgs>,
660 ) -> Result<CallToolResult, rmcp::ErrorData> {
661 let validated = match validate_path_against(&args.path, &self.workspace_root) {
662 Ok(p) => p,
663 Err(e) => return error_result(e),
664 };
665 let manifest = match overshift::Manifest::load(&validated) {
666 Ok(m) => m,
667 Err(e) => return error_result(format!("Cannot load manifest: {e}")),
668 };
669
670 let modules = match overshift::schema::load_schema_modules(&manifest) {
671 Ok(m) => m,
672 Err(e) => return error_result(format!("Failed to load schema modules: {e}")),
673 };
674
675 let migrations = match overshift::migration::discover_migrations(
676 manifest.root_path().unwrap_or(std::path::Path::new(".")),
677 ) {
678 Ok(m) => m,
679 Err(e) => return error_result(format!("Failed to discover migrations: {e}")),
680 };
681
682 let verify_only = args.verify_only.unwrap_or(false);
683
684 if !verify_only {
686 if !is_valid_surql_identifier(&manifest.meta.ns)
687 || !is_valid_surql_identifier(&manifest.meta.db)
688 {
689 return error_result(format!(
690 "Invalid NS/DB in manifest: NS={}, DB={}",
691 manifest.meta.ns, manifest.meta.db
692 ));
693 }
694 let db = self.db.read().await;
695 if let Err(e) = db.use_ns(&manifest.meta.ns).use_db("default").await {
696 return error_result(format!("Failed to switch to NS={}: {e}", manifest.meta.ns));
697 }
698 db.query(format!(
699 "REMOVE DATABASE IF EXISTS `{}`",
700 manifest.meta.db.replace('`', "")
701 ))
702 .await
703 .ok();
704 if let Err(e) = db.use_ns(&manifest.meta.ns).use_db(&manifest.meta.db).await {
705 return error_result(format!(
706 "Failed to switch playground to NS={}/DB={}: {e}",
707 manifest.meta.ns, manifest.meta.db
708 ));
709 }
710
711 for module in &modules {
712 if let Err(e) = db.query(&module.content).await.and_then(|r| r.check()) {
713 return error_result(format!(
714 "Playground: schema module '{}' failed: {e}",
715 module.name
716 ));
717 }
718 }
719 for mig in &migrations {
720 if let Err(e) = db.query(mig.content.as_str()).await.and_then(|r| r.check()) {
721 return error_result(format!(
722 "Playground: migration '{}' failed: {e}",
723 mig.name
724 ));
725 }
726 }
727 }
728
729 let shadow_db = match Surreal::new::<Mem>(()).await {
731 Ok(db) => db,
732 Err(e) => return error_result(format!("Failed to create shadow DB: {e}")),
733 };
734 if let Err(e) = shadow_db
735 .use_ns(&manifest.meta.ns)
736 .use_db(&manifest.meta.db)
737 .await
738 {
739 return error_result(format!(
740 "Failed to switch shadow to NS={}/DB={}: {e}",
741 manifest.meta.ns, manifest.meta.db
742 ));
743 }
744
745 for module in &modules {
746 if let Err(e) = shadow_db
747 .query(&module.content)
748 .await
749 .and_then(|r| r.check())
750 {
751 return error_result(format!(
752 "Shadow: schema module '{}' failed: {e}",
753 module.name
754 ));
755 }
756 }
757 for mig in &migrations {
758 if let Err(e) = shadow_db
759 .query(mig.content.as_str())
760 .await
761 .and_then(|r| r.check())
762 {
763 return error_result(format!("Shadow: migration '{}' failed: {e}", mig.name));
764 }
765 }
766
767 if verify_only {
768 let shadow_info = {
769 let mut resp = match shadow_db.query("INFO FOR DB").await {
770 Ok(r) => r,
771 Err(e) => {
772 return error_result(format!("Failed to query shadow INFO FOR DB: {e}"));
773 }
774 };
775 let val: Option<serde_json::Value> = match resp.take(0) {
776 Ok(v) => v,
777 Err(e) => {
778 return error_result(format!("Failed to read shadow schema: {e}"));
779 }
780 };
781 match val {
782 Some(v) => v,
783 None => return error_result("Shadow INFO FOR DB returned no data".into()),
784 }
785 };
786 let shadow_text =
787 serde_json::to_string_pretty(&shadow_info).unwrap_or_else(|_| "{}".to_string());
788 return Ok(CallToolResult::success(vec![Content::text(format!(
789 "Shadow verification (read-only)\n\
790 {} module(s), {} migration(s)\n\n\
791 ```json\n{shadow_text}\n```",
792 modules.len(),
793 migrations.len(),
794 ))]));
795 }
796
797 let playground_info = {
799 let db = self.db.read().await;
800 let mut resp = match db.query("INFO FOR DB").await {
801 Ok(r) => r,
802 Err(e) => {
803 return error_result(format!("Failed to query playground INFO FOR DB: {e}"));
804 }
805 };
806 let val: Option<serde_json::Value> = match resp.take(0) {
807 Ok(v) => v,
808 Err(e) => return error_result(format!("Failed to read playground schema: {e}")),
809 };
810 match val {
811 Some(v) => v,
812 None => return error_result("Playground INFO FOR DB returned no data".into()),
813 }
814 };
815
816 let shadow_info = {
817 let mut resp = match shadow_db.query("INFO FOR DB").await {
818 Ok(r) => r,
819 Err(e) => return error_result(format!("Failed to query shadow INFO FOR DB: {e}")),
820 };
821 let val: Option<serde_json::Value> = match resp.take(0) {
822 Ok(v) => v,
823 Err(e) => return error_result(format!("Failed to read shadow schema: {e}")),
824 };
825 match val {
826 Some(v) => v,
827 None => return error_result("Shadow INFO FOR DB returned no data".into()),
828 }
829 };
830
831 let diff = overshift::validate::compare_db_info(&playground_info, &shadow_info);
832
833 let mut output = format!(
834 "**Verify** `{}` (NS={}, DB={})\n\
835 Applied {} module(s) + {} migration(s) to playground\n\
836 Applied {} module(s) + {} migration(s) to shadow\n\n",
837 args.path,
838 manifest.meta.ns,
839 manifest.meta.db,
840 modules.len(),
841 migrations.len(),
842 modules.len(),
843 migrations.len(),
844 );
845
846 if diff.is_empty() {
847 output.push_str("Schema matches -- playground and shadow are identical.");
848 } else {
849 output.push_str(&format!("**Drift detected:**\n{diff}"));
850 }
851
852 Ok(CallToolResult::success(vec![Content::text(output)]))
853 }
854
855 #[tool(
856 name = "check",
857 description = "Parse .surql files and report syntax errors without executing. \
858 Path can be a single file or a directory."
859 )]
860 pub async fn check(
861 &self,
862 Parameters(args): Parameters<CheckArgs>,
863 ) -> Result<CallToolResult, rmcp::ErrorData> {
864 let path = match validate_path_against(&args.path, &self.workspace_root) {
865 Ok(p) => p,
866 Err(e) => return error_result(e),
867 };
868 let recursive = args.recursive.unwrap_or(true);
869
870 let files: Vec<PathBuf> = if path.is_file() {
871 vec![path]
872 } else if path.is_dir() {
873 if recursive {
874 let mut collected = Vec::new();
875 surql_parser::collect_surql_files(&path, &mut collected);
876 collected
877 } else {
878 match std::fs::read_dir(&path) {
879 Ok(entries) => entries
880 .filter_map(|e| e.ok())
881 .map(|e| e.path())
882 .filter(|p| {
883 p.extension()
884 .and_then(|ext| ext.to_str())
885 .is_some_and(|ext| ext == "surql")
886 })
887 .collect(),
888 Err(e) => {
889 return error_result(format!("Cannot read directory {}: {e}", args.path));
890 }
891 }
892 }
893 } else {
894 return error_result(format!("Path does not exist: {}", args.path));
895 };
896
897 if files.is_empty() {
898 return Ok(CallToolResult::success(vec![Content::text(
899 "No .surql files found",
900 )]));
901 }
902
903 let mut seen = std::collections::HashSet::new();
904 let mut total_errors = 0usize;
905 let mut error_details = Vec::new();
906
907 for file in &files {
908 let canonical = file.canonicalize().unwrap_or_else(|_| file.clone());
909 if !seen.insert(canonical.clone()) {
910 continue;
911 }
912 let content = match surql_parser::read_surql_file(&canonical) {
913 Ok(c) => c,
914 Err(e) => {
915 error_details.push(format!("{}:0:0: {e}", file.display()));
916 total_errors += 1;
917 continue;
918 }
919 };
920 if let Err(diags) = surql_parser::parse_for_diagnostics(&content) {
921 for d in &diags {
922 error_details.push(format!(
923 "{}:{}:{}: {}",
924 file.display(),
925 d.line,
926 d.column,
927 d.message
928 ));
929 }
930 total_errors += diags.len();
931 }
932 }
933
934 let file_count = seen.len();
935 let mut output = format!(
936 "{file_count} file{} checked, {total_errors} error{} found",
937 if file_count == 1 { "" } else { "s" },
938 if total_errors == 1 { "" } else { "s" },
939 );
940 if !error_details.is_empty() {
941 output.push_str("\n\n");
942 output.push_str(&error_details.join("\n"));
943 }
944
945 Ok(CallToolResult::success(vec![Content::text(output)]))
946 }
947
948 #[tool(
949 name = "rollback",
950 description = "Roll back applied migrations in an overshift project to a target version. \
951 Resets the playground and re-applies schema modules + migrations up to target_version. \
952 In-memory playground rebuilds from scratch (no down.surql needed)."
953 )]
954 pub async fn rollback(
955 &self,
956 Parameters(args): Parameters<RollbackArgs>,
957 ) -> Result<CallToolResult, rmcp::ErrorData> {
958 let validated = match validate_path_against(&args.path, &self.workspace_root) {
959 Ok(p) => p,
960 Err(e) => return error_result(e),
961 };
962 let manifest = match overshift::Manifest::load(&validated) {
963 Ok(m) => m,
964 Err(e) => return error_result(format!("Cannot load manifest: {e}")),
965 };
966
967 let db = self.db.read().await;
968
969 let remove_sql = format!("REMOVE DATABASE IF EXISTS {}", manifest.meta.db);
972 db.query(&remove_sql).await.ok();
973 if let Err(e) = db.use_ns(&manifest.meta.ns).use_db(&manifest.meta.db).await {
974 return error_result(format!(
975 "Failed to switch to NS={}/DB={}: {e}",
976 manifest.meta.ns, manifest.meta.db
977 ));
978 }
979
980 let modules = match overshift::schema::load_schema_modules(&manifest) {
982 Ok(m) => m,
983 Err(e) => return error_result(format!("Failed to load schema modules: {e}")),
984 };
985 let mut schema_applied = 0u32;
986 for module in &modules {
987 let injected = inject_overwrite(&module.content);
988 if let Err(e) = db.query(&injected).await.and_then(|r| r.check()) {
989 return error_result(format!("Schema module {} failed: {e}", module.name));
990 }
991 schema_applied += 1;
992 }
993
994 let target = args.target_version;
996 let migrations = match overshift::migration::discover_migrations(
997 manifest.root_path().unwrap_or(std::path::Path::new(".")),
998 ) {
999 Ok(m) => m,
1000 Err(e) => return error_result(format!("Failed to discover migrations: {e}")),
1001 };
1002 let total_migrations = migrations.len() as u32;
1003 let mut migrations_applied = 0u32;
1004 let mut total_rolled_back = 0u32;
1005 for mig in &migrations {
1006 if mig.version > target {
1007 total_rolled_back += 1;
1008 continue;
1009 }
1010 if let Err(e) = db.query(mig.content.as_str()).await.and_then(|r| r.check()) {
1011 return error_result(format!("Migration {} failed: {e}", mig.name));
1012 }
1013 migrations_applied += 1;
1014 }
1015
1016 let mut output = format!(
1017 "**Rollback** `{}` to v{target:03}\n\
1018 {schema_applied} schema module(s) re-applied, \
1019 {migrations_applied} migration(s) re-applied, \
1020 {total_rolled_back} migration(s) rolled back",
1021 args.path,
1022 );
1023
1024 if total_migrations > 0 {
1025 let max_version = migrations.last().map(|m| m.version).unwrap_or(0);
1026 output.push_str(&format!("\n\nState: v{target:03} of v{max_version:03}"));
1027 }
1028
1029 Ok(CallToolResult::success(vec![Content::text(output)]))
1030 }
1031
1032 #[tool(
1033 name = "graph_affected",
1034 description = "Show which tables would be affected if a table is dropped or modified. \
1035 Follows record<> links in reverse to find all dependents."
1036 )]
1037 pub async fn graph_affected(
1038 &self,
1039 Parameters(args): Parameters<GraphAffectedArgs>,
1040 ) -> Result<CallToolResult, rmcp::ErrorData> {
1041 let dir = match validate_path_against(&args.schema_path, &self.workspace_root) {
1042 Ok(p) => p,
1043 Err(e) => return error_result(e),
1044 };
1045 if !dir.is_dir() {
1046 return error_result(format!("Not a directory: {}", args.schema_path));
1047 }
1048 let graph = match surql_parser::SchemaGraph::from_files(&dir) {
1049 Ok(g) => g,
1050 Err(e) => return error_result(format!("Failed to build schema graph: {e}")),
1051 };
1052 if graph.table(&args.table).is_none() {
1053 return error_result(format!(
1054 "Table '{}' not found in schema at {}",
1055 args.table, args.schema_path
1056 ));
1057 }
1058
1059 let refs = graph.tables_referencing(&args.table);
1060 if refs.is_empty() {
1061 return Ok(CallToolResult::success(vec![Content::text(format!(
1062 "No tables reference `{}`",
1063 args.table
1064 ))]));
1065 }
1066
1067 let mut output = format!(
1068 "**{} table(s) reference `{}`:**\n\n",
1069 refs.len(),
1070 args.table
1071 );
1072 for (ref_table, ref_field) in &refs {
1073 output.push_str(&format!(
1074 "- `{ref_table}.{ref_field}` has `record<{}>`\n",
1075 args.table
1076 ));
1077 }
1078
1079 output.push_str(&format!(
1080 "\nDropping or renaming `{}` would break {} field(s).",
1081 args.table,
1082 refs.len()
1083 ));
1084
1085 Ok(CallToolResult::success(vec![Content::text(output)]))
1086 }
1087
1088 #[tool(
1089 name = "graph_traverse",
1090 description = "Traverse the schema graph from a table, following record<> links \
1091 up to N hops deep. Supports forward (outgoing links) and reverse (incoming links) \
1092 directions."
1093 )]
1094 pub async fn graph_traverse(
1095 &self,
1096 Parameters(args): Parameters<GraphTraverseArgs>,
1097 ) -> Result<CallToolResult, rmcp::ErrorData> {
1098 let dir = match validate_path_against(&args.schema_path, &self.workspace_root) {
1099 Ok(p) => p,
1100 Err(e) => return error_result(e),
1101 };
1102 if !dir.is_dir() {
1103 return error_result(format!("Not a directory: {}", args.schema_path));
1104 }
1105 let graph = match surql_parser::SchemaGraph::from_files(&dir) {
1106 Ok(g) => g,
1107 Err(e) => return error_result(format!("Failed to build schema graph: {e}")),
1108 };
1109 if graph.table(&args.table).is_none() {
1110 return error_result(format!(
1111 "Table '{}' not found in schema at {}",
1112 args.table, args.schema_path
1113 ));
1114 }
1115
1116 let depth = args.depth.unwrap_or(10) as usize;
1117 let direction = args.direction.as_deref().unwrap_or("forward");
1118
1119 match direction {
1120 "forward" => {
1121 let reachable = graph.tables_reachable_from(&args.table, depth);
1122 if reachable.is_empty() {
1123 return Ok(CallToolResult::success(vec![Content::text(format!(
1124 "`{}` has no outgoing record<> links",
1125 args.table
1126 ))]));
1127 }
1128
1129 let tree = graph.dependency_tree(&args.table, depth);
1130 let mut output = format!(
1131 "**Forward traversal from `{}`** (max depth: {depth})\n\n\
1132 {} table(s) reachable:\n\n```\n",
1133 args.table,
1134 reachable.len()
1135 );
1136 output.push_str(&format!("[{}]\n", tree.table));
1137 for child in &tree.children {
1138 format_dependency_node_mcp(&mut output, child, 1);
1139 }
1140 output.push_str("```\n\n**Flat list:**\n");
1141 for (name, d, path) in &reachable {
1142 let path_str = path.join(" -> ");
1143 output.push_str(&format!("- `{name}` (depth {d}): {path_str}\n"));
1144 }
1145 Ok(CallToolResult::success(vec![Content::text(output)]))
1146 }
1147 "reverse" => {
1148 let refs = graph.tables_referencing(&args.table);
1149 if refs.is_empty() {
1150 return Ok(CallToolResult::success(vec![Content::text(format!(
1151 "No tables reference `{}`",
1152 args.table
1153 ))]));
1154 }
1155
1156 let mut output = format!(
1157 "**Reverse traversal for `{}`**\n\n\
1158 {} table(s) reference it:\n\n",
1159 args.table,
1160 refs.len()
1161 );
1162 for (ref_table, ref_field) in &refs {
1163 output.push_str(&format!(
1164 "- `{ref_table}.{ref_field}` -> `{}`\n",
1165 args.table
1166 ));
1167 }
1168 Ok(CallToolResult::success(vec![Content::text(output)]))
1169 }
1170 other => error_result(format!(
1171 "Invalid direction '{other}': must be 'forward' or 'reverse'"
1172 )),
1173 }
1174 }
1175
1176 #[tool(
1177 name = "graph_siblings",
1178 description = "Find tables that share record<> link targets with a given table. \
1179 Shows which other tables also point to the same targets."
1180 )]
1181 pub async fn graph_siblings(
1182 &self,
1183 Parameters(args): Parameters<GraphSiblingsArgs>,
1184 ) -> Result<CallToolResult, rmcp::ErrorData> {
1185 let dir = match validate_path_against(&args.schema_path, &self.workspace_root) {
1186 Ok(p) => p,
1187 Err(e) => return error_result(e),
1188 };
1189 if !dir.is_dir() {
1190 return error_result(format!("Not a directory: {}", args.schema_path));
1191 }
1192 let graph = match surql_parser::SchemaGraph::from_files(&dir) {
1193 Ok(g) => g,
1194 Err(e) => return error_result(format!("Failed to build schema graph: {e}")),
1195 };
1196 if graph.table(&args.table).is_none() {
1197 return error_result(format!(
1198 "Table '{}' not found in schema at {}",
1199 args.table, args.schema_path
1200 ));
1201 }
1202
1203 let siblings = graph.siblings_of(&args.table);
1204 if siblings.is_empty() {
1205 return Ok(CallToolResult::success(vec![Content::text(format!(
1206 "`{}` shares no record<> targets with other tables",
1207 args.table
1208 ))]));
1209 }
1210
1211 let mut output = format!("**Siblings of `{}`:**\n\n", args.table);
1212 for (sib, target, field) in &siblings {
1213 output.push_str(&format!(
1214 "- `{sib}` also links to `{target}` (via `.{field}`)\n"
1215 ));
1216 }
1217 Ok(CallToolResult::success(vec![Content::text(output)]))
1218 }
1219
1220 #[tool(name = "reset", description = "Clear the database and start fresh")]
1221 pub async fn reset(&self) -> Result<CallToolResult, rmcp::ErrorData> {
1222 let db = self.db.read().await;
1223 db.query("REMOVE DATABASE default").await.ok();
1225 if let Err(e) = db.use_ns("default").use_db("default").await {
1226 return error_result(format!("Reset failed: {e}"));
1227 }
1228 Ok(CallToolResult::success(vec![Content::text(
1229 "Database cleared",
1230 )]))
1231 }
1232}
1233
1234#[tool_handler]
1235impl rmcp::handler::server::ServerHandler for SurqlMcp {
1236 fn get_info(&self) -> ServerInfo {
1237 ServerInfo {
1238 instructions: Some(
1239 "SurrealQL playground: run queries, load schema files, explore database".into(),
1240 ),
1241 capabilities: ServerCapabilities::builder().enable_tools().build(),
1242 server_info: Implementation {
1243 name: "surql-mcp".into(),
1244 version: env!("CARGO_PKG_VERSION").into(),
1245 title: None,
1246 description: None,
1247 icons: None,
1248 website_url: None,
1249 },
1250 ..Default::default()
1251 }
1252 }
1253}
1254
1255fn format_dependency_node_mcp(
1256 out: &mut String,
1257 node: &surql_parser::DependencyNode,
1258 indent: usize,
1259) {
1260 let prefix = " ".repeat(indent);
1261 let field_label = node
1262 .field
1263 .as_deref()
1264 .map(|f| format!(".{f} -> "))
1265 .unwrap_or_default();
1266 let cycle_label = if node.is_cycle { " (cycle)" } else { "" };
1267 out.push_str(&format!(
1268 "{prefix}{field_label}[{}]{cycle_label}\n",
1269 node.table
1270 ));
1271 if !node.is_cycle {
1272 for child in &node.children {
1273 format_dependency_node_mcp(out, child, indent + 1);
1274 }
1275 }
1276}
1277
1278pub fn result_text(result: &CallToolResult) -> String {
1279 result
1280 .content
1281 .iter()
1282 .filter_map(|c| match &c.raw {
1283 rmcp::model::RawContent::Text(t) => Some(t.text.as_str()),
1284 _ => None,
1285 })
1286 .collect::<Vec<_>>()
1287 .join("\n")
1288}
1289
1290#[cfg(test)]
1291mod tests {
1292 use super::*;
1293 use rmcp::handler::server::wrapper::Parameters;
1294 use std::fs;
1295 use tempfile::TempDir;
1296
1297 #[tokio::test]
1298 async fn should_start_and_run_query_return() {
1299 let server = SurqlMcp::new().await.unwrap();
1300 let result = server
1301 .run_query(Parameters(ExecArgs {
1302 query: "RETURN 42".into(),
1303 }))
1304 .await
1305 .unwrap();
1306 let text = result_text(&result);
1307 assert!(text.contains("42"), "expected 42 in: {text}");
1308 }
1309
1310 #[tokio::test]
1311 async fn should_run_query_create_and_select() {
1312 let server = SurqlMcp::new().await.unwrap();
1313 server
1314 .run_query(Parameters(ExecArgs {
1315 query: "CREATE user:alice SET name = 'Alice'".into(),
1316 }))
1317 .await
1318 .unwrap();
1319
1320 let result = server
1321 .run_query(Parameters(ExecArgs {
1322 query: "SELECT * FROM user".into(),
1323 }))
1324 .await
1325 .unwrap();
1326 let text = result_text(&result);
1327 assert!(text.contains("Alice"), "expected Alice in: {text}");
1328 assert!(text.contains("1 row"), "expected 1 row in: {text}");
1329 }
1330
1331 #[tokio::test]
1332 async fn should_run_query_select_from_nonexistent_returns_empty() {
1333 let server = SurqlMcp::new().await.unwrap();
1334 let result = server
1335 .run_query(Parameters(ExecArgs {
1336 query: "SELECT * FROM nonexistent".into(),
1337 }))
1338 .await
1339 .unwrap();
1340 let text = result_text(&result);
1341 assert!(
1342 text.contains("empty") || result.is_error == Some(true),
1343 "nonexistent table should return empty or error: {text}"
1344 );
1345 }
1346
1347 #[tokio::test]
1348 async fn should_run_query_report_syntax_error() {
1349 let server = SurqlMcp::new().await.unwrap();
1350 let result = server
1351 .run_query(Parameters(ExecArgs {
1352 query: "NOT VALID SQL !!!".into(),
1353 }))
1354 .await
1355 .unwrap();
1356 assert!(result.is_error == Some(true));
1357 }
1358
1359 #[tokio::test]
1360 async fn should_load_project_from_directory() {
1361 let dir = TempDir::new().unwrap();
1362 fs::create_dir_all(dir.path().join("schema")).unwrap();
1363 fs::write(
1364 dir.path().join("schema/tables.surql"),
1365 "DEFINE TABLE user SCHEMAFULL; DEFINE FIELD name ON user TYPE string;",
1366 )
1367 .unwrap();
1368
1369 let server = SurqlMcp::with_workspace_root(dir.path().to_path_buf())
1370 .await
1371 .unwrap();
1372 let result = server
1373 .load_project(Parameters(LoadProjectArgs {
1374 path: dir.path().to_string_lossy().to_string(),
1375 clean: Some(true),
1376 }))
1377 .await
1378 .unwrap();
1379 let text = result_text(&result);
1380 assert!(text.contains("1 schema"), "expected '1 schema' in: {text}");
1381
1382 let select = server
1383 .run_query(Parameters(ExecArgs {
1384 query: "SELECT * FROM user".into(),
1385 }))
1386 .await
1387 .unwrap();
1388 assert!(result_text(&select).contains("empty"));
1389 }
1390
1391 #[tokio::test]
1392 async fn should_load_project_categorize_and_report() {
1393 let dir = TempDir::new().unwrap();
1394 fs::create_dir_all(dir.path().join("schema")).unwrap();
1395 fs::create_dir_all(dir.path().join("migrations")).unwrap();
1396 fs::create_dir_all(dir.path().join("functions")).unwrap();
1397 fs::create_dir_all(dir.path().join("examples")).unwrap();
1398 fs::write(
1399 dir.path().join("schema/tables.surql"),
1400 "DEFINE TABLE user SCHEMAFULL;\n\
1401 DEFINE FIELD name ON user TYPE string;",
1402 )
1403 .unwrap();
1404 fs::write(
1405 dir.path().join("migrations/001_init.surql"),
1406 "CREATE user:alice SET name = 'Alice';",
1407 )
1408 .unwrap();
1409 fs::write(
1410 dir.path().join("functions/greet.surql"),
1411 "DEFINE FUNCTION fn::greet() { RETURN 'hi'; };",
1412 )
1413 .unwrap();
1414 fs::write(
1415 dir.path().join("examples/demo.surql"),
1416 "SELECT * FROM user;",
1417 )
1418 .unwrap();
1419
1420 let server = SurqlMcp::with_workspace_root(dir.path().to_path_buf())
1421 .await
1422 .unwrap();
1423 let result = server
1424 .load_project(Parameters(LoadProjectArgs {
1425 path: dir.path().to_string_lossy().to_string(),
1426 clean: Some(true),
1427 }))
1428 .await
1429 .unwrap();
1430 let text = result_text(&result);
1431 assert!(text.contains("1 schema"), "expected 1 schema in: {text}");
1432 assert!(
1433 text.contains("1 migrations"),
1434 "expected 1 migrations in: {text}"
1435 );
1436 assert!(
1437 text.contains("1 functions"),
1438 "expected 1 functions in: {text}"
1439 );
1440 assert!(
1441 text.contains("1 examples"),
1442 "expected 1 examples in: {text}"
1443 );
1444 }
1445
1446 #[tokio::test]
1447 async fn should_load_project_example_errors_become_warnings() {
1448 let dir = TempDir::new().unwrap();
1449 fs::create_dir_all(dir.path().join("examples")).unwrap();
1450 fs::write(dir.path().join("examples/bad.surql"), "NOT VALID SQL !!!").unwrap();
1451
1452 let server = SurqlMcp::with_workspace_root(dir.path().to_path_buf())
1453 .await
1454 .unwrap();
1455 let result = server
1456 .load_project(Parameters(LoadProjectArgs {
1457 path: dir.path().to_string_lossy().to_string(),
1458 clean: Some(true),
1459 }))
1460 .await
1461 .unwrap();
1462 let text = result_text(&result);
1463 assert!(
1464 text.contains("Warnings"),
1465 "example errors should be warnings: {text}"
1466 );
1467 assert!(
1468 !text.contains("**Errors"),
1469 "example errors should NOT appear as errors: {text}"
1470 );
1471 }
1472
1473 #[tokio::test]
1474 async fn should_load_project_inject_overwrite_for_schema() {
1475 let dir = TempDir::new().unwrap();
1476 fs::create_dir_all(dir.path().join("schema")).unwrap();
1477 fs::write(
1478 dir.path().join("schema/tables.surql"),
1479 "DEFINE TABLE user SCHEMAFULL;",
1480 )
1481 .unwrap();
1482
1483 let server = SurqlMcp::with_workspace_root(dir.path().to_path_buf())
1484 .await
1485 .unwrap();
1486
1487 server
1489 .load_project(Parameters(LoadProjectArgs {
1490 path: dir.path().to_string_lossy().to_string(),
1491 clean: Some(true),
1492 }))
1493 .await
1494 .unwrap();
1495 let result = server
1496 .load_project(Parameters(LoadProjectArgs {
1497 path: dir.path().to_string_lossy().to_string(),
1498 clean: Some(false),
1499 }))
1500 .await
1501 .unwrap();
1502 let text = result_text(&result);
1503 assert!(
1504 text.contains("1 schema"),
1505 "second load with OVERWRITE should succeed: {text}"
1506 );
1507 assert!(
1508 !text.contains("**Errors"),
1509 "second load should not have errors: {text}"
1510 );
1511 }
1512
1513 #[tokio::test]
1514 async fn should_load_project_reject_nonexistent_dir() {
1515 let server = SurqlMcp::new().await.unwrap();
1516 let result = server
1517 .load_project(Parameters(LoadProjectArgs {
1518 path: "/nonexistent/path".into(),
1519 clean: None,
1520 }))
1521 .await
1522 .unwrap();
1523 assert!(result.is_error == Some(true));
1524 }
1525
1526 #[tokio::test]
1527 async fn should_schema_return_db_info() {
1528 let server = SurqlMcp::new().await.unwrap();
1529 server
1530 .run_query(Parameters(ExecArgs {
1531 query: "DEFINE TABLE user SCHEMAFULL".into(),
1532 }))
1533 .await
1534 .unwrap();
1535
1536 let result = server.schema().await.unwrap();
1537 let text = result_text(&result);
1538 assert!(text.contains("user"), "expected user in schema: {text}");
1539 }
1540
1541 #[tokio::test]
1542 async fn should_describe_return_table_info() {
1543 let server = SurqlMcp::new().await.unwrap();
1544 server
1545 .run_query(Parameters(ExecArgs {
1546 query: "DEFINE TABLE post SCHEMAFULL; \
1547 DEFINE FIELD title ON post TYPE string"
1548 .into(),
1549 }))
1550 .await
1551 .unwrap();
1552
1553 let result = server
1554 .describe(Parameters(DescribeArgs {
1555 table: "post".into(),
1556 }))
1557 .await
1558 .unwrap();
1559 let text = result_text(&result);
1560 assert!(text.contains("post"), "expected post in: {text}");
1561 assert!(text.contains("title"), "expected title field in: {text}");
1562 }
1563
1564 #[tokio::test]
1565 async fn should_reset_clear_all_data() {
1566 let server = SurqlMcp::new().await.unwrap();
1567 server
1568 .run_query(Parameters(ExecArgs {
1569 query: "CREATE user:alice SET name = 'Alice'".into(),
1570 }))
1571 .await
1572 .unwrap();
1573
1574 server.reset().await.unwrap();
1575
1576 let result = server
1577 .run_query(Parameters(ExecArgs {
1578 query: "SELECT * FROM user".into(),
1579 }))
1580 .await
1581 .unwrap();
1582 assert!(
1583 result.is_error == Some(true),
1584 "expected error after reset (table gone)"
1585 );
1586 }
1587
1588 #[tokio::test]
1589 async fn should_load_file_single() {
1590 let dir = TempDir::new().unwrap();
1591 let file = dir.path().join("schema.surql");
1592 fs::write(&file, "DEFINE TABLE test SCHEMAFULL;").unwrap();
1593
1594 let server = SurqlMcp::with_workspace_root(dir.path().to_path_buf())
1595 .await
1596 .unwrap();
1597 let result = server
1598 .load_file(Parameters(LoadFileArgs {
1599 path: file.to_string_lossy().to_string(),
1600 }))
1601 .await
1602 .unwrap();
1603 let text = result_text(&result);
1604 assert!(text.contains("Applied"), "expected Applied in: {text}");
1605 }
1606
1607 #[tokio::test]
1608 async fn should_load_file_report_error_for_missing() {
1609 let server = SurqlMcp::new().await.unwrap();
1610 let result = server
1611 .load_file(Parameters(LoadFileArgs {
1612 path: "/nonexistent.surql".into(),
1613 }))
1614 .await
1615 .unwrap();
1616 assert!(result.is_error == Some(true));
1617 }
1618
1619 #[tokio::test]
1620 async fn should_reject_describe_with_injection() {
1621 let server = SurqlMcp::new().await.unwrap();
1622 let result = server
1623 .describe(Parameters(DescribeArgs {
1624 table: "user; REMOVE DATABASE default".into(),
1625 }))
1626 .await
1627 .unwrap();
1628 assert!(
1629 result.is_error == Some(true),
1630 "should reject table name with injection"
1631 );
1632 }
1633
1634 #[test]
1635 fn should_categorize_files_by_directory() {
1636 let schema = PathBuf::from("/project/schema.surql");
1637 let schema_dir = PathBuf::from("/project/schema/tables.surql");
1638 let migrations = PathBuf::from("/project/migrations/001.surql");
1639 let example = PathBuf::from("/project/examples/demo.surql");
1640 let seed = PathBuf::from("/project/seed/data.surql");
1641 let func = PathBuf::from("/project/functions/auth.surql");
1642 let func_file = PathBuf::from("/project/functions.surql");
1643 let other = PathBuf::from("/project/queries.surql");
1644
1645 assert_eq!(classify_file(&schema), FileCategory::Schema);
1646 assert_eq!(classify_file(&schema_dir), FileCategory::Schema);
1647 assert_eq!(classify_file(&migrations), FileCategory::Migration);
1648 assert_eq!(classify_file(&example), FileCategory::Example);
1649 assert_eq!(classify_file(&seed), FileCategory::Example);
1650 assert_eq!(classify_file(&func), FileCategory::Function);
1651 assert_eq!(classify_file(&func_file), FileCategory::Function);
1652 assert_eq!(classify_file(&other), FileCategory::General);
1653 }
1654
1655 #[test]
1656 fn should_inject_overwrite_into_define_statements() {
1657 let input = "\
1658DEFINE TABLE user SCHEMAFULL;
1659DEFINE FIELD name ON user TYPE string;
1660DEFINE INDEX user_name ON user FIELDS name UNIQUE;
1661DEFINE FUNCTION fn::greet() { RETURN 'hi'; };";
1662
1663 let result = inject_overwrite(input);
1664 assert!(
1665 result.contains("DEFINE TABLE OVERWRITE user"),
1666 "expected OVERWRITE after DEFINE TABLE: {result}"
1667 );
1668 assert!(
1669 result.contains("DEFINE FIELD OVERWRITE name"),
1670 "expected OVERWRITE after DEFINE FIELD: {result}"
1671 );
1672 assert!(
1673 result.contains("DEFINE INDEX OVERWRITE user_name"),
1674 "expected OVERWRITE after DEFINE INDEX: {result}"
1675 );
1676 assert!(
1677 result.contains("DEFINE FUNCTION OVERWRITE fn::greet"),
1678 "expected OVERWRITE after DEFINE FUNCTION: {result}"
1679 );
1680 }
1681
1682 #[test]
1683 fn should_not_double_inject_overwrite() {
1684 let input = "DEFINE TABLE OVERWRITE user SCHEMAFULL;";
1685 let result = inject_overwrite(input);
1686 assert_eq!(
1687 result.matches("OVERWRITE").count(),
1688 1,
1689 "should not double-inject OVERWRITE: {result}"
1690 );
1691 }
1692
1693 #[test]
1694 fn should_not_inject_overwrite_when_if_not_exists() {
1695 let input = "DEFINE TABLE IF NOT EXISTS user SCHEMAFULL;";
1696 let result = inject_overwrite(input);
1697 assert!(
1698 !result.contains("OVERWRITE"),
1699 "should not inject OVERWRITE when IF NOT EXISTS is present: {result}"
1700 );
1701 }
1702
1703 #[test]
1704 fn should_inject_overwrite_preserve_indentation() {
1705 let input = " \tDEFINE TABLE user SCHEMAFULL;";
1706 let result = inject_overwrite(input);
1707 assert!(
1708 result.starts_with(" \tDEFINE TABLE OVERWRITE user"),
1709 "should preserve leading whitespace: {result}"
1710 );
1711 }
1712
1713 #[test]
1714 fn should_inject_overwrite_all_supported_keywords() {
1715 let input = "\
1716DEFINE TABLE t1;
1717DEFINE FIELD f1 ON t1 TYPE string;
1718DEFINE INDEX i1 ON t1 FIELDS f1;
1719DEFINE FUNCTION fn::x() { RETURN 1; };
1720DEFINE EVENT e1 ON t1 WHEN true THEN {};
1721DEFINE ANALYZER a1 TOKENIZERS blank;
1722DEFINE PARAM $p VALUE 1;";
1723
1724 let result = inject_overwrite(input);
1725 assert!(result.contains("DEFINE TABLE OVERWRITE"), "{result}");
1726 assert!(result.contains("DEFINE FIELD OVERWRITE"), "{result}");
1727 assert!(result.contains("DEFINE INDEX OVERWRITE"), "{result}");
1728 assert!(result.contains("DEFINE FUNCTION OVERWRITE"), "{result}");
1729 assert!(result.contains("DEFINE EVENT OVERWRITE"), "{result}");
1730 assert!(result.contains("DEFINE ANALYZER OVERWRITE"), "{result}");
1731 assert!(result.contains("DEFINE PARAM OVERWRITE"), "{result}");
1732 }
1733
1734 #[test]
1735 fn should_inject_overwrite_case_insensitive() {
1736 let input = "define table user SCHEMAFULL;";
1737 let result = inject_overwrite(input);
1738 assert!(
1739 result.contains("define table OVERWRITE user"),
1740 "should handle case-insensitive DEFINE: {result}"
1741 );
1742 }
1743
1744 #[test]
1745 fn should_not_inject_overwrite_in_line_comments() {
1746 let input = "-- DEFINE TABLE user SCHEMAFULL;\nDEFINE TABLE post;";
1747 let result = inject_overwrite(input);
1748 assert!(
1749 result.contains("-- DEFINE TABLE user SCHEMAFULL;"),
1750 "should not inject OVERWRITE inside line comment: {result}"
1751 );
1752 assert!(
1753 result.contains("DEFINE TABLE OVERWRITE post"),
1754 "should still inject OVERWRITE outside comment: {result}"
1755 );
1756 }
1757
1758 #[test]
1759 fn should_not_inject_overwrite_in_block_comments() {
1760 let input = "/*\nDEFINE TABLE user SCHEMAFULL;\n*/\nDEFINE TABLE post;";
1761 let result = inject_overwrite(input);
1762 assert!(
1763 !result.contains("DEFINE TABLE OVERWRITE user"),
1764 "should not inject OVERWRITE inside block comment: {result}"
1765 );
1766 assert!(
1767 result.contains("DEFINE TABLE OVERWRITE post"),
1768 "should still inject OVERWRITE after block comment: {result}"
1769 );
1770 }
1771
1772 #[tokio::test]
1773 async fn should_read_overshift_manifest() {
1774 let dir = TempDir::new().unwrap();
1775 fs::write(
1776 dir.path().join("manifest.toml"),
1777 "[meta]\nns = \"myapp\"\ndb = \"main\"\nsystem_db = \"_system\"\n\n\
1778 [[modules]]\nname = \"auth\"\npath = \"schema/auth\"\ndepends_on = []\n",
1779 )
1780 .unwrap();
1781 fs::create_dir_all(dir.path().join("migrations")).unwrap();
1782 fs::write(
1783 dir.path().join("migrations/v001_init.surql"),
1784 "DEFINE TABLE user;",
1785 )
1786 .unwrap();
1787
1788 let server = SurqlMcp::with_workspace_root(dir.path().to_path_buf())
1789 .await
1790 .unwrap();
1791 let result = server
1792 .manifest(Parameters(ManifestArgs {
1793 path: dir.path().to_string_lossy().to_string(),
1794 }))
1795 .await
1796 .unwrap();
1797 let text = result_text(&result);
1798 assert!(text.contains("myapp"), "expected ns in: {text}");
1799 assert!(text.contains("main"), "expected db in: {text}");
1800 assert!(text.contains("auth"), "expected module in: {text}");
1801 assert!(
1802 text.contains("1 migration"),
1803 "expected migrations in: {text}"
1804 );
1805 }
1806
1807 #[tokio::test]
1808 async fn should_reject_missing_manifest() {
1809 let server = SurqlMcp::new().await.unwrap();
1810 let result = server
1811 .manifest(Parameters(ManifestArgs {
1812 path: "/nonexistent".into(),
1813 }))
1814 .await
1815 .unwrap();
1816 assert!(result.is_error == Some(true));
1817 }
1818
1819 #[tokio::test]
1820 async fn should_compare_detect_missing_table() {
1821 let server = SurqlMcp::new().await.unwrap();
1822 server
1823 .run_query(Parameters(ExecArgs {
1824 query: "DEFINE TABLE user SCHEMAFULL".into(),
1825 }))
1826 .await
1827 .unwrap();
1828
1829 let expected_json = serde_json::json!({
1830 "tables": {
1831 "user": "DEFINE TABLE user TYPE NORMAL SCHEMAFULL",
1832 "post": "DEFINE TABLE post TYPE NORMAL SCHEMAFULL"
1833 },
1834 "functions": {}
1835 });
1836
1837 let result = server
1838 .compare(Parameters(CompareArgs {
1839 expected_json: expected_json.to_string(),
1840 }))
1841 .await
1842 .unwrap();
1843 let text = result_text(&result);
1844 assert!(
1845 text.contains("post") && text.contains("missing"),
1846 "expected missing table 'post' in diff: {text}"
1847 );
1848 }
1849
1850 #[tokio::test]
1851 async fn should_compare_return_match_when_identical() {
1852 let server = SurqlMcp::new().await.unwrap();
1853 server
1854 .run_query(Parameters(ExecArgs {
1855 query: "DEFINE TABLE user SCHEMAFULL; \
1856 DEFINE TABLE post SCHEMAFULL"
1857 .into(),
1858 }))
1859 .await
1860 .unwrap();
1861
1862 let schema_result = server.schema().await.unwrap();
1863 let schema_text = result_text(&schema_result);
1864 let json_start = schema_text.find('{').expect("schema should contain JSON");
1865 let json_end = schema_text.rfind('}').expect("schema should contain JSON") + 1;
1866 let raw_json = &schema_text[json_start..json_end];
1867
1868 let result = server
1869 .compare(Parameters(CompareArgs {
1870 expected_json: raw_json.to_string(),
1871 }))
1872 .await
1873 .unwrap();
1874 let text = result_text(&result);
1875 assert!(
1876 text.contains("Schema matches"),
1877 "expected 'Schema matches' for identical schemas: {text}"
1878 );
1879 }
1880
1881 #[tokio::test]
1882 async fn should_verify_matching_project() {
1883 let dir = TempDir::new().unwrap();
1884 fs::write(
1885 dir.path().join("manifest.toml"),
1886 "[meta]\nns = \"test\"\ndb = \"main\"\nsystem_db = \"_system\"\n\n\
1887 [[modules]]\nname = \"core\"\npath = \"schema/core\"\n",
1888 )
1889 .unwrap();
1890 fs::create_dir_all(dir.path().join("schema/core")).unwrap();
1891 fs::write(
1892 dir.path().join("schema/core/tables.surql"),
1893 "DEFINE TABLE user SCHEMAFULL;\n\
1894 DEFINE FIELD name ON user TYPE string;",
1895 )
1896 .unwrap();
1897
1898 let server = SurqlMcp::with_workspace_root(dir.path().to_path_buf())
1899 .await
1900 .unwrap();
1901 let result = server
1902 .verify(Parameters(VerifyArgs {
1903 verify_only: None,
1904 path: dir.path().to_string_lossy().to_string(),
1905 }))
1906 .await
1907 .unwrap();
1908 let text = result_text(&result);
1909 assert!(
1910 text.contains("Schema matches"),
1911 "expected matching schemas in: {text}"
1912 );
1913 assert!(text.contains("1 module(s)"), "expected 1 module in: {text}");
1914 }
1915
1916 #[tokio::test]
1917 async fn should_verify_reject_missing_manifest() {
1918 let server = SurqlMcp::new().await.unwrap();
1919 let result = server
1920 .verify(Parameters(VerifyArgs {
1921 verify_only: None,
1922 path: "/nonexistent".into(),
1923 }))
1924 .await
1925 .unwrap();
1926 assert!(result.is_error == Some(true));
1927 }
1928
1929 #[tokio::test]
1930 async fn should_check_valid_file() {
1931 let dir = TempDir::new().unwrap();
1932 let file = dir.path().join("schema.surql");
1933 fs::write(&file, "DEFINE TABLE user SCHEMAFULL;").unwrap();
1934
1935 let server = SurqlMcp::with_workspace_root(dir.path().to_path_buf())
1936 .await
1937 .unwrap();
1938 let result = server
1939 .check(Parameters(CheckArgs {
1940 path: file.to_string_lossy().to_string(),
1941 recursive: None,
1942 }))
1943 .await
1944 .unwrap();
1945 let text = result_text(&result);
1946 assert!(
1947 text.contains("1 file checked") && text.contains("0 errors"),
1948 "expected no errors for valid file: {text}"
1949 );
1950 }
1951
1952 #[tokio::test]
1953 async fn should_check_invalid_file_report_errors() {
1954 let dir = TempDir::new().unwrap();
1955 let file = dir.path().join("broken.surql");
1956 fs::write(&file, "SELEC * FORM user;").unwrap();
1957
1958 let server = SurqlMcp::with_workspace_root(dir.path().to_path_buf())
1959 .await
1960 .unwrap();
1961 let result = server
1962 .check(Parameters(CheckArgs {
1963 path: file.to_string_lossy().to_string(),
1964 recursive: None,
1965 }))
1966 .await
1967 .unwrap();
1968 let text = result_text(&result);
1969 assert!(
1970 !text.contains("0 errors"),
1971 "expected errors for invalid file: {text}"
1972 );
1973 }
1974
1975 #[tokio::test]
1976 async fn should_check_directory_recursively() {
1977 let dir = TempDir::new().unwrap();
1978 let sub = dir.path().join("schemas");
1979 fs::create_dir_all(&sub).unwrap();
1980 fs::write(sub.join("a.surql"), "DEFINE TABLE a;").unwrap();
1981 fs::write(dir.path().join("b.surql"), "DEFINE TABLE b;").unwrap();
1982
1983 let server = SurqlMcp::with_workspace_root(dir.path().to_path_buf())
1984 .await
1985 .unwrap();
1986 let result = server
1987 .check(Parameters(CheckArgs {
1988 path: dir.path().to_string_lossy().to_string(),
1989 recursive: Some(true),
1990 }))
1991 .await
1992 .unwrap();
1993 let text = result_text(&result);
1994 assert!(
1995 text.contains("2 files checked"),
1996 "expected 2 files checked: {text}"
1997 );
1998 }
1999
2000 #[tokio::test]
2001 async fn should_check_nonrecursive_skip_subdirs() {
2002 let dir = TempDir::new().unwrap();
2003 let sub = dir.path().join("schemas");
2004 fs::create_dir_all(&sub).unwrap();
2005 fs::write(sub.join("a.surql"), "DEFINE TABLE a;").unwrap();
2006 fs::write(dir.path().join("b.surql"), "DEFINE TABLE b;").unwrap();
2007
2008 let server = SurqlMcp::with_workspace_root(dir.path().to_path_buf())
2009 .await
2010 .unwrap();
2011 let result = server
2012 .check(Parameters(CheckArgs {
2013 path: dir.path().to_string_lossy().to_string(),
2014 recursive: Some(false),
2015 }))
2016 .await
2017 .unwrap();
2018 let text = result_text(&result);
2019 assert!(
2020 text.contains("1 file checked"),
2021 "expected 1 file checked (non-recursive): {text}"
2022 );
2023 }
2024
2025 #[tokio::test]
2026 async fn should_check_reject_nonexistent_path() {
2027 let server = SurqlMcp::new().await.unwrap();
2028 let result = server
2029 .check(Parameters(CheckArgs {
2030 path: "/nonexistent/path.surql".into(),
2031 recursive: None,
2032 }))
2033 .await
2034 .unwrap();
2035 assert!(
2036 result.is_error == Some(true),
2037 "expected error for nonexistent path"
2038 );
2039 }
2040
2041 #[tokio::test]
2042 async fn should_check_empty_directory() {
2043 let dir = TempDir::new().unwrap();
2044
2045 let server = SurqlMcp::with_workspace_root(dir.path().to_path_buf())
2046 .await
2047 .unwrap();
2048 let result = server
2049 .check(Parameters(CheckArgs {
2050 path: dir.path().to_string_lossy().to_string(),
2051 recursive: None,
2052 }))
2053 .await
2054 .unwrap();
2055 let text = result_text(&result);
2056 assert!(
2057 text.contains("No .surql files found"),
2058 "expected no files found: {text}"
2059 );
2060 }
2061
2062 fn write_graph_schema(dir: &std::path::Path) {
2063 fs::create_dir_all(dir.join("schema")).unwrap();
2064 fs::write(
2065 dir.join("schema/tables.surql"),
2066 "DEFINE TABLE user SCHEMAFULL;\n\
2067 DEFINE TABLE post SCHEMAFULL;\n\
2068 DEFINE TABLE comment SCHEMAFULL;\n\
2069 DEFINE FIELD author ON post TYPE record<user>;\n\
2070 DEFINE FIELD post ON comment TYPE record<post>;\n\
2071 DEFINE FIELD author ON comment TYPE record<user>;\n",
2072 )
2073 .unwrap();
2074 }
2075
2076 #[tokio::test]
2077 async fn should_graph_affected_find_dependents() {
2078 let dir = TempDir::new().unwrap();
2079 write_graph_schema(dir.path());
2080
2081 let server = SurqlMcp::with_workspace_root(dir.path().to_path_buf())
2082 .await
2083 .unwrap();
2084 let result = server
2085 .graph_affected(Parameters(GraphAffectedArgs {
2086 table: "user".into(),
2087 schema_path: dir.path().to_string_lossy().to_string(),
2088 }))
2089 .await
2090 .unwrap();
2091 let text = result_text(&result);
2092 assert!(
2093 text.contains("comment.author"),
2094 "expected comment.author in: {text}"
2095 );
2096 assert!(
2097 text.contains("post.author"),
2098 "expected post.author in: {text}"
2099 );
2100 }
2101
2102 #[tokio::test]
2103 async fn should_graph_affected_report_no_dependents() {
2104 let dir = TempDir::new().unwrap();
2105 write_graph_schema(dir.path());
2106
2107 let server = SurqlMcp::with_workspace_root(dir.path().to_path_buf())
2108 .await
2109 .unwrap();
2110 let result = server
2111 .graph_affected(Parameters(GraphAffectedArgs {
2112 table: "comment".into(),
2113 schema_path: dir.path().to_string_lossy().to_string(),
2114 }))
2115 .await
2116 .unwrap();
2117 let text = result_text(&result);
2118 assert!(
2119 text.contains("No tables reference"),
2120 "expected no references for leaf table: {text}"
2121 );
2122 }
2123
2124 #[tokio::test]
2125 async fn should_graph_affected_reject_missing_table() {
2126 let dir = TempDir::new().unwrap();
2127 write_graph_schema(dir.path());
2128
2129 let server = SurqlMcp::with_workspace_root(dir.path().to_path_buf())
2130 .await
2131 .unwrap();
2132 let result = server
2133 .graph_affected(Parameters(GraphAffectedArgs {
2134 table: "nonexistent".into(),
2135 schema_path: dir.path().to_string_lossy().to_string(),
2136 }))
2137 .await
2138 .unwrap();
2139 assert!(
2140 result.is_error == Some(true),
2141 "expected error for nonexistent table"
2142 );
2143 }
2144
2145 #[tokio::test]
2146 async fn should_graph_traverse_forward() {
2147 let dir = TempDir::new().unwrap();
2148 write_graph_schema(dir.path());
2149
2150 let server = SurqlMcp::with_workspace_root(dir.path().to_path_buf())
2151 .await
2152 .unwrap();
2153 let result = server
2154 .graph_traverse(Parameters(GraphTraverseArgs {
2155 table: "comment".into(),
2156 schema_path: dir.path().to_string_lossy().to_string(),
2157 depth: None,
2158 direction: Some("forward".into()),
2159 }))
2160 .await
2161 .unwrap();
2162 let text = result_text(&result);
2163 assert!(text.contains("post"), "expected post in traversal: {text}");
2164 assert!(text.contains("user"), "expected user in traversal: {text}");
2165 }
2166
2167 #[tokio::test]
2168 async fn should_graph_traverse_reverse() {
2169 let dir = TempDir::new().unwrap();
2170 write_graph_schema(dir.path());
2171
2172 let server = SurqlMcp::with_workspace_root(dir.path().to_path_buf())
2173 .await
2174 .unwrap();
2175 let result = server
2176 .graph_traverse(Parameters(GraphTraverseArgs {
2177 table: "user".into(),
2178 schema_path: dir.path().to_string_lossy().to_string(),
2179 depth: None,
2180 direction: Some("reverse".into()),
2181 }))
2182 .await
2183 .unwrap();
2184 let text = result_text(&result);
2185 assert!(
2186 text.contains("post.author"),
2187 "expected post.author in reverse: {text}"
2188 );
2189 assert!(
2190 text.contains("comment.author"),
2191 "expected comment.author in reverse: {text}"
2192 );
2193 }
2194
2195 #[tokio::test]
2196 async fn should_graph_traverse_reject_invalid_direction() {
2197 let dir = TempDir::new().unwrap();
2198 write_graph_schema(dir.path());
2199
2200 let server = SurqlMcp::with_workspace_root(dir.path().to_path_buf())
2201 .await
2202 .unwrap();
2203 let result = server
2204 .graph_traverse(Parameters(GraphTraverseArgs {
2205 table: "user".into(),
2206 schema_path: dir.path().to_string_lossy().to_string(),
2207 depth: None,
2208 direction: Some("sideways".into()),
2209 }))
2210 .await
2211 .unwrap();
2212 assert!(
2213 result.is_error == Some(true),
2214 "expected error for invalid direction"
2215 );
2216 }
2217
2218 #[tokio::test]
2219 async fn should_graph_siblings_find_shared_targets() {
2220 let dir = TempDir::new().unwrap();
2221 write_graph_schema(dir.path());
2222
2223 let server = SurqlMcp::with_workspace_root(dir.path().to_path_buf())
2224 .await
2225 .unwrap();
2226 let result = server
2227 .graph_siblings(Parameters(GraphSiblingsArgs {
2228 table: "post".into(),
2229 schema_path: dir.path().to_string_lossy().to_string(),
2230 }))
2231 .await
2232 .unwrap();
2233 let text = result_text(&result);
2234 assert!(
2235 text.contains("comment"),
2236 "expected comment as sibling of post (both link to user): {text}"
2237 );
2238 }
2239
2240 #[tokio::test]
2241 async fn should_graph_siblings_report_no_siblings() {
2242 let dir = TempDir::new().unwrap();
2243 fs::create_dir_all(dir.path().join("schema")).unwrap();
2244 fs::write(
2245 dir.path().join("schema/tables.surql"),
2246 "DEFINE TABLE solo SCHEMAFULL;\n\
2247 DEFINE FIELD name ON solo TYPE string;\n",
2248 )
2249 .unwrap();
2250
2251 let server = SurqlMcp::with_workspace_root(dir.path().to_path_buf())
2252 .await
2253 .unwrap();
2254 let result = server
2255 .graph_siblings(Parameters(GraphSiblingsArgs {
2256 table: "solo".into(),
2257 schema_path: dir.path().to_string_lossy().to_string(),
2258 }))
2259 .await
2260 .unwrap();
2261 let text = result_text(&result);
2262 assert!(
2263 text.contains("shares no record<> targets"),
2264 "expected no siblings for isolated table: {text}"
2265 );
2266 }
2267}