Skip to main content

surql_mcp/
lib.rs

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			// Reset to known state: switch to default NS/DB first, then remove the database
234			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			// Re-create the default database after removal
239			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		// 1. Schema files (with OVERWRITE injection)
264		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		// 2. Function files (with OVERWRITE injection)
282		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		// 3. Migration files (in version order, one-shot)
300		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		// 4. General files
320		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		// 5. Example files (errors become warnings)
338		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		// Discover migrations via overshift
519		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		// Reset DB
554		let db = self.db.read().await;
555		// REMOVE DATABASE may fail if it doesn't exist yet -- safe to ignore
556		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		// Apply schema modules first
568		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		// Then migrations
583		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		// Apply to playground DB (skip in verify-only mode)
685		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		// Create shadow in-memory DB and apply the same project
730		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		// Get INFO FOR DB from both
798		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		// Reset playground: switch to NS/DB then remove+recreate so we rebuild from scratch.
970		// In-memory playground does not need down.surql — we rebuild state up to target_version.
971		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		// Re-apply schema modules (same as load_manifest)
981		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		// Discover and re-apply migrations only up to target_version
995		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		// REMOVE DATABASE may fail if it doesn't exist yet -- safe to ignore
1224		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		// Load twice -- second load should succeed due to OVERWRITE injection
1488		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}