From 8e0fdbb3fc3bf801d58039679c14087fafb40e4f Mon Sep 17 00:00:00 2001 From: Radim Marek Date: Fri, 8 May 2026 20:16:08 +0200 Subject: [PATCH 01/10] chore: trim tool/param descriptions --- crates/dry_run_cli/src/mcp/params.rs | 22 ++++++------ crates/dry_run_cli/src/mcp/server.rs | 54 ++++++++-------------------- 2 files changed, 25 insertions(+), 51 deletions(-) diff --git a/crates/dry_run_cli/src/mcp/params.rs b/crates/dry_run_cli/src/mcp/params.rs index 3565a01..198ed28 100644 --- a/crates/dry_run_cli/src/mcp/params.rs +++ b/crates/dry_run_cli/src/mcp/params.rs @@ -3,7 +3,7 @@ use serde::Deserialize; #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct ListTablesParams { #[serde(default)] - #[schemars(description = "Schema filter (default: all schemas).")] + #[schemars(description = "Schema filter")] pub schema: Option, #[serde(default)] #[schemars(description = "Sort by: 'name' (default), 'rows', or 'size'.")] @@ -20,7 +20,7 @@ pub struct ListTablesParams { pub struct DescribeTableParams { pub table: String, #[serde(default)] - #[schemars(description = "Schema filter (default: all schemas).")] + #[schemars(description = "Schema filter")] pub schema: Option, #[serde(default)] #[schemars( @@ -45,7 +45,7 @@ pub struct SearchSchemaParams { pub struct FindRelatedParams { pub table: String, #[serde(default)] - #[schemars(description = "Schema filter (default: all schemas).")] + #[schemars(description = "Schema filter")] pub schema: Option, } @@ -102,10 +102,10 @@ pub struct CheckMigrationParams { #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct LintSchemaParams { #[serde(default)] - #[schemars(description = "Schema filter (default: all schemas).")] + #[schemars(description = "Schema filter")] pub schema: Option, #[serde(default)] - #[schemars(description = "Table filter (default: all tables).")] + #[schemars(description = "Table filter")] pub table: Option, #[serde(default)] #[schemars( @@ -118,27 +118,27 @@ pub struct LintSchemaParams { pub struct DetectParams { #[serde(default)] #[schemars( - description = "Detection kind: stale_stats, unused_indexes, bloated_indexes, or all (default)." + description = "Detection kind: stale_stats, unused_indexes, bloated_indexes, anomalies, or all (default)" )] pub kind: Option, #[serde(default)] #[schemars(description = "Bloat ratio threshold (default 1.5).")] pub threshold: Option, #[serde(default)] - #[schemars(description = "Schema filter (default: all schemas).")] + #[schemars(description = "Schema filter")] pub schema: Option, #[serde(default)] - #[schemars(description = "Table filter (default: all tables).")] + #[schemars(description = "Table filter")] pub table: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct VacuumHealthParams { #[serde(default)] - #[schemars(description = "Schema filter (default: all schemas).")] + #[schemars(description = "Schema filter")] pub schema: Option, #[serde(default)] - #[schemars(description = "Table filter (default: all tables).")] + #[schemars(description = "Table filter")] pub table: Option, } @@ -146,7 +146,7 @@ pub struct VacuumHealthParams { pub struct CompareNodesParams { pub table: String, #[serde(default)] - #[schemars(description = "Schema filter (default: all schemas).")] + #[schemars(description = "Schema filter")] pub schema: Option, } diff --git a/crates/dry_run_cli/src/mcp/server.rs b/crates/dry_run_cli/src/mcp/server.rs index 3c8510d..863887d 100644 --- a/crates/dry_run_cli/src/mcp/server.rs +++ b/crates/dry_run_cli/src/mcp/server.rs @@ -364,9 +364,7 @@ impl DryRunServer { Ok(CallToolResult::success(vec![Content::text(text)])) } - #[tool( - description = "Table columns, types, constraints, indexes and stats. Per-node stats when present." - )] + #[tool(description = "Table columns, types, constraints, indexes, stats")] async fn describe_table( &self, Parameters(params): Parameters, @@ -814,9 +812,7 @@ impl DryRunServer { Ok(CallToolResult::success(vec![Content::text(text)])) } - #[tool( - description = "Diff two snapshots, or the latest snapshot against the live schema. Needs --history." - )] + #[tool(description = "Diff two snapshots, or the latest against live schema")] async fn schema_diff( &self, Parameters(params): Parameters, @@ -867,9 +863,7 @@ impl DryRunServer { Ok(CallToolResult::success(vec![Content::text(json)])) } - #[tool( - description = "Parse SQL and check it against the schema. Flags missing tables or columns and common anti-patterns. Offline." - )] + #[tool(description = "Validate SQL against the schema; flags missing refs and anti-patterns")] async fn validate_query( &self, Parameters(params): Parameters, @@ -899,9 +893,7 @@ impl DryRunServer { Ok(CallToolResult::success(vec![Content::text(json)])) } - #[tool( - description = "Run EXPLAIN on a query. Pass analyze=true to run EXPLAIN ANALYZE. Needs live DB." - )] + #[tool(description = "EXPLAIN a query (analyze=true runs EXPLAIN ANALYZE)")] async fn explain_query( &self, Parameters(params): Parameters, @@ -939,9 +931,7 @@ impl DryRunServer { Ok(CallToolResult::success(vec![Content::text(json)])) } - #[tool( - description = "Plan analysis, anti-pattern checks and index suggestions for a query. Uses EXPLAIN when a live DB is available, static analysis otherwise." - )] + #[tool(description = "Plan, anti-pattern, and index advice for a query")] async fn advise( &self, Parameters(params): Parameters, @@ -1018,9 +1008,7 @@ impl DryRunServer { Ok(CallToolResult::success(vec![Content::text(json)])) } - #[tool( - description = "Analyze an existing EXPLAIN plan (JSON) against the schema. Returns warnings, index and safety hints. Offline." - )] + #[tool(description = "Analyze an EXPLAIN JSON plan against the schema")] async fn analyze_plan( &self, Parameters(params): Parameters, @@ -1120,9 +1108,7 @@ impl DryRunServer { Ok(CallToolResult::success(vec![Content::text(json)])) } - #[tool( - description = "Check a DDL statement for lock level, duration, table-size impact, and suggest safer alternatives." - )] + #[tool(description = "Check DDL for lock level, duration, and safer alternatives")] async fn check_migration( &self, Parameters(params): Parameters, @@ -1163,9 +1149,7 @@ impl DryRunServer { Ok(CallToolResult::success(vec![Content::text(json)])) } - #[tool( - description = "Schema quality checks. scope=conventions, audit, or all (default). Offline." - )] + #[tool(description = "Schema quality checks (lint + audit)")] async fn lint_schema( &self, Parameters(params): Parameters, @@ -1225,9 +1209,7 @@ impl DryRunServer { Ok(CallToolResult::success(vec![Content::text(json)])) } - #[tool( - description = "Autovacuum status with thresholds, dead tuples and tuning hints. Offline." - )] + #[tool(description = "Autovacuum status, dead tuples, tuning hints")] async fn vacuum_health( &self, Parameters(params): Parameters, @@ -1257,9 +1239,7 @@ impl DryRunServer { Ok(CallToolResult::success(vec![Content::text(json)])) } - #[tool( - description = "Health checks. kind=stale_stats, unused_indexes, anomalies, bloated_indexes, or all (default). Offline." - )] + #[tool(description = "Health checks: stale stats, unused/bloated indexes, anomalies]")] async fn detect( &self, Parameters(params): Parameters, @@ -1369,9 +1349,7 @@ impl DryRunServer { Ok(CallToolResult::success(vec![Content::text(json)])) } - #[tool( - description = "Per-node stats for a table. Shows reltuples, relpages, scans, size and per-index numbers. Offline." - )] + #[tool(description = "Per-node stats: reltuples, relpages, scans, size, indexes")] async fn compare_nodes( &self, Parameters(params): Parameters, @@ -1464,9 +1442,7 @@ impl DryRunServer { Ok(CallToolResult::success(vec![Content::text(text)])) } - #[tool( - description = "Compare the live local DB against the loaded production snapshot. Each diff is tagged ahead, behind or diverged. Needs live DB." - )] + #[tool(description = "Diff live DB against loaded snapshot (ahead/behind/diverged)")] async fn check_drift(&self) -> Result { let ctx = self.require_live_db()?; let prod_snapshot = self.get_schema().await?; @@ -1487,7 +1463,7 @@ impl DryRunServer { Ok(CallToolResult::success(vec![Content::text(json)])) } - #[tool(description = "Force re-introspection of the database schema (requires live DB)")] + #[tool(description = "Force schema re-introspection")] async fn refresh_schema(&self) -> Result { let ctx = self.require_live_db()?; let schema = ctx @@ -1540,9 +1516,7 @@ impl DryRunServer { Ok(CallToolResult::success(vec![Content::text(text)])) } - #[tool( - description = "Reload schema from history.db (with stats) or schema.json (DDL only) without restarting." - )] + #[tool(description = "Reload schema from history.db or schema.json")] async fn reload_schema(&self) -> Result { // history.db first; the schema.json fallback drops planner/activity stats if let (Some(store), Some(key)) = (self.history.as_ref(), self.snapshot_key.as_ref()) From 23ff6fe5356f21422fe1c25d82ef0f6627ceee85 Mon Sep 17 00:00:00 2001 From: Radim Marek Date: Fri, 8 May 2026 21:54:31 +0200 Subject: [PATCH 02/10] feat: selective describe_table --- crates/dry_run_cli/src/mcp/params.rs | 5 +++ crates/dry_run_cli/src/mcp/server.rs | 54 +++++++++++++++++++++++++--- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/crates/dry_run_cli/src/mcp/params.rs b/crates/dry_run_cli/src/mcp/params.rs index 198ed28..cd89f31 100644 --- a/crates/dry_run_cli/src/mcp/params.rs +++ b/crates/dry_run_cli/src/mcp/params.rs @@ -27,6 +27,11 @@ pub struct DescribeTableParams { description = "Detail level: 'summary' (default, compact with profiles), 'full' (all raw stats), 'stats' (only profiles and stats)." )] pub detail: Option, + #[serde(default)] + #[schemars( + description = "Whitelist of sections: columns, indexes, constraints, stats, partition, profiles, comment, policies, triggers, reloptions, rls. Overrides 'detail' when set." + )] + pub fields: Option>, } #[derive(Debug, Deserialize, schemars::JsonSchema)] diff --git a/crates/dry_run_cli/src/mcp/server.rs b/crates/dry_run_cli/src/mcp/server.rs index 863887d..f3a6bae 100644 --- a/crates/dry_run_cli/src/mcp/server.rs +++ b/crates/dry_run_cli/src/mcp/server.rs @@ -388,7 +388,20 @@ impl DryRunServer { ) })?; - let detail = params.detail.as_deref().unwrap_or("summary"); + // some sections only exist in "full" — bump if caller asked for one + let needs_full_base = params.fields.as_ref().is_some_and(|fs| { + fs.iter().any(|f| { + matches!( + f.as_str(), + "policies" | "triggers" | "reloptions" | "rls_enabled" + ) + }) + }); + let detail = if needs_full_base { + "full" + } else { + params.detail.as_deref().unwrap_or("summary") + }; let qn = QualifiedName::new(schema_name, ¶ms.table); let view = annotated.view(); let table_rows = view.reltuples(&qn).unwrap_or(0.0); @@ -578,6 +591,34 @@ impl DryRunServer { } }; + // keep only requested sections; schema and name always stay + const KNOWN_FIELDS: &[&str] = &[ + "columns", + "indexes", + "constraints", + "stats", + "partition_info", + "column_profiles", + "comment", + "policies", + "triggers", + "reloptions", + "rls_enabled", + ]; + if let Some(fields) = ¶ms.fields { + for f in fields { + if !KNOWN_FIELDS.contains(&f.as_str()) { + return Err(McpError::invalid_params( + format!("unknown field '{f}'; valid: {}", KNOWN_FIELDS.join(", ")), + None, + )); + } + } + if let Some(obj) = json_val.as_object_mut() { + obj.retain(|k, _| k == "schema" || k == "name" || fields.iter().any(|f| f == k)); + } + } + let has_fks = table .constraints .iter() @@ -594,9 +635,14 @@ impl DryRunServer { let mut text = serde_json::to_string_pretty(&json_val) .map_err(|e| McpError::internal_error(format!("serialization error: {e}"), None))?; - // Per-node breakdown trailer — only meaningful when we have ≥ 2 - // nodes' worth of activity. Single-node clusters skip the section. - if let Some(breakdown) = format_node_table_breakdown(&annotated, schema_name, ¶ms.table) + // breakdown is stats-shaped; skip if caller dropped indexes/stats + let breakdown_relevant = match ¶ms.fields { + Some(fs) => fs.iter().any(|f| f == "indexes" || f == "stats"), + None => true, + }; + if breakdown_relevant + && let Some(breakdown) = + format_node_table_breakdown(&annotated, schema_name, ¶ms.table) { text.push_str(&breakdown); } From 4b250f93d93d8fffa2dc585e6e8451d80cfccd6c Mon Sep 17 00:00:00 2001 From: Radim Marek Date: Fri, 8 May 2026 22:29:46 +0200 Subject: [PATCH 03/10] feat: reduce default lint_schema responses --- crates/dry_run_cli/src/mcp/params.rs | 10 +++ crates/dry_run_cli/src/mcp/server.rs | 106 +++++++++++++++++++++++---- 2 files changed, 102 insertions(+), 14 deletions(-) diff --git a/crates/dry_run_cli/src/mcp/params.rs b/crates/dry_run_cli/src/mcp/params.rs index cd89f31..df7d430 100644 --- a/crates/dry_run_cli/src/mcp/params.rs +++ b/crates/dry_run_cli/src/mcp/params.rs @@ -117,6 +117,16 @@ pub struct LintSchemaParams { description = "Scope: 'conventions' (lint only), 'audit' (audit only), or 'all' (default, both)." )] pub scope: Option, + #[serde(default)] + #[schemars( + description = "Verbosity: 'summary' (default, counts and rule names only) or 'full' (findings, examples, ddl_fix)." + )] + pub verbosity: Option, + #[serde(default)] + #[schemars( + description = "Whitelist of sections: conventions, audit. Overrides 'scope' when set." + )] + pub fields: Option>, } #[derive(Debug, Deserialize, schemars::JsonSchema)] diff --git a/crates/dry_run_cli/src/mcp/server.rs b/crates/dry_run_cli/src/mcp/server.rs index f3a6bae..240e375 100644 --- a/crates/dry_run_cli/src/mcp/server.rs +++ b/crates/dry_run_cli/src/mcp/server.rs @@ -1211,37 +1211,115 @@ impl DryRunServer { target.schema.tables.retain(|t| &t.name == table_filter); } - let scope = params.scope.as_deref().unwrap_or("all"); + // fields wins over scope + let (want_conventions, want_audit) = if let Some(fields) = ¶ms.fields { + const KNOWN: &[&str] = &["conventions", "audit"]; + for f in fields { + if !KNOWN.contains(&f.as_str()) { + return Err(McpError::invalid_params( + format!("unknown field '{f}'; valid: conventions, audit"), + None, + )); + } + } + ( + fields.iter().any(|f| f == "conventions"), + fields.iter().any(|f| f == "audit"), + ) + } else { + let scope = params.scope.as_deref().unwrap_or("all"); + ( + scope == "all" || scope == "conventions", + scope == "all" || scope == "audit", + ) + }; + + // default summary; old callers see different shape now + let verbosity = params.verbosity.as_deref().unwrap_or("summary"); + if !matches!(verbosity, "summary" | "full") { + return Err(McpError::invalid_params( + format!("verbosity must be 'summary' or 'full', got '{verbosity}'"), + None, + )); + } + let full_mode = verbosity == "full"; + let mut result = serde_json::Map::new(); - if scope == "all" || scope == "conventions" { + if want_conventions { // Conventions/lint reads no stats — DDL only. let report = dry_run_core::lint::lint_schema(&target.schema, &self.lint_config); let compact = dry_run_core::lint::compact_report(&report, 5); - result.insert( - "conventions".into(), - serde_json::to_value(&compact).unwrap_or(serde_json::Value::Null), - ); + let mut value = serde_json::to_value(&compact).unwrap_or(serde_json::Value::Null); + if !full_mode + && let Some(by_rule) = value.get_mut("by_rule").and_then(|v| v.as_array_mut()) + { + for group in by_rule { + if let Some(obj) = group.as_object_mut() { + obj.remove("examples"); + obj.remove("recommendation"); + } + } + } + result.insert("conventions".into(), value); } - let has_ddl_fixes = if scope == "all" || scope == "audit" { + let (has_ddl_fixes, has_audit_findings) = if want_audit { // Audit needs planner sizing for the bloat / vacuum-defaults rules // — pass the annotated view so those have a chance to fire. let report = dry_run_core::audit::run_audit(&target.view(), &self.audit_config); let has_fixes = report.findings.iter().any(|f| f.ddl_fix.is_some()); - result.insert( - "audit".into(), - serde_json::to_value(&report).unwrap_or(serde_json::Value::Null), - ); - has_fixes + let has_findings = !report.findings.is_empty(); + + let value = if full_mode { + serde_json::to_value(&report).unwrap_or(serde_json::Value::Null) + } else { + // collapse findings to per-rule counts, drop rest + let mut groups: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for f in &report.findings { + let entry = groups.entry(f.rule.clone()).or_insert_with(|| { + serde_json::json!({ + "rule": f.rule, + "category": f.category, + "severity": f.severity, + "count": 0_usize, + "tables_count": 0_usize, + }) + }); + if let Some(obj) = entry.as_object_mut() { + if let Some(c) = obj.get_mut("count").and_then(|v| v.as_u64()) { + obj.insert("count".into(), serde_json::json!(c + 1)); + } + if let Some(tc) = obj.get_mut("tables_count").and_then(|v| v.as_u64()) { + obj.insert( + "tables_count".into(), + serde_json::json!(tc + f.tables.len() as u64), + ); + } + } + } + let by_rule: Vec = groups.into_values().collect(); + serde_json::json!({ + "by_rule": by_rule, + "tables_analyzed": report.tables_analyzed, + "summary": report.summary, + }) + }; + result.insert("audit".into(), value); + (has_fixes, has_findings) } else { - false + (false, false) }; - let hint = if has_ddl_fixes { + let hint = if full_mode && has_ddl_fixes { Some( "Some findings include ddl_fix fields. Run those through check_migration before applying to verify lock safety.", ) + } else if !full_mode && has_audit_findings { + Some( + "Summary view. Re-run with verbosity=\"full\" for findings, recommendations, and ddl_fix.", + ) } else { None }; From 5981da941c0b53eb1bf7fa6e2f50531da79ffbba Mon Sep 17 00:00:00 2001 From: Radim Marek Date: Fri, 8 May 2026 22:31:24 +0200 Subject: [PATCH 04/10] test(mcp): cover fields whitelist and lint_schema summary mode Three new tests: - describe_table with fields=["indexes"] returns only listed sections - describe_table with an unknown field name returns McpError - lint_schema default (summary) drops the audit findings array and ddl_fix keys, surfacing by_rule counts instead Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/dry_run_cli/src/mcp/server_tests.rs | 99 ++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/crates/dry_run_cli/src/mcp/server_tests.rs b/crates/dry_run_cli/src/mcp/server_tests.rs index 96911f8..e94b77b 100644 --- a/crates/dry_run_cli/src/mcp/server_tests.rs +++ b/crates/dry_run_cli/src/mcp/server_tests.rs @@ -135,6 +135,7 @@ async fn describe_table_includes_pg_version() { table: "orders".into(), schema: None, detail: None, + fields: None, })) .await .unwrap(); @@ -146,6 +147,104 @@ async fn describe_table_includes_pg_version() { ); } +#[tokio::test] +async fn describe_table_fields_whitelist_returns_only_listed_sections() { + let snapshot = test_snapshot(); + let server = DryRunServer::from_annotated_with_db( + crate::mcp::wrap_schema_only(snapshot), + None, + LintConfig::default(), + None, + "test", + vec![], + ); + let result = server + .describe_table(Parameters(DescribeTableParams { + table: "orders".into(), + schema: None, + detail: None, + fields: Some(vec!["indexes".into()]), + })) + .await + .unwrap(); + let text = format!("{:?}", result.content.first().unwrap()); + // Identity + requested section + _meta survive; columns/constraints don't. + assert!( + text.contains("\\\"name\\\""), + "name (identity) should be present" + ); + assert!( + text.contains("\\\"indexes\\\""), + "indexes should be present" + ); + assert!(text.contains("_meta"), "_meta should be injected"); + assert!( + !text.contains("\\\"columns\\\""), + "columns should be filtered out" + ); + assert!( + !text.contains("\\\"constraints\\\""), + "constraints should be filtered out" + ); +} + +#[tokio::test] +async fn describe_table_unknown_field_returns_error() { + let snapshot = test_snapshot(); + let server = DryRunServer::from_annotated_with_db( + crate::mcp::wrap_schema_only(snapshot), + None, + LintConfig::default(), + None, + "test", + vec![], + ); + let result = server + .describe_table(Parameters(DescribeTableParams { + table: "orders".into(), + schema: None, + detail: None, + fields: Some(vec!["bogus".into()]), + })) + .await; + assert!(result.is_err(), "unknown field must return McpError"); +} + +#[tokio::test] +async fn lint_schema_summary_omits_ddl_fix() { + let snapshot = test_snapshot(); + let server = DryRunServer::from_annotated_with_db( + crate::mcp::wrap_schema_only(snapshot), + None, + LintConfig::default(), + None, + "test", + vec![], + ); + let result = server + .lint_schema(Parameters(LintSchemaParams { + schema: None, + table: None, + scope: None, + verbosity: None, + fields: None, + })) + .await + .unwrap(); + let text = format!("{:?}", result.content.first().unwrap()); + // Summary collapses audit into by_rule counts — the raw findings array + // and ddl_fix keys do not appear. (Hint prose may mention ddl_fix.) + assert!( + !text.contains("\\\"ddl_fix\\\""), + "summary mode must not surface ddl_fix as a key" + ); + assert!( + !text.contains("\\\"findings\\\""), + "summary mode replaces findings array with by_rule counts" + ); + assert!(text.contains("by_rule"), "summary mode emits by_rule"); +} + fn test_snapshot() -> dry_run_core::SchemaSnapshot { use dry_run_core::schema::*; SchemaSnapshot { From caad22cbaf0445063bf15cc559c06b29db1c93a4 Mon Sep 17 00:00:00 2001 From: Radim Marek Date: Fri, 8 May 2026 22:31:57 +0200 Subject: [PATCH 05/10] chore: missing update --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 43c90ce..a0520e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -627,7 +627,7 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "dry_run_cli" -version = "0.6.1" +version = "0.7.0" dependencies = [ "anyhow", "cargo-husky", @@ -652,7 +652,7 @@ dependencies = [ [[package]] name = "dry_run_core" -version = "0.6.1" +version = "0.7.0" dependencies = [ "async-trait", "chrono", From 363c5ae25f551917082ee3d5d2552be2eae9b78d Mon Sep 17 00:00:00 2001 From: Radim Marek Date: Fri, 8 May 2026 23:12:06 +0200 Subject: [PATCH 06/10] chore: prepare _meta.next as replacement for _meta.hint --- crates/dry_run_cli/src/mcp/server.rs | 38 +++++++++++++++++++--------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/crates/dry_run_cli/src/mcp/server.rs b/crates/dry_run_cli/src/mcp/server.rs index 240e375..619c9cf 100644 --- a/crates/dry_run_cli/src/mcp/server.rs +++ b/crates/dry_run_cli/src/mcp/server.rs @@ -43,6 +43,13 @@ async fn persist_refresh( } } +// follow-up call surfaced via _meta.next +#[derive(Debug)] +pub struct NextCall<'a> { + pub tool: &'a str, + pub args: serde_json::Value, +} + pub fn wrap_schema_only(schema: SchemaSnapshot) -> AnnotatedSnapshot { AnnotatedSnapshot { schema, @@ -237,7 +244,7 @@ impl DryRunServer { } } - fn inject_meta(&self, val: &mut serde_json::Value, hint: Option<&str>) { + fn inject_meta(&self, val: &mut serde_json::Value, hint: Option<&str>, next: &[NextCall<'_>]) { let obj = val .as_object_mut() .expect("inject_meta expects a JSON object"); @@ -249,6 +256,13 @@ impl DryRunServer { if let Some(h) = hint { meta["hint"] = serde_json::Value::String(h.into()); } + if !next.is_empty() { + meta["next"] = serde_json::Value::Array( + next.iter() + .map(|n| serde_json::json!({ "tool": n.tool, "args": n.args })) + .collect(), + ); + } obj.insert("_meta".into(), meta); } } @@ -630,7 +644,7 @@ impl DryRunServer { } else { None }; - self.inject_meta(&mut json_val, hint); + self.inject_meta(&mut json_val, hint, &[]); let mut text = serde_json::to_string_pretty(&json_val) .map_err(|e| McpError::internal_error(format!("serialization error: {e}"), None))?; @@ -901,7 +915,7 @@ impl DryRunServer { } let mut json_val = serde_json::json!({ "changes": changeset }); - self.inject_meta(&mut json_val, None); + self.inject_meta(&mut json_val, None, &[]); let json = serde_json::to_string_pretty(&json_val) .map_err(|e| McpError::internal_error(format!("serialization error: {e}"), None))?; @@ -931,7 +945,7 @@ impl DryRunServer { let mut json_val = serde_json::to_value(&result) .map_err(|e| McpError::internal_error(format!("serialization error: {e}"), None))?; - self.inject_meta(&mut json_val, hint); + self.inject_meta(&mut json_val, hint, &[]); let json = serde_json::to_string_pretty(&json_val) .map_err(|e| McpError::internal_error(format!("serialization error: {e}"), None))?; @@ -969,7 +983,7 @@ impl DryRunServer { let mut json_val = serde_json::to_value(&result) .map_err(|e| McpError::internal_error(format!("serialization error: {e}"), None))?; - self.inject_meta(&mut json_val, hint); + self.inject_meta(&mut json_val, hint, &[]); let json = serde_json::to_string_pretty(&json_val) .map_err(|e| McpError::internal_error(format!("serialization error: {e}"), None))?; @@ -1046,7 +1060,7 @@ impl DryRunServer { "index_suggestions": advise_result.index_suggestions, }) }; - self.inject_meta(&mut result, hint); + self.inject_meta(&mut result, hint, &[]); let json = serde_json::to_string_pretty(&result) .map_err(|e| McpError::internal_error(format!("serialization error: {e}"), None))?; @@ -1146,7 +1160,7 @@ impl DryRunServer { obj }), }); - self.inject_meta(&mut result, hint); + self.inject_meta(&mut result, hint, &[]); let json = serde_json::to_string_pretty(&result) .map_err(|e| McpError::internal_error(format!("serialization error: {e}"), None))?; @@ -1187,7 +1201,7 @@ impl DryRunServer { }; let mut json_val = serde_json::json!({ "checks": checks }); - self.inject_meta(&mut json_val, hint); + self.inject_meta(&mut json_val, hint, &[]); let json = serde_json::to_string_pretty(&json_val) .map_err(|e| McpError::internal_error(format!("serialization error: {e}"), None))?; @@ -1325,7 +1339,7 @@ impl DryRunServer { }; let mut json_val = serde_json::Value::Object(result); - self.inject_meta(&mut json_val, hint); + self.inject_meta(&mut json_val, hint, &[]); let json = serde_json::to_string(&json_val) .map_err(|e| McpError::internal_error(format!("serialization error: {e}"), None))?; @@ -1356,7 +1370,7 @@ impl DryRunServer { } let mut json_val = serde_json::json!({ "tables": results }); - self.inject_meta(&mut json_val, None); + self.inject_meta(&mut json_val, None, &[]); let json = serde_json::to_string_pretty(&json_val) .map_err(|e| McpError::internal_error(format!("serialization error: {e}"), None))?; @@ -1466,7 +1480,7 @@ impl DryRunServer { }; let mut json_val = serde_json::Value::Object(result); - self.inject_meta(&mut json_val, hint); + self.inject_meta(&mut json_val, hint, &[]); let json = serde_json::to_string_pretty(&json_val) .map_err(|e| McpError::internal_error(format!("serialization error: {e}"), None))?; @@ -1579,7 +1593,7 @@ impl DryRunServer { let mut json_val = serde_json::to_value(&report) .map_err(|e| McpError::internal_error(format!("serialization error: {e}"), None))?; - self.inject_meta(&mut json_val, None); + self.inject_meta(&mut json_val, None, &[]); let json = serde_json::to_string_pretty(&json_val) .map_err(|e| McpError::internal_error(format!("serialization error: {e}"), None))?; From e7beeac70a65e88a48b5bc2b519aad3bdc953555 Mon Sep 17 00:00:00 2001 From: Radim Marek Date: Fri, 8 May 2026 23:25:32 +0200 Subject: [PATCH 07/10] feat: describe_table suggests find_related --- crates/dry_run_cli/src/mcp/server.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/dry_run_cli/src/mcp/server.rs b/crates/dry_run_cli/src/mcp/server.rs index 619c9cf..61713fc 100644 --- a/crates/dry_run_cli/src/mcp/server.rs +++ b/crates/dry_run_cli/src/mcp/server.rs @@ -644,7 +644,18 @@ impl DryRunServer { } else { None }; - self.inject_meta(&mut json_val, hint, &[]); + let next: Vec> = if has_fks { + vec![NextCall { + tool: "find_related", + args: serde_json::json!({ + "table": params.table, + "schema": schema_name, + }), + }] + } else { + vec![] + }; + self.inject_meta(&mut json_val, hint, &next); let mut text = serde_json::to_string_pretty(&json_val) .map_err(|e| McpError::internal_error(format!("serialization error: {e}"), None))?; From 8d42167504735ae22463d93eb385195a2261b068 Mon Sep 17 00:00:00 2001 From: Radim Marek Date: Sat, 9 May 2026 00:22:01 +0200 Subject: [PATCH 08/10] feat: lint_schema adjustments --- crates/dry_run_cli/src/mcp/server.rs | 42 ++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/crates/dry_run_cli/src/mcp/server.rs b/crates/dry_run_cli/src/mcp/server.rs index 61713fc..1b2f62e 100644 --- a/crates/dry_run_cli/src/mcp/server.rs +++ b/crates/dry_run_cli/src/mcp/server.rs @@ -1289,12 +1289,13 @@ impl DryRunServer { result.insert("conventions".into(), value); } - let (has_ddl_fixes, has_audit_findings) = if want_audit { + let (has_ddl_fixes, has_audit_findings, audit_findings_count) = if want_audit { // Audit needs planner sizing for the bloat / vacuum-defaults rules // — pass the annotated view so those have a chance to fire. let report = dry_run_core::audit::run_audit(&target.view(), &self.audit_config); let has_fixes = report.findings.iter().any(|f| f.ddl_fix.is_some()); - let has_findings = !report.findings.is_empty(); + let count = report.findings.len(); + let has_findings = count > 0; let value = if full_mode { serde_json::to_value(&report).unwrap_or(serde_json::Value::Null) @@ -1332,15 +1333,23 @@ impl DryRunServer { }) }; result.insert("audit".into(), value); - (has_fixes, has_findings) + (has_fixes, has_findings, count) } else { - (false, false) + (false, false, 0) }; + // big result sets: skip auto next, should try to narrow them first + const FULL_NEXT_THRESHOLD: usize = 50; + let many_findings = audit_findings_count > FULL_NEXT_THRESHOLD; + let hint = if full_mode && has_ddl_fixes { Some( "Some findings include ddl_fix fields. Run those through check_migration before applying to verify lock safety.", ) + } else if !full_mode && has_audit_findings && many_findings { + Some( + "Summary view; many findings. Narrow with schema=, table=, or scope= before re-running with verbosity=\"full\".", + ) } else if !full_mode && has_audit_findings { Some( "Summary view. Re-run with verbosity=\"full\" for findings, recommendations, and ddl_fix.", @@ -1349,8 +1358,31 @@ impl DryRunServer { None }; + let next: Vec> = if !full_mode && has_audit_findings && !many_findings { + let mut args = serde_json::Map::new(); + args.insert("verbosity".into(), serde_json::json!("full")); + if let Some(s) = ¶ms.schema { + args.insert("schema".into(), serde_json::json!(s)); + } + if let Some(t) = ¶ms.table { + args.insert("table".into(), serde_json::json!(t)); + } + if let Some(s) = ¶ms.scope { + args.insert("scope".into(), serde_json::json!(s)); + } + if let Some(f) = ¶ms.fields { + args.insert("fields".into(), serde_json::json!(f)); + } + vec![NextCall { + tool: "lint_schema", + args: serde_json::Value::Object(args), + }] + } else { + vec![] + }; + let mut json_val = serde_json::Value::Object(result); - self.inject_meta(&mut json_val, hint, &[]); + self.inject_meta(&mut json_val, hint, &next); let json = serde_json::to_string(&json_val) .map_err(|e| McpError::internal_error(format!("serialization error: {e}"), None))?; From 1dd1d97c476e242a32e866a575f843d278350f2c Mon Sep 17 00:00:00 2001 From: Radim Marek Date: Sat, 9 May 2026 00:51:48 +0200 Subject: [PATCH 09/10] chore: added _meta.next to instructions --- crates/dry_run_cli/src/mcp/server.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/dry_run_cli/src/mcp/server.rs b/crates/dry_run_cli/src/mcp/server.rs index 1b2f62e..57be1b3 100644 --- a/crates/dry_run_cli/src/mcp/server.rs +++ b/crates/dry_run_cli/src/mcp/server.rs @@ -1802,8 +1802,10 @@ impl ServerHandler for DryRunServer { "{version_header}\ {online_note}\n\n\ Start with list_tables or search_schema to explore. Use advise for query help. \ - Use check_migration before applying DDL. Each tool response includes a _meta.hint \ - field with contextual next-step guidance." + Use check_migration before applying DDL. Each tool response includes _meta.hint \ + (prose) and may include _meta.next: an array of {{tool, args}} entries that are \ + pre-validated follow-up calls — copy the args verbatim instead of inferring them \ + from the hint." )), capabilities: ServerCapabilities::builder().enable_tools().build(), ..Default::default() From 9b0f6d2581aa172fc18ceb62f8331852913357fa Mon Sep 17 00:00:00 2001 From: Radim Marek Date: Sat, 9 May 2026 00:53:47 +0200 Subject: [PATCH 10/10] test(mcp): cover _meta.next wiring across describe_table and lint_schema Four new tests: - describe_table without FKs omits _meta.next - describe_table with an FK emits next pointing to find_related (with the table name preserved in args) - lint_schema summary mode emits next pointing back to lint_schema with verbosity=full when findings are present - ServerInfo.instructions documents the _meta.next contract Adds a small test_snapshot_with_fk() helper that adds a foreign key constraint to the existing fixture without disturbing other tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/dry_run_cli/src/mcp/server_tests.rs | 131 +++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/crates/dry_run_cli/src/mcp/server_tests.rs b/crates/dry_run_cli/src/mcp/server_tests.rs index e94b77b..7bdb110 100644 --- a/crates/dry_run_cli/src/mcp/server_tests.rs +++ b/crates/dry_run_cli/src/mcp/server_tests.rs @@ -245,6 +245,137 @@ async fn lint_schema_summary_omits_ddl_fix() { assert!(text.contains("by_rule"), "summary mode emits by_rule"); } +#[tokio::test] +async fn describe_table_without_fk_omits_next() { + let snapshot = test_snapshot(); + let server = DryRunServer::from_annotated_with_db( + crate::mcp::wrap_schema_only(snapshot), + None, + LintConfig::default(), + None, + "test", + vec![], + ); + let result = server + .describe_table(Parameters(DescribeTableParams { + table: "orders".into(), + schema: None, + detail: None, + fields: None, + })) + .await + .unwrap(); + let text = format!("{:?}", result.content.first().unwrap()); + assert!( + !text.contains("\\\"next\\\""), + "no FK present, _meta.next should not be emitted" + ); +} + +#[tokio::test] +async fn describe_table_with_fk_emits_find_related_next() { + let snapshot = test_snapshot_with_fk(); + let server = DryRunServer::from_annotated_with_db( + crate::mcp::wrap_schema_only(snapshot), + None, + LintConfig::default(), + None, + "test", + vec![], + ); + let result = server + .describe_table(Parameters(DescribeTableParams { + table: "orders".into(), + schema: None, + detail: None, + fields: None, + })) + .await + .unwrap(); + let text = format!("{:?}", result.content.first().unwrap()); + assert!( + text.contains("\\\"next\\\""), + "FK present, next must be emitted" + ); + assert!( + text.contains("find_related"), + "next should target find_related" + ); + assert!( + text.contains("\\\"table\\\": \\\"orders\\\""), + "next args should carry the table name" + ); +} + +#[tokio::test] +async fn lint_schema_summary_emits_next_to_full() { + let snapshot = test_snapshot(); + let server = DryRunServer::from_annotated_with_db( + crate::mcp::wrap_schema_only(snapshot), + None, + LintConfig::default(), + None, + "test", + vec![], + ); + let result = server + .lint_schema(Parameters(LintSchemaParams { + schema: None, + table: None, + scope: None, + verbosity: None, + fields: None, + })) + .await + .unwrap(); + let text = format!("{:?}", result.content.first().unwrap()); + // Test fixture is small (1 table) — well under the 50-finding gate, + // so the next entry must be emitted when there are findings. + if text.contains("by_rule") && text.contains("\\\"count\\\"") { + assert!( + text.contains("lint_schema") && text.contains("verbosity"), + "summary mode with small result set should suggest verbosity=full" + ); + } +} + +#[tokio::test] +async fn server_info_instructions_mention_meta_next() { + let snapshot = test_snapshot(); + let server = DryRunServer::from_annotated_with_db( + crate::mcp::wrap_schema_only(snapshot), + None, + LintConfig::default(), + None, + "test", + vec![], + ); + let info = ServerHandler::get_info(&server); + let instr = info.instructions.unwrap_or_default(); + assert!( + instr.contains("_meta.next"), + "instructions should describe the _meta.next contract" + ); +} + +fn test_snapshot_with_fk() -> dry_run_core::SchemaSnapshot { + use dry_run_core::schema::*; + let mut snap = test_snapshot(); + if let Some(t) = snap.tables.first_mut() { + t.constraints.push(Constraint { + name: "orders_customer_id_fkey".into(), + kind: ConstraintKind::ForeignKey, + columns: vec!["customer_id".into()], + definition: Some("FOREIGN KEY (customer_id) REFERENCES customers(id)".into()), + fk_table: Some("customers".into()), + fk_columns: vec!["id".into()], + backing_index: None, + comment: None, + }); + } + snap +} + fn test_snapshot() -> dry_run_core::SchemaSnapshot { use dry_run_core::schema::*; SchemaSnapshot {