Skip to content

Commit d80f417

Browse files
committed
feat: streamline module compare guidance
1 parent 05b82f0 commit d80f417

10 files changed

Lines changed: 129 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
All notable changes to this project are documented here.
44

5+
## 1.15.0 - 2026-06-29
6+
7+
- Added `changedLineSummary` to module/file comparison results so callers do not need to parse summary text for changed and returned line counts.
8+
- Added first-body-difference next actions that point directly to the database and local file lines to inspect.
9+
- Added `compare.repoExcludePatterns` config defaults, merged with per-call `excludePatterns`, for routinely ignoring historical or backup SQL folders during repository module discovery.
10+
511
## 1.14.0 - 2026-06-29
612

713
- Added `firstBodyDifference` to module/file comparison results, mapping the first SQL-normalized body difference back to database and file line numbers.

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ See [`docs/sqlserver_mcp.example.json`](docs/sqlserver_mcp.example.json) for a c
8383
| `security.allowSystemDatabases` | `false` | Allow system databases |
8484
| `textSearch.targets` | `[]` | Allow-listed text columns for `search_config_text` |
8585
| `textSearch.snippetLength` | `240` | Snippet length for configured text searches |
86+
| `compare.repoExcludePatterns` | `[]` | Default repository glob patterns to exclude in `compare_module_to_repo` |
8687
| `logging.logSql` | `false` | Include submitted SQL text in file logs |
8788
| `connection.encrypt` | `true` | Encrypt SQL connections |
8889
| `connection.trustServerCertificate` | `false` | Skip certificate-chain validation |
@@ -120,7 +121,7 @@ Relative `logs`, `cache`, and `tmp` directories are created beside the config fi
120121

121122
Search, definition-slice, configuration-text, usage, and read-only query tools expose `resultInfo` for consistent returned-count, limit, truncation, reason, and hint metadata.
122123

123-
Module/file comparison returns a readable top-level summary, `differenceKind`, `firstBodyDifference`, ignored wrapper-difference labels, and multi-hunk diffs with truncation metadata. Use `diffMode=summary` for ranges only, `compact` for the default focused diff, or `full` with larger `maxHunks` / `maxDiffLinesPerSide` caps. Repository comparison also accepts `excludePatterns` such as `backup/**` and returns `suggestedPatterns` for ambiguous candidates. It reports `sqlNormalizedMatch` and SQL-normalized hashes to ignore common deployment-script wrapper differences such as `CREATE OR ALTER`, leading `SET ANSI_NULLS` / `SET QUOTED_IDENTIFIER`, and trailing `GO`, while `firstBodyDifference` maps the first SQL-normalized body change back to database and file line numbers.
124+
Module/file comparison returns a readable top-level summary, `changedLineSummary`, `differenceKind`, `firstBodyDifference`, ignored wrapper-difference labels, and multi-hunk diffs with truncation metadata. Use `diffMode=summary` for ranges only, `compact` for the default focused diff, or `full` with larger `maxHunks` / `maxDiffLinesPerSide` caps. Repository comparison accepts per-call `excludePatterns`, merges them with `compare.repoExcludePatterns` defaults such as `backup/**`, and returns `suggestedPatterns` for ambiguous candidates. It reports `sqlNormalizedMatch` and SQL-normalized hashes to ignore common deployment-script wrapper differences such as `CREATE OR ALTER`, leading `SET ANSI_NULLS` / `SET QUOTED_IDENTIFIER`, and trailing `GO`, while `firstBodyDifference` maps the first SQL-normalized body change back to database and file line numbers. When a body difference exists, `nextActions` points directly to the database and local file lines to inspect.
124125

125126
`describe_query_result` accepts optional `templateValues` for UI SQL placeholders, for example `{ "0": "1=1" }` replaces `{0}` before describing columns. Replacements are raw SQL fragments, and the final SQL is still parsed by the read-only guard.
126127

README.zh-CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ SQL 文本可能包含敏感数据,仅在确有需要时启用 `logging.logSql
8383

8484
`compare_module_to_file` 适合已知道本地 SQL 文件路径时确认“仓库 SQL 是否已执行到数据库”;`compare_module_to_repo` 可按对象名在本地仓库/目录下自动发现 `.sql` 候选文件,唯一高分候选会直接比较,并列候选会返回列表让调用方收窄路径。
8585

86-
模块/文件对比会返回顶层可读摘要、`differenceKind``firstBodyDifference`、被忽略的脚本包装差异标签,以及带截断信息的多 hunk diff。`diffMode=summary` 只返回差异范围,`compact` 是默认紧凑输出,`full` 可配合更大的 `maxHunks` / `maxDiffLinesPerSide` 查看更多行;仓库对比还支持 `excludePatterns` 排除 `backup/**` 等历史目录,并在候选并列时返回 `suggestedPatterns` 便于收窄。工具同时返回 `sqlNormalizedMatch` 和 SQL 归一化 hash,用于忽略常见部署脚本包装差异,例如 `CREATE OR ALTER`、开头 `SET ANSI_NULLS` / `SET QUOTED_IDENTIFIER`、结尾 `GO``firstBodyDifference` 会把第一处 SQL 归一化后的正文差异映射回数据库和本地文件行号。
86+
模块/文件对比会返回顶层可读摘要、`changedLineSummary``differenceKind``firstBodyDifference`、被忽略的脚本包装差异标签,以及带截断信息的多 hunk diff。`diffMode=summary` 只返回差异范围,`compact` 是默认紧凑输出,`full` 可配合更大的 `maxHunks` / `maxDiffLinesPerSide` 查看更多行;仓库对比支持调用参数 `excludePatterns`,并会和配置项 `compare.repoExcludePatterns` 合并,用于默认排除 `backup/**``domain2/**` 等历史目录,候选并列时返回 `suggestedPatterns` 便于收窄。工具同时返回 `sqlNormalizedMatch` 和 SQL 归一化 hash,用于忽略常见部署脚本包装差异,例如 `CREATE OR ALTER`、开头 `SET ANSI_NULLS` / `SET QUOTED_IDENTIFIER`、结尾 `GO``firstBodyDifference` 会把第一处 SQL 归一化后的正文差异映射回数据库和本地文件行号,存在正文差异时 `nextActions` 会直接提示要查看的数据库行和本地文件行
8787

8888
搜索、模块定义切片、配置文本搜索、用法搜索和只读查询工具都会返回 `resultInfo`,用于统一判断结果是否被限制、为什么被截断以及下一步该如何缩小范围。
8989

docs/sqlserver_mcp.example.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@
3737
}
3838
]
3939
},
40+
"compare": {
41+
"repoExcludePatterns": [
42+
"**/backup/**",
43+
"**/domain2/**"
44+
]
45+
},
4046
"logging": {
4147
"logSql": false,
4248
"maxFileSizeMb": 10,

src/SqlServerMcp/Configuration/SqlServerMcpOptions.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ public sealed class SqlServerMcpOptions
1818

1919
public TextSearchOptions TextSearch { get; init; } = new();
2020

21+
public CompareOptions Compare { get; init; } = new();
22+
2123
public LoggingOptions Logging { get; init; } = new();
2224

2325
public ConnectionOptions Connection { get; init; } = new();
@@ -85,6 +87,7 @@ private void Validate()
8587

8688
Limits.Normalize();
8789
TextSearch.Normalize();
90+
Compare.Normalize();
8891
Logging.Normalize();
8992
}
9093
}
@@ -211,6 +214,21 @@ public void Normalize()
211214
}
212215
}
213216

217+
public sealed class CompareOptions
218+
{
219+
public string[] RepoExcludePatterns { get; set; } = [];
220+
221+
public void Normalize()
222+
{
223+
RepoExcludePatterns = RepoExcludePatterns
224+
.Where(pattern => !string.IsNullOrWhiteSpace(pattern))
225+
.Select(pattern => pattern.Trim().Replace('\\', '/'))
226+
.Distinct(StringComparer.OrdinalIgnoreCase)
227+
.Take(100)
228+
.ToArray();
229+
}
230+
}
231+
214232
public sealed class LoggingOptions
215233
{
216234
public bool LogSql { get; set; }

src/SqlServerMcp/Sql/SqlMetadataService.cs

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@ public async Task<object> HealthCheckAsync(CancellationToken cancellationToken)
236236
},
237237
limits = _options.Limits,
238238
security = _options.Security,
239+
compare = new
240+
{
241+
repoExcludePatterns = _options.Compare.RepoExcludePatterns
242+
},
239243
textSearch = new
240244
{
241245
snippetLength = _options.TextSearch.SnippetLength,
@@ -778,7 +782,8 @@ public async Task<object> CompareModuleToRepoAsync(
778782
CancellationToken cancellationToken)
779783
{
780784
var module = await GetModuleDefinitionCoreAsync(schema, name, cancellationToken);
781-
var discovery = FindModuleSqlFileDiscovery(root, module.Schema, module.Name, patterns, excludePatterns, maxCandidates);
785+
var effectiveExcludePatterns = MergeRepoCompareExcludePatterns(_options.Compare.RepoExcludePatterns, excludePatterns);
786+
var discovery = FindModuleSqlFileDiscovery(root, module.Schema, module.Name, patterns, effectiveExcludePatterns, maxCandidates);
782787
if (discovery.SelectedCandidate is null)
783788
{
784789
return new
@@ -821,7 +826,8 @@ public async Task<object> CompareModuleToRepoAsync(
821826
sqlNormalizedMatch = comparison.SqlNormalizedMatch,
822827
differenceKind = comparison.DifferenceKind,
823828
firstBodyDifference = comparison.FirstBodyDifference,
824-
nextActions = ModuleCompareNextActions
829+
changedLineSummary = comparison.ChangedLineSummary,
830+
nextActions = comparison.NextActions
825831
};
826832
}
827833

@@ -2464,14 +2470,17 @@ private async Task<ModuleFileComparisonResult> BuildModuleFileComparisonAsync(
24642470
var differenceKind = ClassifyModuleFileDifference(exactMatch, normalizedMatch, sqlNormalizedMatch);
24652471
var ignoredWrapperDifferences = DetectIgnoredWrapperDifferences(module.Definition, fileText);
24662472
var firstBodyDifference = FindFirstBodyDifference(module.Definition, fileText);
2473+
var changedLineSummary = BuildChangedLineSummary(diff);
24672474
var summary = BuildModuleFileComparisonSummary(
24682475
file,
24692476
diff,
2477+
changedLineSummary,
24702478
exactMatch,
24712479
normalizedMatch,
24722480
sqlNormalizedMatch,
24732481
differenceKind,
24742482
firstBodyDifference);
2483+
var nextActions = BuildModuleCompareNextActions(firstBodyDifference, diff);
24752484

24762485
return new ModuleFileComparisonResult(
24772486
module.Schema,
@@ -2501,9 +2510,10 @@ private async Task<ModuleFileComparisonResult> BuildModuleFileComparisonAsync(
25012510
differenceKind,
25022511
ignoredWrapperDifferences,
25032512
firstBodyDifference,
2513+
changedLineSummary,
25042514
summary,
25052515
diff,
2506-
ModuleCompareNextActions);
2516+
nextActions);
25072517
}
25082518

25092519
internal static ModuleSqlFileDiscovery FindModuleSqlFileDiscovery(
@@ -2581,6 +2591,13 @@ internal static ModuleSqlFileDiscovery FindModuleSqlFileDiscovery(
25812591
hint);
25822592
}
25832593

2594+
internal static string[] MergeRepoCompareExcludePatterns(string[] configuredPatterns, string[]? requestedPatterns)
2595+
{
2596+
return NormalizeRepoComparePatterns(
2597+
configuredPatterns.Concat(requestedPatterns ?? []).ToArray(),
2598+
[]);
2599+
}
2600+
25842601
private static string[] NormalizeRepoComparePatterns(string[]? patterns)
25852602
{
25862603
return NormalizeRepoComparePatterns(patterns, DefaultRepoComparePatterns);
@@ -2643,6 +2660,32 @@ private static string[] BuildRepoCompareSuggestedPatterns(ModuleSqlFileCandidate
26432660
.ToArray();
26442661
}
26452662

2663+
internal static ModuleChangedLineSummary BuildChangedLineSummary(ModuleFileDiff diff)
2664+
{
2665+
return new ModuleChangedLineSummary(
2666+
diff.DatabaseChangedLineCount,
2667+
diff.FileChangedLineCount,
2668+
diff.Hunks.Sum(hunk => hunk.DatabaseLines.Length + hunk.FileLines.Length));
2669+
}
2670+
2671+
internal static string[] BuildModuleCompareNextActions(ModuleBodyDifference? firstBodyDifference, ModuleFileDiff diff)
2672+
{
2673+
var nextActions = new List<string>();
2674+
if (firstBodyDifference is not null)
2675+
{
2676+
nextActions.Add(
2677+
$"Inspect first SQL body difference at database line {FormatNullableInt(firstBodyDifference.DatabaseLine)} and local file line {FormatNullableInt(firstBodyDifference.FileLine)}.");
2678+
}
2679+
2680+
nextActions.AddRange(ModuleCompareNextActions);
2681+
if (!diff.Equal && diff.Mode == "summary")
2682+
{
2683+
nextActions.Add("Use diffMode=compact to include surrounding line text for changed hunks.");
2684+
}
2685+
2686+
return nextActions.ToArray();
2687+
}
2688+
26462689
private static string ClassifyModuleFileDifference(bool exactMatch, bool normalizedMatch, bool sqlNormalizedMatch)
26472690
{
26482691
if (exactMatch)
@@ -2700,18 +2743,18 @@ private static string[] DetectIgnoredWrapperDifferences(string databaseDefinitio
27002743
private static string BuildModuleFileComparisonSummary(
27012744
FileInfo file,
27022745
ModuleFileDiff diff,
2746+
ModuleChangedLineSummary changedLineSummary,
27032747
bool exactMatch,
27042748
bool normalizedMatch,
27052749
bool sqlNormalizedMatch,
27062750
string differenceKind,
27072751
ModuleBodyDifference? firstBodyDifference)
27082752
{
2709-
var returnedDiffLineCount = diff.Hunks.Sum(hunk => hunk.DatabaseLines.Length + hunk.FileLines.Length);
27102753
var truncatedText = diff.Truncated ? "truncated" : "not truncated";
27112754
var firstBodyText = firstBodyDifference is null
27122755
? "firstBodyDifference=none"
27132756
: $"firstBodyDifference=body:{firstBodyDifference.BodyLine},db:{FormatNullableInt(firstBodyDifference.DatabaseLine)},file:{FormatNullableInt(firstBodyDifference.FileLine)}";
2714-
return $"{differenceKind}; selected {file.Name}; exactMatch={FormatBool(exactMatch)}; normalizedMatch={FormatBool(normalizedMatch)}; sqlNormalizedMatch={FormatBool(sqlNormalizedMatch)}; {diff.Hunks.Length} hunks; changedLines=db:{diff.DatabaseChangedLineCount},file:{diff.FileChangedLineCount}; returnedDiffLines={returnedDiffLineCount}; {firstBodyText}; {truncatedText}";
2757+
return $"{differenceKind}; selected {file.Name}; exactMatch={FormatBool(exactMatch)}; normalizedMatch={FormatBool(normalizedMatch)}; sqlNormalizedMatch={FormatBool(sqlNormalizedMatch)}; {diff.Hunks.Length} hunks; changedLines=db:{changedLineSummary.Database},file:{changedLineSummary.File}; returnedDiffLines={changedLineSummary.Returned}; {firstBodyText}; {truncatedText}";
27152758
}
27162759

27172760
private static string FormatBool(bool value)
@@ -5340,10 +5383,16 @@ private sealed record ModuleFileComparisonResult(
53405383
string DifferenceKind,
53415384
string[] IgnoredWrapperDifferences,
53425385
ModuleBodyDifference? FirstBodyDifference,
5386+
ModuleChangedLineSummary ChangedLineSummary,
53435387
string Summary,
53445388
ModuleFileDiff Diff,
53455389
string[] NextActions);
53465390

5391+
internal sealed record ModuleChangedLineSummary(
5392+
int Database,
5393+
int File,
5394+
int Returned);
5395+
53475396
internal sealed record ModuleBodyDifference(
53485397
int BodyLine,
53495398
int? DatabaseLine,

src/SqlServerMcp/SqlServerMcp.csproj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77
<RootNamespace>SqlServerMcp</RootNamespace>
88
<ImplicitUsings>enable</ImplicitUsings>
99
<Nullable>enable</Nullable>
10-
<Version>1.14.0</Version>
11-
<AssemblyVersion>1.14.0.0</AssemblyVersion>
12-
<FileVersion>1.14.0.0</FileVersion>
13-
<InformationalVersion>1.14.0</InformationalVersion>
10+
<Version>1.15.0</Version>
11+
<AssemblyVersion>1.15.0.0</AssemblyVersion>
12+
<FileVersion>1.15.0.0</FileVersion>
13+
<InformationalVersion>1.15.0</InformationalVersion>
1414
<Description>A read-only Model Context Protocol server for exploring and querying SQL Server.</Description>
1515
<RepositoryUrl>https://github.com/EdmondLu/sqlserver-mcp</RepositoryUrl>
1616
<RepositoryType>git</RepositoryType>

src/SqlServerMcp/Tools/SqlServerMcpTools.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ public static Task<string> CompareModuleToRepo(
164164
[Description("Module name.")] string name,
165165
[Description("Optional repository/folder root. Defaults to the MCP process current directory.")] string? root = null,
166166
[Description("Optional path glob patterns such as **/*.sql, procedures/*.sql, or *proc*.sql. Defaults to **/*.sql.")] string[]? patterns = null,
167-
[Description("Optional path glob patterns to exclude from candidate discovery, such as backup/** or domain2/**.")] string[]? excludePatterns = null,
167+
[Description("Optional path glob patterns to exclude from candidate discovery, such as backup/** or domain2/**. Merged with compare.repoExcludePatterns from config.")] string[]? excludePatterns = null,
168168
[Description("Maximum ranked candidates to return when discovery is empty or ambiguous. Defaults to 10 and is capped.")] int? maxCandidates = null,
169169
[Description("Context lines around the changed block after a file is selected. Defaults to 5 and is capped.")] int? contextLines = null,
170170
[Description("Diff output mode: summary, compact, or full. Defaults to compact.")] string? diffMode = null,

tests/SqlServerMcp.Tests/ModuleFileDiffTests.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ CREATE PROC dbo.Sample
8585
Assert.Equal(5, hunk.DatabaseEndLine);
8686
Assert.Empty(hunk.DatabaseLines);
8787
Assert.Empty(hunk.FileLines);
88+
Assert.Equal(new(1, 1, 0), SqlMetadataService.BuildChangedLineSummary(diff));
8889
}
8990

9091
[Fact]
@@ -174,6 +175,19 @@ SELECT 2
174175
Assert.Equal(3, firstBodyDifference.BodyLine);
175176
Assert.Equal(3, firstBodyDifference.DatabaseLine);
176177
Assert.Equal(7, firstBodyDifference.FileLine);
178+
179+
var diff = SqlMetadataService.BuildLineDiff(
180+
databaseDefinition,
181+
fileText,
182+
contextLines: 1,
183+
diffMode: "summary",
184+
maxHunks: null,
185+
maxDiffLinesPerSide: null);
186+
var nextActions = SqlMetadataService.BuildModuleCompareNextActions(firstBodyDifference, diff);
187+
188+
Assert.Contains(nextActions, action => action.Contains("database line 3", StringComparison.Ordinal)
189+
&& action.Contains("local file line 7", StringComparison.Ordinal));
190+
Assert.Contains(nextActions, action => action.Contains("diffMode=compact", StringComparison.Ordinal));
177191
}
178192

179193
[Fact]
@@ -255,6 +269,16 @@ public void FindModuleSqlFileDiscovery_AppliesExcludePatterns()
255269
}
256270
}
257271

272+
[Fact]
273+
public void MergeRepoCompareExcludePatterns_CombinesConfiguredAndRequestedPatterns()
274+
{
275+
var patterns = SqlMetadataService.MergeRepoCompareExcludePatterns(
276+
["backup/**", "domain2/**"],
277+
["domain2/**", "archive/**"]);
278+
279+
Assert.Equal(["backup/**", "domain2/**", "archive/**"], patterns);
280+
}
281+
258282
[Fact]
259283
public void FindModuleSqlFileDiscovery_AppliesPathPatterns()
260284
{

tests/SqlServerMcp.Tests/OptionsDefaultsTests.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,17 @@ public void TextSearchOptions_HaveNoTargetsByDefault()
3030
Assert.Empty(options.Targets);
3131
Assert.Equal(240, options.SnippetLength);
3232
}
33+
34+
[Fact]
35+
public void CompareOptions_NormalizeRepoExcludePatterns()
36+
{
37+
var options = new CompareOptions
38+
{
39+
RepoExcludePatterns = [" backup\\** ", "backup/**", "", "domain2/**"]
40+
};
41+
42+
options.Normalize();
43+
44+
Assert.Equal(["backup/**", "domain2/**"], options.RepoExcludePatterns);
45+
}
3346
}

0 commit comments

Comments
 (0)