Skip to content

Commit 0692bb5

Browse files
razeoneCopilot
andcommitted
feat(mcp-server): implement read-only SQL Server tools (M6)
Build out CloudEngAgent.Mcp.Server as a working MCP server exposing four read-only SQL Server introspection tools over MCP/HTTP at /mcp: - list_databases -> sys.databases (system DBs filtered) - list_tables -> INFORMATION_SCHEMA.TABLES - describe_table -> INFORMATION_SCHEMA.COLUMNS - sample_rows -> SELECT TOP (n) * FROM <quoted>; (n capped at 100) Safety model: - All values flow through SqlParameter; identifiers are allow-listed (^[A-Za-z_][A-Za-z0-9_]{0,127}\$) and verified against INFORMATION_SCHEMA before being bracket-quoted. - Errors return McpException with friendly messages; SqlException details are logged but never leaked through MCP. - Connection strings bind from Mcp:SqlServer:ConnectionStrings; the server still boots with no DBs configured (tools then return InvalidParams 'no connection configured'). Tests (CloudEngAgent.Mcp.Server.Tests): - SqlIdentifier unit tests (allow-list / injection rejection / quoting). - Testcontainers MsSql integration tests for all four tools, skipped automatically when 'docker info' fails (Windows-without-Docker safe). - WebApplicationFactory health-endpoint test that boots the server with no SQL config to confirm the DI graph stays optional. README: new 'MCP Server (M6)' section + roadmap mark. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c4d7554 commit 0692bb5

14 files changed

Lines changed: 963 additions & 2 deletions

CloudEngAgent.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@
1010
<Project Path="tests/CloudEngAgent.Api.Tests/CloudEngAgent.Api.Tests.csproj" />
1111
<Project Path="tests/CloudEngAgent.Domain.Tests/CloudEngAgent.Domain.Tests.csproj" />
1212
<Project Path="tests/CloudEngAgent.Infrastructure.Tests/CloudEngAgent.Infrastructure.Tests.csproj" />
13+
<Project Path="tests/CloudEngAgent.Mcp.Server.Tests/CloudEngAgent.Mcp.Server.Tests.csproj" />
1314
</Folder>
1415
</Solution>

README.md

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,67 @@ Selection is driven by `WorkflowEngine:Mode`:
251251

252252
Multi-agent orchestration via `Microsoft.Agents.AI.Workflows` (orchestrator → {explorer, analyst, …}) is the M5.2 follow-up; the M5.1 slice runs the workflow's entry persona as a single agent.
253253

254+
## MCP Server (M6)
255+
256+
`CloudEngAgent.Mcp.Server` is an ASP.NET Core 10 host that exposes a small set of **read-only** SQL Server introspection tools over the [Model Context Protocol](https://modelcontextprotocol.io). It is the server side of the MCP integration; the API project consumes it through the `IMcpToolRegistry` HTTP client (M7).
257+
258+
### Run locally
259+
260+
```powershell
261+
dotnet run --project src\CloudEngAgent.Mcp.Server
262+
```
263+
264+
The server listens on a Kestrel-assigned port and exposes:
265+
266+
| Method | Route | Description |
267+
| ------ | ---------- | ----------------------------------------------------------------------- |
268+
| GET | `/healthz` | Liveness probe. |
269+
| ANY | `/mcp` | MCP endpoint (`Streamable HTTP` transport from `ModelContextProtocol.AspNetCore`). |
270+
271+
### Configuration
272+
273+
Connection strings live under `Mcp:SqlServer:ConnectionStrings`. Each entry maps a logical database name (the value MCP callers pass as the `database` argument) to a SQL Server ADO.NET connection string. The first non-empty entry is used when callers omit `database`.
274+
275+
```jsonc
276+
{
277+
"Mcp": {
278+
"SqlServer": {
279+
"ConnectionStrings": {
280+
"default": "Server=localhost,1433;Database=master;User Id=sa;Password=...;TrustServerCertificate=true",
281+
"warehouse": "Server=warehouse.example.com;Database=dw;Authentication=Active Directory Default"
282+
}
283+
}
284+
}
285+
}
286+
```
287+
288+
For local development use user-secrets (the project ships with a user-secrets id):
289+
290+
```powershell
291+
dotnet user-secrets set --project src\CloudEngAgent.Mcp.Server `
292+
"Mcp:SqlServer:ConnectionStrings:default" `
293+
"Server=localhost,1433;Database=master;User Id=sa;Password=Your_strong_Passw0rd!;TrustServerCertificate=true"
294+
```
295+
296+
If no connection strings are configured the server still boots (and `/healthz` returns OK), but invoking any tool returns a structured `InvalidParams` error so callers get a clear "no connection configured" message.
297+
298+
### Tool catalog
299+
300+
| Tool | Arguments | Returns |
301+
| ---------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
302+
| `list_databases` | `database?` | User database names from `sys.databases` (system DBs excluded). |
303+
| `list_tables` | `database?` | `{schema, name}` for every base table in `INFORMATION_SCHEMA.TABLES`. |
304+
| `describe_table` | `schema`, `table`, `database?` | Columns from `INFORMATION_SCHEMA.COLUMNS` (name, type, nullability, length/precision). |
305+
| `sample_rows` | `schema`, `table`, `top` (1–100, default 10), `database?`| Up to N rows as `{column → value}` dictionaries. `SELECT TOP (n) * FROM ...`. |
306+
307+
### Safety model
308+
309+
- All tool **values** are sent as `SqlParameter` instances — never concatenated into SQL.
310+
- All tool **identifiers** (schema/table) must pass a strict allow-list (`^[A-Za-z_][A-Za-z0-9_]{0,127}$`) **and** be present in `INFORMATION_SCHEMA` before being bracket-quoted and interpolated. Anything else is rejected up-front with `InvalidParams`.
311+
- `sample_rows` enforces a hard cap of 100 rows regardless of the requested `top`.
312+
- `SqlException` details are logged in full but only the SQL `Number` + the first line of the message are returned to the client; stack traces never leak through MCP.
313+
- All tools are **read-only** — there is no DDL/DML surface.
314+
254315
## API surface (v1)
255316

256317
| Method | Route | Description |
@@ -339,7 +400,7 @@ See the in-session plan for the full P0/P1/P2 backlog. Milestones:
339400
- **M4 (YAML personas + hot reload)**: ✅ Complete — see the "Personas (M4)" section above.
340401
- **M5.1 (single-agent real LLM execution)**: ✅ Complete — see the "Workflow engine (M5.1)" section above.
341402
- **M5.2** (multi-agent graph orchestration via `Microsoft.Agents.AI.Workflows`)
342-
- **M6** (MCP SQL server)
403+
- **M6 (MCP SQL server)**: ✅ Complete — see the "MCP Server (M6)" section above.
343404
- **M7** (MCP client wiring)
344405

345406
Forward-looking features (P3) include resumption, multi-tenancy, persona overlays, a workflow DSL, human-in-the-loop tool approval, cost/token telemetry, and saved investigations.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
namespace CloudEngAgent.Mcp.Server.Options;
2+
3+
/// <summary>
4+
/// Bound from configuration section <c>Mcp:SqlServer</c>. Each entry in
5+
/// <see cref="ConnectionStrings"/> maps a logical database name (the value
6+
/// callers pass to MCP tools) → a SQL Server ADO.NET connection string.
7+
/// The first entry is the default when a tool's <c>database</c> argument is
8+
/// omitted or empty.
9+
/// </summary>
10+
public sealed class SqlServerOptions
11+
{
12+
public const string SectionName = "Mcp:SqlServer";
13+
14+
public Dictionary<string, string> ConnectionStrings { get; init; } =
15+
new(StringComparer.OrdinalIgnoreCase);
16+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,27 @@
1+
using CloudEngAgent.Mcp.Server.Options;
2+
using CloudEngAgent.Mcp.Server.Sql;
3+
using CloudEngAgent.Mcp.Server.Tools;
4+
15
var builder = WebApplication.CreateBuilder(args);
26

7+
builder.Services
8+
.AddOptions<SqlServerOptions>()
9+
.Bind(builder.Configuration.GetSection(SqlServerOptions.SectionName));
10+
11+
builder.Services.AddSingleton<ISqlConnectionFactory, SqlConnectionFactory>();
12+
builder.Services.AddSingleton<SqlServerTools>();
13+
14+
builder.Services
15+
.AddMcpServer()
16+
.WithHttpTransport()
17+
.WithToolsFromAssembly(typeof(Program).Assembly);
18+
319
var app = builder.Build();
420

521
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
22+
app.MapMcp("/mcp");
623

724
app.Run();
25+
26+
// Exposed so WebApplicationFactory<Program> can boot the host in tests.
27+
public partial class Program;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using Microsoft.Data.SqlClient;
2+
3+
namespace CloudEngAgent.Mcp.Server.Sql;
4+
5+
/// <summary>
6+
/// Resolves a logical database name (as configured in
7+
/// <c>Mcp:SqlServer:ConnectionStrings</c>) to an open
8+
/// <see cref="SqlConnection"/>. Centralized to keep tools unit-testable
9+
/// and to enforce a single connection-creation path.
10+
/// </summary>
11+
public interface ISqlConnectionFactory
12+
{
13+
/// <summary>
14+
/// The set of configured logical database names, in declaration order.
15+
/// </summary>
16+
IReadOnlyList<string> ConfiguredDatabases { get; }
17+
18+
/// <summary>
19+
/// Opens a new <see cref="SqlConnection"/> for the named logical database.
20+
/// If <paramref name="database"/> is null/empty the first configured
21+
/// database is used.
22+
/// </summary>
23+
Task<SqlConnection> OpenAsync(string? database, CancellationToken cancellationToken);
24+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
using CloudEngAgent.Mcp.Server.Options;
2+
using Microsoft.Data.SqlClient;
3+
using Microsoft.Extensions.Options;
4+
using ModelContextProtocol;
5+
6+
namespace CloudEngAgent.Mcp.Server.Sql;
7+
8+
public sealed class SqlConnectionFactory : ISqlConnectionFactory
9+
{
10+
private readonly IReadOnlyList<KeyValuePair<string, string>> _entries;
11+
12+
public SqlConnectionFactory(IOptions<SqlServerOptions> options)
13+
{
14+
ArgumentNullException.ThrowIfNull(options);
15+
_entries = options.Value.ConnectionStrings
16+
.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Value))
17+
.ToArray();
18+
}
19+
20+
public IReadOnlyList<string> ConfiguredDatabases =>
21+
_entries.Select(e => e.Key).ToArray();
22+
23+
public async Task<SqlConnection> OpenAsync(string? database, CancellationToken cancellationToken)
24+
{
25+
if (_entries.Count == 0)
26+
{
27+
throw new McpException(
28+
"No SQL Server connection strings are configured. Set 'Mcp:SqlServer:ConnectionStrings' in configuration.",
29+
McpErrorCode.InvalidParams);
30+
}
31+
32+
string connectionString;
33+
if (string.IsNullOrWhiteSpace(database))
34+
{
35+
connectionString = _entries[0].Value;
36+
}
37+
else
38+
{
39+
var match = _entries.FirstOrDefault(
40+
e => string.Equals(e.Key, database, StringComparison.OrdinalIgnoreCase));
41+
if (match.Value is null)
42+
{
43+
var known = string.Join(", ", _entries.Select(e => e.Key));
44+
throw new McpException(
45+
$"Unknown database '{database}'. Configured: [{known}].",
46+
McpErrorCode.InvalidParams);
47+
}
48+
connectionString = match.Value;
49+
}
50+
51+
var conn = new SqlConnection(connectionString);
52+
try
53+
{
54+
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
55+
return conn;
56+
}
57+
catch
58+
{
59+
await conn.DisposeAsync().ConfigureAwait(false);
60+
throw;
61+
}
62+
}
63+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
using ModelContextProtocol;
3+
4+
namespace CloudEngAgent.Mcp.Server.Sql;
5+
6+
/// <summary>
7+
/// Helpers for validating and quoting SQL identifiers (schema/table/column
8+
/// names). The MCP tools never accept arbitrary SQL: every identifier passed
9+
/// in by a caller must pass <see cref="IsValid"/> AND be present in
10+
/// <c>INFORMATION_SCHEMA</c> before it is interpolated into a query.
11+
/// Values are always sent as parameters; identifiers go through
12+
/// <see cref="Quote(string)"/>.
13+
/// </summary>
14+
public static class SqlIdentifier
15+
{
16+
private const int MaxLength = 128;
17+
18+
/// <summary>
19+
/// Returns true when <paramref name="identifier"/> is a "safe" SQL
20+
/// identifier: 1-128 chars, starts with a letter or underscore, and
21+
/// contains only ASCII letters, digits, and underscores. We deliberately
22+
/// reject anything else (dots, brackets, quotes, spaces, hyphens) so that
23+
/// SQL-injection attempts via identifier slots are rejected before any
24+
/// query is built.
25+
/// </summary>
26+
public static bool IsValid([NotNullWhen(true)] string? identifier)
27+
{
28+
if (string.IsNullOrEmpty(identifier) || identifier.Length > MaxLength)
29+
{
30+
return false;
31+
}
32+
33+
var first = identifier[0];
34+
if (!IsAsciiLetter(first) && first != '_')
35+
{
36+
return false;
37+
}
38+
39+
for (var i = 1; i < identifier.Length; i++)
40+
{
41+
var c = identifier[i];
42+
if (!IsAsciiLetter(c) && !IsAsciiDigit(c) && c != '_')
43+
{
44+
return false;
45+
}
46+
}
47+
48+
return true;
49+
}
50+
51+
/// <summary>
52+
/// Validates and quotes <paramref name="identifier"/> using T-SQL bracket
53+
/// quoting (e.g., <c>dbo</c> → <c>[dbo]</c>). Throws
54+
/// <see cref="McpException"/> with <see cref="McpErrorCode.InvalidParams"/>
55+
/// when the identifier fails <see cref="IsValid"/>.
56+
/// </summary>
57+
public static string Quote(string? identifier)
58+
{
59+
if (!IsValid(identifier))
60+
{
61+
throw new McpException(
62+
$"Invalid SQL identifier '{identifier}'. Only ASCII letters, digits, and underscores are allowed (must start with a letter or underscore, max {MaxLength} chars).",
63+
McpErrorCode.InvalidParams);
64+
}
65+
66+
// Identifier is already constrained to [A-Za-z0-9_], so no embedded
67+
// ']' is possible — bracket quoting is sufficient. Defense-in-depth:
68+
// double any ']' anyway in case the validation surface ever changes.
69+
return "[" + identifier.Replace("]", "]]", StringComparison.Ordinal) + "]";
70+
}
71+
72+
private static bool IsAsciiLetter(char c) =>
73+
(c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
74+
75+
private static bool IsAsciiDigit(char c) => c >= '0' && c <= '9';
76+
}

0 commit comments

Comments
 (0)