Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions src/CloudEngAgent.Mcp.Server/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@

builder.Services.AddSingleton<ISqlConnectionFactory, SqlConnectionFactory>();
builder.Services.AddSingleton<SqlServerTools>();
builder.Services.AddSingleton<TopQueriesTool>();
builder.Services.AddSingleton<MissingIndexesTool>();
builder.Services.AddSingleton<WaitStatsTool>();
builder.Services.AddSingleton<BlockingSessionsTool>();
builder.Services.AddSingleton<FkGraphTool>();
builder.Services.AddSingleton<ColumnStatsTool>();
builder.Services.AddSingleton<DbHealthChecksTool>();

builder.Services
.AddMcpServer()
Expand Down
88 changes: 88 additions & 0 deletions src/CloudEngAgent.Mcp.Server/Tools/BlockingSessionsTool.cs
Original file line number Diff line number Diff line change
@@ -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<BlockingSessionsTool> _logger;

public BlockingSessionsTool(ISqlConnectionFactory connections, ILogger<BlockingSessionsTool> 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<IReadOnlyList<BlockingSessionRow>> 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<BlockingSessionRow>();
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 ?? "<default>");
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);
}
113 changes: 113 additions & 0 deletions src/CloudEngAgent.Mcp.Server/Tools/ColumnStatsTool.cs
Original file line number Diff line number Diff line change
@@ -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<ColumnStatsTool> _logger;

public ColumnStatsTool(ISqlConnectionFactory connections, ILogger<ColumnStatsTool> 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<IReadOnlyList<ColumnStatRow>> 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<ColumnStatRow>();
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 ?? "<default>", 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);
}
Loading
Loading