using System.ComponentModel; using System.Globalization; using System.Text; using System.Text.RegularExpressions; using ModelContextProtocol; using ModelContextProtocol.Server; using Npgsql; /// The three tools. Every query runs as the role in SCHEMA_MCP_CONNECTION. public sealed class SchemaTools(NpgsqlDataSource db) { private const int MaxRows = 100; private const string StatementTimeout = "5s"; [McpServerTool(Name = "list_tables", ReadOnly = true)] [Description("Lists the tables the database role can read, as schema.table.")] public async Task ListTables(CancellationToken cancellationToken) { const string sql = """ SELECT table_schema, table_name FROM information_schema.tables WHERE table_type = 'BASE TABLE' AND table_schema NOT IN ('pg_catalog', 'information_schema') ORDER BY table_schema, table_name """; var text = new StringBuilder(); await using var command = db.CreateCommand(sql); await using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) { text.AppendLine($"{reader.GetString(0)}.{reader.GetString(1)}"); } return text.Length == 0 ? "No tables visible to this role." : text.ToString(); } [McpServerTool(Name = "describe_table", ReadOnly = true)] [Description("Describes one table: columns with types and nullability, primary and foreign keys, " + "indexes, and the foreign keys in other tables that point at it. Names are case-sensitive.")] public async Task DescribeTable( [Description("Table name exactly as stored, for example Appointments")] string table, [Description("Schema name; defaults to public")] string schema = "public", CancellationToken cancellationToken = default) { await using var connection = await db.OpenConnectionAsync(cancellationToken); await using var find = new NpgsqlCommand("SELECT to_regclass(format('%I.%I', $1, $2))::oid", connection); find.Parameters.AddWithValue(schema); find.Parameters.AddWithValue(table); if (await find.ExecuteScalarAsync(cancellationToken) is not uint relation) { throw new McpException($"Table {schema}.{table} not found. Call list_tables for the exact names."); } var text = new StringBuilder().AppendLine($"Table {schema}.{table}").AppendLine().AppendLine("Columns:"); await AppendRows(connection, relation, text, """ SELECT a.attname || ' ' || format_type(a.atttypid, a.atttypmod) || CASE WHEN a.attnotnull THEN ' NOT NULL' ELSE ' NULL' END || CASE a.attidentity WHEN 'a' THEN ' GENERATED ALWAYS AS IDENTITY' WHEN 'd' THEN ' GENERATED BY DEFAULT AS IDENTITY' ELSE '' END || COALESCE(' DEFAULT ' || pg_get_expr(d.adbin, d.adrelid), '') FROM pg_attribute a LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum WHERE a.attrelid = $1 AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum """, cancellationToken); text.AppendLine().AppendLine("Constraints (primary key, foreign keys, unique, check):"); await AppendRows(connection, relation, text, """ SELECT conname || ': ' || pg_get_constraintdef(oid) FROM pg_constraint WHERE conrelid = $1 AND contype <> 'n' -- PostgreSQL 18 lists NOT NULL here too ORDER BY CASE contype WHEN 'p' THEN 0 WHEN 'f' THEN 1 ELSE 2 END, conname """, cancellationToken); text.AppendLine().AppendLine("Indexes:"); await AppendRows(connection, relation, text, "SELECT pg_get_indexdef(indexrelid) FROM pg_index WHERE indrelid = $1 ORDER BY indexrelid::regclass::text", cancellationToken); text.AppendLine().AppendLine("Referenced by:"); await AppendRows(connection, relation, text, """ SELECT conrelid::regclass::text || ' ' || conname || ': ' || pg_get_constraintdef(oid) FROM pg_constraint WHERE confrelid = $1 AND contype = 'f' ORDER BY 1 """, cancellationToken); return text.ToString(); } [McpServerTool(Name = "run_select", ReadOnly = true)] [Description("Runs ONE SELECT statement in a read-only transaction and returns at most 100 rows. " + "Anything else (INSERT, UPDATE, DELETE, DDL, several statements) is refused. " + "Quote mixed-case names: SELECT \"FullName\" FROM \"Doctors\".")] public async Task RunSelect( [Description("A single SELECT statement")] string sql, CancellationToken cancellationToken) { // A convenience check with a clear message. It is not the safety net: the read-only // transaction and the role's privileges are (a reader who connects as an owner loses both). if (RefusalReason(sql) is { } reason) { throw new McpException($"Refused by SchemaMcp: {reason}"); } try { await using var connection = await db.OpenConnectionAsync(cancellationToken); await using var transaction = await connection.BeginTransactionAsync(cancellationToken); await using (var setup = new NpgsqlCommand( $"SET TRANSACTION READ ONLY; SET LOCAL statement_timeout = '{StatementTimeout}'", connection, transaction)) { await setup.ExecuteNonQueryAsync(cancellationToken); } await using var command = new NpgsqlCommand(sql, connection, transaction); await using var reader = await command.ExecuteReaderAsync(cancellationToken); var text = new StringBuilder(); text.AppendLine(string.Join(" | ", Enumerable.Range(0, reader.FieldCount).Select(reader.GetName))); var rows = 0; while (rows < MaxRows && await reader.ReadAsync(cancellationToken)) { text.AppendLine(string.Join(" | ", Enumerable.Range(0, reader.FieldCount) .Select(i => Format(reader.GetValue(i))))); rows++; } var more = rows == MaxRows && await reader.ReadAsync(cancellationToken); text.Append(more ? $"(first {MaxRows} rows; more exist)" : $"({rows} rows)"); return text.ToString(); // no commit: disposing the transaction rolls it back } catch (PostgresException e) { throw new McpException($"PostgreSQL refused the query: {e.SqlState}: {e.MessageText}"); } } private static string? RefusalReason(string sql) { var text = sql.Trim().TrimEnd(';').TrimEnd(); if (text.Contains(';')) { return "only one statement is allowed."; } return Regex.IsMatch(text, @"^SELECT\b", RegexOptions.IgnoreCase) ? null : "only a single SELECT statement is allowed."; } private static async Task AppendRows(NpgsqlConnection connection, uint relation, StringBuilder text, string sql, CancellationToken cancellationToken) { await using var command = new NpgsqlCommand(sql, connection); command.Parameters.Add(new NpgsqlParameter { Value = relation, DataTypeName = "oid" }); await using var reader = await command.ExecuteReaderAsync(cancellationToken); var any = false; while (await reader.ReadAsync(cancellationToken)) { text.AppendLine(" " + reader.GetString(0)); any = true; } if (!any) { text.AppendLine(" (none)"); } } private static string Format(object value) => value switch { DBNull => "NULL", DateTime d => d.ToString("O", CultureInfo.InvariantCulture), IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), _ => value.ToString() ?? "", }; }