diff --git a/README.md b/README.md index 1c50367..1eee03a 100644 --- a/README.md +++ b/README.md @@ -303,12 +303,19 @@ If no connection strings are configured the server still boots (and `/healthz` r ### Tool catalog -| Tool | Arguments | Returns | -| ---------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| `list_databases` | `database?` | User database names from `sys.databases` (system DBs excluded). | -| `list_tables` | `database?` | `{schema, name}` for every base table in `INFORMATION_SCHEMA.TABLES`. | -| `describe_table` | `schema`, `table`, `database?` | Columns from `INFORMATION_SCHEMA.COLUMNS` (name, type, nullability, length/precision). | -| `sample_rows` | `schema`, `table`, `top` (1–100, default 10), `database?`| Up to N rows as `{column → value}` dictionaries. `SELECT TOP (n) * FROM ...`. | +| Tool | Arguments | Returns | +| ------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `list_databases` | `database?` | User database names from `sys.databases` (system DBs excluded). | +| `list_tables` | `database?` | `{schema, name}` for every base table in `INFORMATION_SCHEMA.TABLES`. | +| `describe_table` | `schema`, `table`, `database?` | Columns from `INFORMATION_SCHEMA.COLUMNS` (name, type, nullability, length/precision). | +| `sample_rows` | `schema`, `table`, `top` (1–100, default 10), `database?`| Up to N rows as `{column → value}` dictionaries. `SELECT TOP (n) * FROM ...`. | +| `top_queries` | `database?`, `top` (1–50, default 10) | Top-N queries by total CPU from `sys.dm_exec_query_stats` + `sys.dm_exec_sql_text`. Capped at 50. | +| `missing_indexes` | `database?` | Missing-index recommendations (top 50 by `avg_total_user_cost * avg_user_impact * (seeks+scans)`). | +| `wait_stats` | `database?`, `top` (1–50, default 20) | Top wait types from `sys.dm_os_wait_stats` with idle/system noise filtered out. Capped at 50. | +| `blocking_sessions` | `database?` | Currently blocked sessions (`blocking_session_id <> 0`) with blocker login + blocked SQL text. | +| `fk_graph` | `database?` | Foreign-key graph (one row per FK column; composite FKs produce multiple rows). No row cap. | +| `column_stats` | `schema`, `table`, `database?` | Per-column statistics from `sys.stats` + `sys.dm_db_stats_properties`. | +| `db_health_checks` | `database?` | Curated read-only checks (auto_close, auto_shrink, recovery model, page verify, backups, etc.). | ### Safety model diff --git a/src/CloudEngAgent.Mcp.Server/Program.cs b/src/CloudEngAgent.Mcp.Server/Program.cs index 764d950..a373c31 100644 --- a/src/CloudEngAgent.Mcp.Server/Program.cs +++ b/src/CloudEngAgent.Mcp.Server/Program.cs @@ -10,6 +10,13 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services .AddMcpServer() diff --git a/src/CloudEngAgent.Mcp.Server/Tools/BlockingSessionsTool.cs b/src/CloudEngAgent.Mcp.Server/Tools/BlockingSessionsTool.cs new file mode 100644 index 0000000..bc65d29 --- /dev/null +++ b/src/CloudEngAgent.Mcp.Server/Tools/BlockingSessionsTool.cs @@ -0,0 +1,88 @@ +using System.ComponentModel; +using CloudEngAgent.Mcp.Server.Sql; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; + +namespace CloudEngAgent.Mcp.Server.Tools; + +[McpServerToolType] +public sealed class BlockingSessionsTool +{ + private readonly ISqlConnectionFactory _connections; + private readonly ILogger _logger; + + public BlockingSessionsTool(ISqlConnectionFactory connections, ILogger logger) + { + ArgumentNullException.ThrowIfNull(connections); + ArgumentNullException.ThrowIfNull(logger); + _connections = connections; + _logger = logger; + } + + [McpServerTool(Name = "blocking_sessions")] + [Description("Returns currently blocked sessions (blocking_session_id <> 0) joined to blocker and blocked sessions and the blocked SQL text (first 500 chars).")] + public async Task> InvokeAsync( + [Description("Logical database name from configuration. Optional; defaults to the first configured entry.")] + string? database = null, + CancellationToken cancellationToken = default) + { + await using var conn = await SqlToolHelpers.OpenAsync(_connections, _logger, database, "blocking_sessions", cancellationToken).ConfigureAwait(false); + + const string sql = """ + SELECT + blocked.session_id AS blocked_session_id, + blocked.blocking_session_id AS blocking_session_id, + blocked.wait_type, + blocked.wait_time AS wait_time_ms, + blocked.wait_resource, + bs.login_name AS blocked_login, + gs.login_name AS blocking_login, + SUBSTRING(t.text, 1, 500) AS blocked_query + FROM sys.dm_exec_requests AS blocked + INNER JOIN sys.dm_exec_sessions AS bs + ON blocked.session_id = bs.session_id + LEFT JOIN sys.dm_exec_sessions AS gs + ON blocked.blocking_session_id = gs.session_id + OUTER APPLY sys.dm_exec_sql_text(blocked.sql_handle) AS t + WHERE blocked.blocking_session_id <> 0; + """; + + try + { + await using var cmd = new SqlCommand(sql, conn); + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + var results = new List(); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + results.Add(new BlockingSessionRow( + BlockedSessionId: reader.IsDBNull(0) ? 0 : (int)reader.GetInt16(0), + BlockingSessionId: reader.IsDBNull(1) ? 0 : (int)reader.GetInt16(1), + WaitType: reader.IsDBNull(2) ? null : reader.GetString(2), + WaitTimeMs: reader.IsDBNull(3) ? 0 : reader.GetInt32(3), + WaitResource: reader.IsDBNull(4) ? null : reader.GetString(4), + BlockedLogin: reader.IsDBNull(5) ? null : reader.GetString(5), + BlockingLogin: reader.IsDBNull(6) ? null : reader.GetString(6), + BlockedQuery: reader.IsDBNull(7) ? null : reader.GetString(7))); + } + _logger.LogInformation( + "blocking_sessions returned {RowCount} rows for {Database}", + results.Count, database ?? ""); + return results; + } + catch (SqlException ex) + { + throw SqlToolHelpers.ToMcpException(_logger, ex, "blocking_sessions"); + } + } + + public sealed record BlockingSessionRow( + int BlockedSessionId, + int BlockingSessionId, + string? WaitType, + int WaitTimeMs, + string? WaitResource, + string? BlockedLogin, + string? BlockingLogin, + string? BlockedQuery); +} diff --git a/src/CloudEngAgent.Mcp.Server/Tools/ColumnStatsTool.cs b/src/CloudEngAgent.Mcp.Server/Tools/ColumnStatsTool.cs new file mode 100644 index 0000000..2dcc192 --- /dev/null +++ b/src/CloudEngAgent.Mcp.Server/Tools/ColumnStatsTool.cs @@ -0,0 +1,113 @@ +using System.ComponentModel; +using CloudEngAgent.Mcp.Server.Sql; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; +using ModelContextProtocol; +using ModelContextProtocol.Server; + +namespace CloudEngAgent.Mcp.Server.Tools; + +[McpServerToolType] +public sealed class ColumnStatsTool +{ + private readonly ISqlConnectionFactory _connections; + private readonly ILogger _logger; + + public ColumnStatsTool(ISqlConnectionFactory connections, ILogger logger) + { + ArgumentNullException.ThrowIfNull(connections); + ArgumentNullException.ThrowIfNull(logger); + _connections = connections; + _logger = logger; + } + + [McpServerTool(Name = "column_stats")] + [Description("Returns column statistics (sys.stats joined to sys.stats_columns + sys.dm_db_stats_properties) for a given table. Validates schema/table identifiers.")] + public async Task> InvokeAsync( + [Description("Schema name (e.g., 'dbo').")] string schema, + [Description("Table name.")] string table, + [Description("Logical database name from configuration. Optional; defaults to the first configured entry.")] + string? database = null, + CancellationToken cancellationToken = default) + { + SqlToolHelpers.EnsureIdentifier(schema, nameof(schema)); + SqlToolHelpers.EnsureIdentifier(table, nameof(table)); + + await using var conn = await SqlToolHelpers.OpenAsync(_connections, _logger, database, "column_stats", cancellationToken).ConfigureAwait(false); + + if (!await SqlToolHelpers.TableExistsAsync(conn, schema, table, cancellationToken).ConfigureAwait(false)) + { + throw new McpException( + $"Table [{schema}].[{table}] does not exist or is not visible to the connection.", + McpErrorCode.InvalidParams); + } + + // Quote AFTER existence check; identifiers were already allow-listed. + var fullName = SqlIdentifier.Quote(schema) + "." + SqlIdentifier.Quote(table); + + // OBJECT_ID() takes a string literal — feed the qualified, allow-listed name as a parameter. + var qualified = $"{schema}.{table}"; + + const string sql = """ + DECLARE @oid int = OBJECT_ID(@qualified); + SELECT + c.name AS column_name, + s.name AS stats_name, + s.auto_created, + s.user_created, + sp.last_updated, + sp.rows, + sp.rows_sampled, + sp.modification_counter, + sp.unfiltered_rows + FROM sys.stats AS s + INNER JOIN sys.stats_columns AS sc + ON sc.object_id = s.object_id AND sc.stats_id = s.stats_id + INNER JOIN sys.columns AS c + ON c.object_id = sc.object_id AND c.column_id = sc.column_id + OUTER APPLY sys.dm_db_stats_properties(s.object_id, s.stats_id) AS sp + WHERE s.object_id = @oid + ORDER BY s.name, sc.stats_column_id; + """; + + try + { + await using var cmd = new SqlCommand(sql, conn); + cmd.Parameters.Add(new SqlParameter("@qualified", System.Data.SqlDbType.NVarChar, 257) { Value = qualified }); + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + var results = new List(); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + results.Add(new ColumnStatRow( + ColumnName: reader.GetString(0), + StatsName: reader.GetString(1), + AutoCreated: !reader.IsDBNull(2) && reader.GetBoolean(2), + UserCreated: !reader.IsDBNull(3) && reader.GetBoolean(3), + LastUpdated: reader.IsDBNull(4) ? null : reader.GetDateTime(4), + Rows: reader.IsDBNull(5) ? null : reader.GetInt64(5), + RowsSampled: reader.IsDBNull(6) ? null : reader.GetInt64(6), + ModificationCounter: reader.IsDBNull(7) ? null : reader.GetInt64(7), + UnfilteredRows: reader.IsDBNull(8) ? null : reader.GetInt64(8))); + } + _logger.LogInformation( + "column_stats returned {RowCount} rows for {Schema}.{Table} on {Database} (resolved via {Qualified})", + results.Count, schema, table, database ?? "", fullName); + return results; + } + catch (SqlException ex) + { + throw SqlToolHelpers.ToMcpException(_logger, ex, "column_stats"); + } + } + + public sealed record ColumnStatRow( + string ColumnName, + string StatsName, + bool AutoCreated, + bool UserCreated, + DateTime? LastUpdated, + long? Rows, + long? RowsSampled, + long? ModificationCounter, + long? UnfilteredRows); +} diff --git a/src/CloudEngAgent.Mcp.Server/Tools/DbHealthChecksTool.cs b/src/CloudEngAgent.Mcp.Server/Tools/DbHealthChecksTool.cs new file mode 100644 index 0000000..434b3cd --- /dev/null +++ b/src/CloudEngAgent.Mcp.Server/Tools/DbHealthChecksTool.cs @@ -0,0 +1,380 @@ +using System.ComponentModel; +using System.Globalization; +using CloudEngAgent.Mcp.Server.Sql; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; + +namespace CloudEngAgent.Mcp.Server.Tools; + +[McpServerToolType] +public sealed class DbHealthChecksTool +{ + private readonly ISqlConnectionFactory _connections; + private readonly ILogger _logger; + + public DbHealthChecksTool(ISqlConnectionFactory connections, ILogger logger) + { + ArgumentNullException.ThrowIfNull(connections); + ArgumentNullException.ThrowIfNull(logger); + _connections = connections; + _logger = logger; + } + + [McpServerTool(Name = "db_health_checks")] + [Description("Runs a curated set of read-only health checks against the connected database and returns one row per check (severity, category, pass/fail, details, remediation).")] + public async Task> InvokeAsync( + [Description("Logical database name from configuration. Optional; defaults to the first configured entry.")] + string? database = null, + CancellationToken cancellationToken = default) + { + await using var conn = await SqlToolHelpers.OpenAsync(_connections, _logger, database, "db_health_checks", cancellationToken).ConfigureAwait(false); + + var results = new List(); + + // Single round-trip pulls the database-level config flags we need. + DbConfig? dbInfo; + try + { + const string cfgSql = """ + SELECT + is_auto_close_on, + is_auto_shrink_on, + recovery_model_desc, + page_verify_option_desc, + compatibility_level, + name + FROM sys.databases + WHERE database_id = DB_ID(); + """; + await using var cmd = new SqlCommand(cfgSql, conn); + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + dbInfo = await reader.ReadAsync(cancellationToken).ConfigureAwait(false) + ? new DbConfig( + AutoClose: reader.GetBoolean(0), + AutoShrink: reader.GetBoolean(1), + RecoveryModel: reader.GetString(2), + PageVerify: reader.IsDBNull(3) ? string.Empty : reader.GetString(3), + CompatibilityLevel: reader.GetByte(4), + DbName: reader.GetString(5)) + : null; + } + catch (SqlException ex) + { + _logger.LogError(ex, + "db_health_checks: prequery failed (Number={SqlNumber}, State={SqlState})", + ex.Number, ex.State); + dbInfo = null; + } + + if (dbInfo is null) + { + results.Add(new HealthCheckRow( + "auto_close_off", "Info", "Configuration", false, + "AUTO_CLOSE is OFF", + "Could not read sys.databases for current DB.", + "Verify connection has access to sys.databases.")); + return results; + } + + var info = dbInfo.Value; + + results.Add(new HealthCheckRow( + CheckId: "auto_close_off", + Severity: "Medium", + Category: "Configuration", + Passed: !info.AutoClose, + Title: "AUTO_CLOSE is OFF", + Details: info.AutoClose + ? "is_auto_close_on = 1 — DB closes/reopens on idle, hurting first-hit latency." + : "is_auto_close_on = 0.", + Remediation: info.AutoClose ? $"ALTER DATABASE [{info.DbName}] SET AUTO_CLOSE OFF;" : string.Empty)); + + results.Add(new HealthCheckRow( + CheckId: "auto_shrink_off", + Severity: "High", + Category: "Performance", + Passed: !info.AutoShrink, + Title: "AUTO_SHRINK is OFF", + Details: info.AutoShrink + ? "is_auto_shrink_on = 1 — auto shrink causes fragmentation and CPU spikes." + : "is_auto_shrink_on = 0.", + Remediation: info.AutoShrink ? $"ALTER DATABASE [{info.DbName}] SET AUTO_SHRINK OFF;" : string.Empty)); + + var recoveryAppropriate = + string.Equals(info.RecoveryModel, "FULL", StringComparison.OrdinalIgnoreCase) || + string.Equals(info.RecoveryModel, "SIMPLE", StringComparison.OrdinalIgnoreCase); + results.Add(new HealthCheckRow( + CheckId: "recovery_model_appropriate", + Severity: "Info", + Category: "Operational", + Passed: recoveryAppropriate, + Title: "Recovery model is FULL or SIMPLE", + Details: $"recovery_model_desc = '{info.RecoveryModel}'.", + Remediation: recoveryAppropriate + ? string.Empty + : $"Consider ALTER DATABASE [{info.DbName}] SET RECOVERY FULL; (or SIMPLE).")); + + var pageVerifyOk = string.Equals(info.PageVerify, "CHECKSUM", StringComparison.OrdinalIgnoreCase); + results.Add(new HealthCheckRow( + CheckId: "page_verify_checksum", + Severity: "High", + Category: "Operational", + Passed: pageVerifyOk, + Title: "PAGE_VERIFY is CHECKSUM", + Details: $"page_verify_option_desc = '{info.PageVerify}'.", + Remediation: pageVerifyOk ? string.Empty : $"ALTER DATABASE [{info.DbName}] SET PAGE_VERIFY CHECKSUM;")); + + var compatOk = info.CompatibilityLevel >= 140; + results.Add(new HealthCheckRow( + CheckId: "compatibility_level_modern", + Severity: "Medium", + Category: "Configuration", + Passed: compatOk, + Title: "Compatibility level >= 140", + Details: $"compatibility_level = {info.CompatibilityLevel.ToString(CultureInfo.InvariantCulture)}.", + Remediation: compatOk + ? string.Empty + : $"ALTER DATABASE [{info.DbName}] SET COMPATIBILITY_LEVEL = 150; (or higher per supported).")); + + results.Add(await CheckQueryStoreAsync(conn, info.DbName, cancellationToken).ConfigureAwait(false)); + results.Add(await CheckLastFullBackupAsync(conn, info.DbName, cancellationToken).ConfigureAwait(false)); + results.Add(await CheckLastLogBackupAsync(conn, info.DbName, info.RecoveryModel, cancellationToken).ConfigureAwait(false)); + results.Add(await CheckUnusedIndexesAsync(conn, cancellationToken).ConfigureAwait(false)); + results.Add(await CheckDbccCheckDbAsync(conn, info.DbName, cancellationToken).ConfigureAwait(false)); + + _logger.LogInformation( + "db_health_checks returned {RowCount} checks for {Database}", + results.Count, database ?? ""); + return results; + } + + private async Task CheckQueryStoreAsync(SqlConnection conn, string dbName, CancellationToken ct) + { + try + { + const string sql = "SELECT actual_state_desc FROM sys.database_query_store_options;"; + await using var cmd = new SqlCommand(sql, conn); + var state = (string?)await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false) ?? "OFF"; + var passed = !string.Equals(state, "OFF", StringComparison.OrdinalIgnoreCase); + return new HealthCheckRow( + "query_store_enabled", "Medium", "Performance", passed, + "Query Store is enabled", + $"actual_state_desc = '{state}'.", + passed ? string.Empty : $"ALTER DATABASE [{dbName}] SET QUERY_STORE = ON;"); + } + catch (SqlException ex) + { + return ToErrorRow("query_store_enabled", "Performance", "Query Store is enabled", ex); + } + } + + private async Task CheckLastFullBackupAsync(SqlConnection conn, string dbName, CancellationToken ct) + { + try + { + const string sql = """ + SELECT TOP (1) backup_finish_date + FROM msdb.dbo.backupset + WHERE database_name = @db AND type = 'D' + ORDER BY backup_finish_date DESC; + """; + await using var cmd = new SqlCommand(sql, conn); + cmd.Parameters.Add(new SqlParameter("@db", System.Data.SqlDbType.NVarChar, 128) { Value = dbName }); + var raw = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false); + if (raw is null or DBNull) + { + return new HealthCheckRow( + "last_full_backup_within_7d", "Critical", "Operational", false, + "Full backup within 7 days", + "No full backup recorded in msdb.dbo.backupset.", + $"BACKUP DATABASE [{dbName}] TO DISK = N'...';"); + } + var when = (DateTime)raw; + var ageDays = (DateTime.UtcNow - when.ToUniversalTime()).TotalDays; + var passed = ageDays <= 7; + return new HealthCheckRow( + "last_full_backup_within_7d", "Critical", "Operational", passed, + "Full backup within 7 days", + $"Last full backup at {when:O} ({ageDays.ToString("F1", CultureInfo.InvariantCulture)} days ago).", + passed ? string.Empty : $"BACKUP DATABASE [{dbName}] TO DISK = N'...';"); + } + catch (SqlException ex) + { + return SkippedRow("last_full_backup_within_7d", "Operational", "Full backup within 7 days", ex); + } + } + + private async Task CheckLastLogBackupAsync(SqlConnection conn, string dbName, string recoveryModel, CancellationToken ct) + { + if (!string.Equals(recoveryModel, "FULL", StringComparison.OrdinalIgnoreCase)) + { + return new HealthCheckRow( + "last_log_backup_within_24h_when_full_recovery", "Info", "Operational", true, + "Log backup within 24h (FULL recovery only)", + $"Recovery model is '{recoveryModel}' — log-backup check skipped.", + string.Empty); + } + + try + { + const string sql = """ + SELECT TOP (1) backup_finish_date + FROM msdb.dbo.backupset + WHERE database_name = @db AND type = 'L' + ORDER BY backup_finish_date DESC; + """; + await using var cmd = new SqlCommand(sql, conn); + cmd.Parameters.Add(new SqlParameter("@db", System.Data.SqlDbType.NVarChar, 128) { Value = dbName }); + var raw = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false); + if (raw is null or DBNull) + { + return new HealthCheckRow( + "last_log_backup_within_24h_when_full_recovery", "Critical", "Operational", false, + "Log backup within 24h (FULL recovery)", + "No log backup recorded in msdb.dbo.backupset.", + $"BACKUP LOG [{dbName}] TO DISK = N'...';"); + } + var when = (DateTime)raw; + var ageHours = (DateTime.UtcNow - when.ToUniversalTime()).TotalHours; + var passed = ageHours <= 24; + return new HealthCheckRow( + "last_log_backup_within_24h_when_full_recovery", "Critical", "Operational", passed, + "Log backup within 24h (FULL recovery)", + $"Last log backup at {when:O} ({ageHours.ToString("F1", CultureInfo.InvariantCulture)} hours ago).", + passed ? string.Empty : $"BACKUP LOG [{dbName}] TO DISK = N'...';"); + } + catch (SqlException ex) + { + return SkippedRow("last_log_backup_within_24h_when_full_recovery", "Operational", "Log backup within 24h (FULL recovery)", ex); + } + } + + private async Task CheckUnusedIndexesAsync(SqlConnection conn, CancellationToken ct) + { + try + { + const string sql = """ + SELECT COUNT(*) + FROM sys.dm_db_index_usage_stats AS ius + INNER JOIN sys.indexes AS i + ON ius.object_id = i.object_id AND ius.index_id = i.index_id + WHERE ius.database_id = DB_ID() + AND i.index_id > 1 + AND i.is_unique = 0 + AND (ius.user_seeks + ius.user_scans + ius.user_lookups) = 0 + AND ius.user_updates > 0; + """; + await using var cmd = new SqlCommand(sql, conn); + var raw = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false); + var count = raw is int i ? i : Convert.ToInt32(raw, CultureInfo.InvariantCulture); + var passed = count == 0; + return new HealthCheckRow( + "unused_indexes_present", "Low", "Performance", passed, + "No unused indexes detected", + $"{count.ToString(CultureInfo.InvariantCulture)} indexes appear unused (writes only, no reads since last restart).", + passed + ? string.Empty + : "Review missing_indexes / index usage stats and consider DROPing unused non-unique non-clustered indexes."); + } + catch (SqlException ex) + { + return ToErrorRow("unused_indexes_present", "Performance", "No unused indexes detected", ex); + } + } + + private async Task CheckDbccCheckDbAsync(SqlConnection conn, string dbName, CancellationToken ct) + { + try + { + // DBCC DBINFO requires elevated permissions — falls back to "skipped" on access denied. + var sql = $"DBCC DBINFO ({SqlIdentifier.Quote(dbName)}) WITH TABLERESULTS, NO_INFOMSGS;"; + await using var cmd = new SqlCommand(sql, conn); + await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + DateTime? lastKnownGood = null; + while (await reader.ReadAsync(ct).ConfigureAwait(false)) + { + string? field = null; + string? value = null; + for (var i = 0; i < reader.FieldCount; i++) + { + var name = reader.GetName(i); + if (string.Equals(name, "Field", StringComparison.OrdinalIgnoreCase)) + { + field = reader.IsDBNull(i) ? null : reader.GetValue(i)?.ToString(); + } + else if (string.Equals(name, "Value", StringComparison.OrdinalIgnoreCase)) + { + value = reader.IsDBNull(i) ? null : reader.GetValue(i)?.ToString(); + } + } + if (string.Equals(field, "dbi_dbccLastKnownGood", StringComparison.OrdinalIgnoreCase) && + DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)) + { + lastKnownGood = parsed; + break; + } + } + + if (lastKnownGood is null || lastKnownGood.Value.Year <= 1900) + { + return new HealthCheckRow( + "dbcc_checkdb_recent", "High", "Operational", false, + "DBCC CHECKDB run within 7 days", + "No record of a successful DBCC CHECKDB.", + $"DBCC CHECKDB ([{dbName}]);"); + } + + var ageDays = (DateTime.UtcNow - lastKnownGood.Value.ToUniversalTime()).TotalDays; + var passed = ageDays <= 7; + return new HealthCheckRow( + "dbcc_checkdb_recent", "High", "Operational", passed, + "DBCC CHECKDB run within 7 days", + $"dbi_dbccLastKnownGood = {lastKnownGood:O} ({ageDays.ToString("F1", CultureInfo.InvariantCulture)} days ago).", + passed ? string.Empty : $"DBCC CHECKDB ([{dbName}]);"); + } + catch (SqlException ex) + { + return SkippedRow("dbcc_checkdb_recent", "Operational", "DBCC CHECKDB run within 7 days", ex); + } + } + + private HealthCheckRow ToErrorRow(string id, string category, string title, SqlException ex) + { + _logger.LogError(ex, + "db_health_checks: check {CheckId} failed (Number={SqlNumber}, State={SqlState})", + id, ex.Number, ex.State); + return new HealthCheckRow( + id, "Info", category, false, title, + $"error: {ex.Number} {SqlToolHelpers.FirstLine(ex.Message)}", + string.Empty); + } + + private HealthCheckRow SkippedRow(string id, string category, string title, SqlException ex) + { + _logger.LogWarning(ex, + "db_health_checks: check {CheckId} skipped (Number={SqlNumber}, State={SqlState})", + id, ex.Number, ex.State); + return new HealthCheckRow( + id, "Info", category, false, title, + $"skipped — insufficient permissions or unavailable ({ex.Number}: {SqlToolHelpers.FirstLine(ex.Message)})", + string.Empty); + } + + private readonly record struct DbConfig( + bool AutoClose, + bool AutoShrink, + string RecoveryModel, + string PageVerify, + byte CompatibilityLevel, + string DbName); + + public sealed record HealthCheckRow( + string CheckId, + string Severity, + string Category, + bool Passed, + string Title, + string Details, + string Remediation); +} diff --git a/src/CloudEngAgent.Mcp.Server/Tools/FkGraphTool.cs b/src/CloudEngAgent.Mcp.Server/Tools/FkGraphTool.cs new file mode 100644 index 0000000..73037dd --- /dev/null +++ b/src/CloudEngAgent.Mcp.Server/Tools/FkGraphTool.cs @@ -0,0 +1,94 @@ +using System.ComponentModel; +using CloudEngAgent.Mcp.Server.Sql; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; + +namespace CloudEngAgent.Mcp.Server.Tools; + +[McpServerToolType] +public sealed class FkGraphTool +{ + private readonly ISqlConnectionFactory _connections; + private readonly ILogger _logger; + + public FkGraphTool(ISqlConnectionFactory connections, ILogger logger) + { + ArgumentNullException.ThrowIfNull(connections); + ArgumentNullException.ThrowIfNull(logger); + _connections = connections; + _logger = logger; + } + + [McpServerTool(Name = "fk_graph")] + [Description("Returns the foreign-key graph of the connected database (one row per FK column; composite FKs produce multiple rows). No row cap — can return hundreds.")] + public async Task> InvokeAsync( + [Description("Logical database name from configuration. Optional; defaults to the first configured entry.")] + string? database = null, + CancellationToken cancellationToken = default) + { + await using var conn = await SqlToolHelpers.OpenAsync(_connections, _logger, database, "fk_graph", cancellationToken).ConfigureAwait(false); + + const string sql = """ + SELECT + fk.name AS fk_name, + ps.name AS parent_schema, + pt.name AS parent_table, + pc.name AS parent_column, + rs.name AS referenced_schema, + rt.name AS referenced_table, + rc.name AS referenced_column, + fk.is_disabled, + fk.delete_referential_action_desc + FROM sys.foreign_keys AS fk + INNER JOIN sys.foreign_key_columns AS fkc + ON fkc.constraint_object_id = fk.object_id + INNER JOIN sys.tables AS pt ON pt.object_id = fk.parent_object_id + INNER JOIN sys.schemas AS ps ON ps.schema_id = pt.schema_id + INNER JOIN sys.columns AS pc ON pc.object_id = fk.parent_object_id AND pc.column_id = fkc.parent_column_id + INNER JOIN sys.tables AS rt ON rt.object_id = fk.referenced_object_id + INNER JOIN sys.schemas AS rs ON rs.schema_id = rt.schema_id + INNER JOIN sys.columns AS rc ON rc.object_id = fk.referenced_object_id AND rc.column_id = fkc.referenced_column_id + ORDER BY ps.name, pt.name, fk.name, fkc.constraint_column_id; + """; + + try + { + await using var cmd = new SqlCommand(sql, conn); + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + var results = new List(); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + results.Add(new FkEdge( + FkName: reader.GetString(0), + ParentSchema: reader.GetString(1), + ParentTable: reader.GetString(2), + ParentColumn: reader.GetString(3), + ReferencedSchema: reader.GetString(4), + ReferencedTable: reader.GetString(5), + ReferencedColumn: reader.GetString(6), + IsDisabled: !reader.IsDBNull(7) && reader.GetBoolean(7), + DeleteReferentialActionDesc: reader.IsDBNull(8) ? string.Empty : reader.GetString(8))); + } + _logger.LogInformation( + "fk_graph returned {RowCount} rows for {Database}", + results.Count, database ?? ""); + return results; + } + catch (SqlException ex) + { + throw SqlToolHelpers.ToMcpException(_logger, ex, "fk_graph"); + } + } + + public sealed record FkEdge( + string FkName, + string ParentSchema, + string ParentTable, + string ParentColumn, + string ReferencedSchema, + string ReferencedTable, + string ReferencedColumn, + bool IsDisabled, + string DeleteReferentialActionDesc); +} diff --git a/src/CloudEngAgent.Mcp.Server/Tools/MissingIndexesTool.cs b/src/CloudEngAgent.Mcp.Server/Tools/MissingIndexesTool.cs new file mode 100644 index 0000000..3ad444d --- /dev/null +++ b/src/CloudEngAgent.Mcp.Server/Tools/MissingIndexesTool.cs @@ -0,0 +1,93 @@ +using System.ComponentModel; +using CloudEngAgent.Mcp.Server.Sql; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; + +namespace CloudEngAgent.Mcp.Server.Tools; + +[McpServerToolType] +public sealed class MissingIndexesTool +{ + public const int HardCap = 50; + + private readonly ISqlConnectionFactory _connections; + private readonly ILogger _logger; + + public MissingIndexesTool(ISqlConnectionFactory connections, ILogger logger) + { + ArgumentNullException.ThrowIfNull(connections); + ArgumentNullException.ThrowIfNull(logger); + _connections = connections; + _logger = logger; + } + + [McpServerTool(Name = "missing_indexes")] + [Description("Returns missing-index recommendations for the connected database from the sys.dm_db_missing_index_* DMVs, ordered by improvement_measure (top 50).")] + public async Task> InvokeAsync( + [Description("Logical database name from configuration. Optional; defaults to the first configured entry.")] + string? database = null, + CancellationToken cancellationToken = default) + { + await using var conn = await SqlToolHelpers.OpenAsync(_connections, _logger, database, "missing_indexes", cancellationToken).ConfigureAwait(false); + + const string sql = """ + SELECT TOP (50) + OBJECT_SCHEMA_NAME(mid.object_id) AS schema_name, + OBJECT_NAME(mid.object_id) AS table_name, + ISNULL(mid.equality_columns, '') AS equality_columns, + ISNULL(mid.inequality_columns, '') AS inequality_columns, + ISNULL(mid.included_columns, '') AS included_columns, + (migs.avg_total_user_cost * migs.avg_user_impact * (migs.user_seeks + migs.user_scans)) AS improvement_measure, + migs.unique_compiles, + migs.user_seeks, + migs.user_scans + FROM sys.dm_db_missing_index_details AS mid + INNER JOIN sys.dm_db_missing_index_groups AS mig + ON mid.index_handle = mig.index_handle + INNER JOIN sys.dm_db_missing_index_group_stats AS migs + ON mig.index_group_handle = migs.group_handle + WHERE mid.database_id = DB_ID() + ORDER BY improvement_measure DESC; + """; + + try + { + await using var cmd = new SqlCommand(sql, conn); + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + var results = new List(); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + results.Add(new MissingIndexRow( + SchemaName: reader.IsDBNull(0) ? string.Empty : reader.GetString(0), + TableName: reader.IsDBNull(1) ? string.Empty : reader.GetString(1), + EqualityColumns: reader.GetString(2), + InequalityColumns: reader.GetString(3), + IncludedColumns: reader.GetString(4), + ImprovementMeasure: reader.IsDBNull(5) ? 0d : Convert.ToDouble(reader.GetValue(5), System.Globalization.CultureInfo.InvariantCulture), + UniqueCompiles: reader.IsDBNull(6) ? 0 : reader.GetInt64(6), + UserSeeks: reader.IsDBNull(7) ? 0 : reader.GetInt64(7), + UserScans: reader.IsDBNull(8) ? 0 : reader.GetInt64(8))); + } + _logger.LogInformation( + "missing_indexes returned {RowCount} rows for {Database}", + results.Count, database ?? ""); + return results; + } + catch (SqlException ex) + { + throw SqlToolHelpers.ToMcpException(_logger, ex, "missing_indexes"); + } + } + + public sealed record MissingIndexRow( + string SchemaName, + string TableName, + string EqualityColumns, + string InequalityColumns, + string IncludedColumns, + double ImprovementMeasure, + long UniqueCompiles, + long UserSeeks, + long UserScans); +} diff --git a/src/CloudEngAgent.Mcp.Server/Tools/SqlToolHelpers.cs b/src/CloudEngAgent.Mcp.Server/Tools/SqlToolHelpers.cs new file mode 100644 index 0000000..bcf86fa --- /dev/null +++ b/src/CloudEngAgent.Mcp.Server/Tools/SqlToolHelpers.cs @@ -0,0 +1,73 @@ +using CloudEngAgent.Mcp.Server.Sql; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; +using ModelContextProtocol; + +namespace CloudEngAgent.Mcp.Server.Tools; + +/// +/// Shared helpers for read-only SQL Server MCP tools. Centralizes the +/// identifier allow-list check, the SqlException → McpException mapping +/// (logs full detail, returns only Number + first line to the caller), and +/// the INFORMATION_SCHEMA existence probe used by tools that need to +/// bracket-quote a schema/table after parameterized validation. +/// +internal static class SqlToolHelpers +{ + public static void EnsureIdentifier(string value, string paramName) + { + if (!SqlIdentifier.IsValid(value)) + { + throw new McpException( + $"Invalid value for '{paramName}': only ASCII letters, digits, and underscores are allowed (must start with a letter or underscore).", + McpErrorCode.InvalidParams); + } + } + + public static async Task OpenAsync( + ISqlConnectionFactory connections, + ILogger logger, + string? database, + string toolName, + CancellationToken cancellationToken) + { + try + { + return await connections.OpenAsync(database, cancellationToken).ConfigureAwait(false); + } + catch (SqlException ex) + { + throw ToMcpException(logger, ex, toolName); + } + } + + public static McpException ToMcpException(ILogger logger, SqlException ex, string toolName) + { + logger.LogError(ex, + "MCP SQL tool {Tool} failed (Number={SqlNumber}, State={SqlState})", + toolName, ex.Number, ex.State); + return new McpException( + $"SQL Server error {ex.Number}: {ex.Message.Split('\n')[0]}", + McpErrorCode.InternalError); + } + + public static string FirstLine(string message) => + string.IsNullOrEmpty(message) ? string.Empty : message.Split('\n')[0]; + + public static async Task TableExistsAsync( + SqlConnection conn, string schema, string table, CancellationToken cancellationToken) + { + const string sql = """ + SELECT 1 + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = @schema + AND TABLE_NAME = @table + AND TABLE_TYPE = 'BASE TABLE'; + """; + await using var cmd = new SqlCommand(sql, conn); + cmd.Parameters.Add(new SqlParameter("@schema", System.Data.SqlDbType.NVarChar, 128) { Value = schema }); + cmd.Parameters.Add(new SqlParameter("@table", System.Data.SqlDbType.NVarChar, 128) { Value = table }); + var result = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + return result is not null; + } +} diff --git a/src/CloudEngAgent.Mcp.Server/Tools/TopQueriesTool.cs b/src/CloudEngAgent.Mcp.Server/Tools/TopQueriesTool.cs new file mode 100644 index 0000000..4385c35 --- /dev/null +++ b/src/CloudEngAgent.Mcp.Server/Tools/TopQueriesTool.cs @@ -0,0 +1,104 @@ +using System.ComponentModel; +using System.Globalization; +using CloudEngAgent.Mcp.Server.Sql; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; +using ModelContextProtocol; +using ModelContextProtocol.Server; + +namespace CloudEngAgent.Mcp.Server.Tools; + +[McpServerToolType] +public sealed class TopQueriesTool +{ + public const int HardCap = 50; + public const int DefaultTop = 10; + + private readonly ISqlConnectionFactory _connections; + private readonly ILogger _logger; + + public TopQueriesTool(ISqlConnectionFactory connections, ILogger logger) + { + ArgumentNullException.ThrowIfNull(connections); + ArgumentNullException.ThrowIfNull(logger); + _connections = connections; + _logger = logger; + } + + [McpServerTool(Name = "top_queries")] + [Description("Returns the top-N queries by total CPU (worker time) from sys.dm_exec_query_stats. Read-only; capped at 50.")] + public async Task> InvokeAsync( + [Description("Logical database name from configuration. Optional; defaults to the first configured entry.")] + string? database = null, + [Description("Maximum number of queries to return (1-50). Default 10.")] + int top = DefaultTop, + CancellationToken cancellationToken = default) + { + if (top <= 0) + { + throw new McpException("'top' must be greater than zero.", McpErrorCode.InvalidParams); + } + var capped = Math.Min(top, HardCap); + + await using var conn = await SqlToolHelpers.OpenAsync(_connections, _logger, database, "top_queries", cancellationToken).ConfigureAwait(false); + + var sql = $""" + SELECT TOP ({capped.ToString(CultureInfo.InvariantCulture)}) + SUBSTRING(t.text, + (qs.statement_start_offset / 2) + 1, + CASE WHEN qs.statement_end_offset = -1 + THEN 250 + ELSE (qs.statement_end_offset - qs.statement_start_offset) / 2 + END) AS query_text, + qs.execution_count, + qs.total_worker_time / 1000.0 AS total_worker_time_ms, + (qs.total_worker_time / NULLIF(qs.execution_count, 0)) / 1000.0 AS avg_worker_time_ms, + qs.total_logical_reads, + qs.total_logical_reads / NULLIF(qs.execution_count, 0) AS avg_logical_reads, + qs.total_rows, + qs.last_execution_time + FROM sys.dm_exec_query_stats AS qs + CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS t + ORDER BY qs.total_worker_time DESC; + """; + + try + { + await using var cmd = new SqlCommand(sql, conn); + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + var results = new List(); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + var raw = reader.IsDBNull(0) ? string.Empty : reader.GetString(0); + var queryText = raw.Length > 500 ? raw[..500] : raw; + results.Add(new TopQueryRow( + QueryText: queryText, + ExecutionCount: reader.IsDBNull(1) ? 0 : reader.GetInt64(1), + TotalWorkerTimeMs: reader.IsDBNull(2) ? 0d : Convert.ToDouble(reader.GetValue(2), CultureInfo.InvariantCulture), + AvgWorkerTimeMs: reader.IsDBNull(3) ? 0d : Convert.ToDouble(reader.GetValue(3), CultureInfo.InvariantCulture), + TotalLogicalReads: reader.IsDBNull(4) ? 0 : reader.GetInt64(4), + AvgLogicalReads: reader.IsDBNull(5) ? 0 : reader.GetInt64(5), + TotalRows: reader.IsDBNull(6) ? 0 : reader.GetInt64(6), + LastExecutionTime: reader.IsDBNull(7) ? null : reader.GetDateTime(7))); + } + _logger.LogInformation( + "top_queries returned {RowCount} rows for {Database} (requested {Top}, capped {Capped})", + results.Count, database ?? "", top, capped); + return results; + } + catch (SqlException ex) + { + throw SqlToolHelpers.ToMcpException(_logger, ex, "top_queries"); + } + } + + public sealed record TopQueryRow( + string QueryText, + long ExecutionCount, + double TotalWorkerTimeMs, + double AvgWorkerTimeMs, + long TotalLogicalReads, + long AvgLogicalReads, + long TotalRows, + DateTime? LastExecutionTime); +} diff --git a/src/CloudEngAgent.Mcp.Server/Tools/WaitStatsTool.cs b/src/CloudEngAgent.Mcp.Server/Tools/WaitStatsTool.cs new file mode 100644 index 0000000..c248fab --- /dev/null +++ b/src/CloudEngAgent.Mcp.Server/Tools/WaitStatsTool.cs @@ -0,0 +1,125 @@ +using System.ComponentModel; +using System.Globalization; +using CloudEngAgent.Mcp.Server.Sql; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; +using ModelContextProtocol; +using ModelContextProtocol.Server; + +namespace CloudEngAgent.Mcp.Server.Tools; + +[McpServerToolType] +public sealed class WaitStatsTool +{ + public const int HardCap = 50; + public const int DefaultTop = 20; + + // Standard "noise" wait types excluded from server health analysis. + // The values are SQL Server constants, not user input, so a literal + // IN-list is safe. + private const string ExcludedWaitsClause = """ + wait_type NOT LIKE 'CLR_%' + AND wait_type NOT LIKE 'DBMIRROR_%' + AND wait_type NOT LIKE 'BROKER_%' + AND wait_type NOT LIKE 'HADR_%' + AND wait_type NOT LIKE 'XE_%' + AND wait_type NOT LIKE 'SLEEP_%' + AND wait_type NOT IN ( + 'SP_SERVER_DIAGNOSTICS_SLEEP', + 'LAZYWRITER_SLEEP', + 'REQUEST_FOR_DEADLOCK_SEARCH', + 'LOGMGR_QUEUE', + 'CHECKPOINT_QUEUE', + 'ONDEMAND_TASK_QUEUE', + 'FT_IFTS_SCHEDULER_IDLE_WAIT', + 'WAITFOR', + 'CLR_AUTO_EVENT', + 'CLR_MANUAL_EVENT', + 'DISPATCHER_QUEUE_SEMAPHORE', + 'FT_IFTSHC_MUTEX', + 'BROKER_RECEIVE_WAITFOR', + 'BROKER_TASK_STOP', + 'BROKER_TO_FLUSH', + 'MISCELLANEOUS' + ) + """; + + private readonly ISqlConnectionFactory _connections; + private readonly ILogger _logger; + + public WaitStatsTool(ISqlConnectionFactory connections, ILogger logger) + { + ArgumentNullException.ThrowIfNull(connections); + ArgumentNullException.ThrowIfNull(logger); + _connections = connections; + _logger = logger; + } + + [McpServerTool(Name = "wait_stats")] + [Description("Returns top wait types from sys.dm_os_wait_stats with the standard idle/system wait noise filtered out. Capped at 50.")] + public async Task> InvokeAsync( + [Description("Logical database name from configuration. Optional; defaults to the first configured entry.")] + string? database = null, + [Description("Maximum number of wait types to return (1-50). Default 20.")] + int top = DefaultTop, + CancellationToken cancellationToken = default) + { + if (top <= 0) + { + throw new McpException("'top' must be greater than zero.", McpErrorCode.InvalidParams); + } + var capped = Math.Min(top, HardCap); + + await using var conn = await SqlToolHelpers.OpenAsync(_connections, _logger, database, "wait_stats", cancellationToken).ConfigureAwait(false); + + var sql = $""" + WITH filtered AS ( + SELECT wait_type, waiting_tasks_count, wait_time_ms, signal_wait_time_ms + FROM sys.dm_os_wait_stats + WHERE {ExcludedWaitsClause} + AND wait_time_ms > 0 + ) + SELECT TOP ({capped.ToString(CultureInfo.InvariantCulture)}) + wait_type, + waiting_tasks_count, + wait_time_ms, + signal_wait_time_ms, + CASE WHEN SUM(wait_time_ms) OVER () = 0 THEN 0 + ELSE 100.0 * wait_time_ms / SUM(wait_time_ms) OVER () + END AS pct_of_total + FROM filtered + ORDER BY wait_time_ms DESC; + """; + + try + { + await using var cmd = new SqlCommand(sql, conn); + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + var results = new List(); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + results.Add(new WaitStatRow( + WaitType: reader.GetString(0), + WaitingTasksCount: reader.IsDBNull(1) ? 0 : reader.GetInt64(1), + WaitTimeMs: reader.IsDBNull(2) ? 0 : reader.GetInt64(2), + SignalWaitTimeMs: reader.IsDBNull(3) ? 0 : reader.GetInt64(3), + PctOfTotal: reader.IsDBNull(4) ? 0d : Convert.ToDouble(reader.GetValue(4), CultureInfo.InvariantCulture))); + } + _logger.LogInformation( + "wait_stats returned {RowCount} rows for {Database} (requested {Top}, capped {Capped})", + results.Count, database ?? "", top, capped); + return results; + } + catch (SqlException ex) + { + throw SqlToolHelpers.ToMcpException(_logger, ex, "wait_stats"); + } + } + + public sealed record WaitStatRow( + string WaitType, + long WaitingTasksCount, + long WaitTimeMs, + long SignalWaitTimeMs, + double PctOfTotal); +} diff --git a/tests/CloudEngAgent.Mcp.Server.Tests/McpToolRegistrationTests.cs b/tests/CloudEngAgent.Mcp.Server.Tests/McpToolRegistrationTests.cs new file mode 100644 index 0000000..0689b33 --- /dev/null +++ b/tests/CloudEngAgent.Mcp.Server.Tests/McpToolRegistrationTests.cs @@ -0,0 +1,62 @@ +using System.Reflection; +using CloudEngAgent.Mcp.Server.Tools; +using FluentAssertions; +using ModelContextProtocol.Server; +using Xunit; + +namespace CloudEngAgent.Mcp.Server.Tests; + +/// +/// Validates that all 7 new MVP demo tools are exposed via the +/// mechanism the SDK discovers from the +/// assembly. Catches accidental rename / missing-attribute regressions +/// without needing a live SQL Server. +/// +public sealed class McpToolRegistrationTests +{ + [Theory] + [InlineData("top_queries", typeof(TopQueriesTool))] + [InlineData("missing_indexes", typeof(MissingIndexesTool))] + [InlineData("wait_stats", typeof(WaitStatsTool))] + [InlineData("blocking_sessions", typeof(BlockingSessionsTool))] + [InlineData("fk_graph", typeof(FkGraphTool))] + [InlineData("column_stats", typeof(ColumnStatsTool))] + [InlineData("db_health_checks", typeof(DbHealthChecksTool))] + public void Tool_IsAdvertisedWithExpectedName(string expectedName, Type toolType) + { + toolType.GetCustomAttribute().Should().NotBeNull( + "{0} must be marked as an MCP tool type", toolType.Name); + + var method = toolType.GetMethods(BindingFlags.Public | BindingFlags.Instance) + .SingleOrDefault(m => m.GetCustomAttribute() is not null); + + method.Should().NotBeNull("{0} must expose a single [McpServerTool] method", toolType.Name); + + var attr = method!.GetCustomAttribute()!; + attr.Name.Should().Be(expectedName); + } + + [Fact] + public void All_ExpectedToolNames_AreDiscoverableInAssembly() + { + var expected = new[] + { + "top_queries", "missing_indexes", "wait_stats", "blocking_sessions", + "fk_graph", "column_stats", "db_health_checks", + }; + + var discovered = typeof(SqlServerTools).Assembly + .GetTypes() + .Where(t => t.GetCustomAttribute() is not null) + .SelectMany(t => t.GetMethods(BindingFlags.Public | BindingFlags.Instance)) + .Select(m => m.GetCustomAttribute()) + .Where(a => a is not null) + .Select(a => a!.Name) + .ToHashSet(StringComparer.Ordinal); + + foreach (var name in expected) + { + discovered.Should().Contain(name); + } + } +} diff --git a/tests/CloudEngAgent.Mcp.Server.Tests/NewToolValidationTests.cs b/tests/CloudEngAgent.Mcp.Server.Tests/NewToolValidationTests.cs new file mode 100644 index 0000000..9e9833e --- /dev/null +++ b/tests/CloudEngAgent.Mcp.Server.Tests/NewToolValidationTests.cs @@ -0,0 +1,69 @@ +using CloudEngAgent.Mcp.Server.Sql; +using CloudEngAgent.Mcp.Server.Tools; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol; +using NSubstitute; +using Xunit; + +namespace CloudEngAgent.Mcp.Server.Tests; + +/// +/// Identifier-validation tests for the new MVP tools. These exercise the +/// pre-DB validation path so we can assert the InvalidParams shape without +/// needing a live SQL Server (parity with the existing test suite). +/// +public sealed class ColumnStatsToolValidationTests +{ + private static ColumnStatsTool CreateTool() => + new(Substitute.For(), NullLogger.Instance); + + [Theory] + [InlineData("dbo';--", "Customers")] + [InlineData("dbo", "Customers; DROP TABLE Customers--")] + [InlineData("has space", "t")] + [InlineData("dbo", "1startsWithDigit")] + [InlineData("", "Customers")] + [InlineData("dbo", "")] + public async Task Invoke_RejectsInvalidIdentifierBeforeOpeningConnection(string schema, string table) + { + var tool = CreateTool(); + + var act = async () => await tool.InvokeAsync( + schema: schema, + table: table, + database: "anything", + cancellationToken: CancellationToken.None); + + var ex = await act.Should().ThrowAsync(); + ex.Which.ErrorCode.Should().Be(McpErrorCode.InvalidParams); + } +} + +public sealed class TopQueriesToolValidationTests +{ + [Fact] + public async Task Invoke_RejectsZeroOrNegativeTopBeforeOpeningConnection() + { + var tool = new TopQueriesTool(Substitute.For(), NullLogger.Instance); + + var act = async () => await tool.InvokeAsync(top: 0, cancellationToken: CancellationToken.None); + + var ex = await act.Should().ThrowAsync(); + ex.Which.ErrorCode.Should().Be(McpErrorCode.InvalidParams); + } +} + +public sealed class WaitStatsToolValidationTests +{ + [Fact] + public async Task Invoke_RejectsZeroOrNegativeTopBeforeOpeningConnection() + { + var tool = new WaitStatsTool(Substitute.For(), NullLogger.Instance); + + var act = async () => await tool.InvokeAsync(top: -1, cancellationToken: CancellationToken.None); + + var ex = await act.Should().ThrowAsync(); + ex.Which.ErrorCode.Should().Be(McpErrorCode.InvalidParams); + } +}