2026-09-17 09:01:14 +00:00
using System ;
using System.Collections.Generic ;
2026-10-02 12:46:52 +00:00
using System.Data.Common ;
2026-09-17 09:01:14 +00:00
using System.Linq ;
2026-09-24 11:58:23 +00:00
using System.Text.Json ;
2026-10-07 13:53:08 +00:00
using System.Text.RegularExpressions ;
2026-09-17 09:01:14 +00:00
using System.Threading.Tasks ;
using Microsoft.AspNetCore.Authorization ;
2026-10-07 14:34:49 +00:00
using Microsoft.Extensions.Configuration ;
2026-09-17 09:01:14 +00:00
using Sozsoft.Mcp.Dtos ;
using Sozsoft.Mcp.Services ;
using Sozsoft.Platform ;
2026-10-17 18:51:10 +00:00
using Sozsoft.Platform.Data ;
2026-10-07 14:34:49 +00:00
using Sozsoft.Platform.Data.Seeds ;
2026-09-17 09:01:14 +00:00
using Sozsoft.Platform.Entities ;
using Sozsoft.Platform.Enums ;
using Sozsoft.Platform.ListForms.Select ;
using Volo.Abp ;
using Volo.Abp.Domain.Repositories ;
namespace Sozsoft.Mcp ;
/// <summary>
/// Salt-okunur sorgu yuzeyi. MCP kullaniciya yetki vermez, uygulamadaki yetkisini tasir:
/// <list type="bullet">
/// <item><c>query_list</c> ekranin kendi sorgusundan gecer; liste ve alan yetkileri, kiraci ve
/// sube filtreleri aynen uygulanir.</item>
/// <item>Ham SQL ve veritabani nesneleri (view/procedure/function) liste yetkilerini asar; bu
/// yuzden yalnizca uygulamada SQL Query Manager yetkisi olan kullaniciya aciktir.</item>
/// <item>Custom Endpoint'ler endpoint dispatcher'inin yetkisini ve endpoint'in kendi
/// kullanici/rol listesini ister.</item>
/// </list>
/// SQL kurallari <see cref="IMcpSqlGuard"/>, veri kaynagi cozumu ve calistirma
/// <see cref="IMcpQueryExecutor"/> icindedir.
/// </summary>
[Authorize(PlatformConsts.AppCodes.DeveloperKits.McpServers.Invoke)]
2026-10-07 13:53:08 +00:00
public partial class McpQueryAppService (
2026-09-17 09:01:14 +00:00
IMcpServerConfigProvider serverConfigProvider ,
IMcpSqlGuard sqlGuard ,
IMcpQueryExecutor queryExecutor ,
IRepository < CustomEndpoint , Guid > customEndpointRepository ,
2026-09-24 11:58:23 +00:00
IListFormSelectAppService listFormSelectAppService ,
2026-10-07 14:34:49 +00:00
IMcpListFormAccessFilter accessFilter ,
IConfiguration configuration )
2026-09-17 09:01:14 +00:00
: McpAppService ( serverConfigProvider ) , IMcpQueryAppService
{
private const string SqlPermission = PlatformConsts . AppCodes . SqlQueryManagers . Default ;
private const string CustomEndpointPermission = PlatformConsts . AppCodes . DeveloperKits . Get ;
2026-10-10 22:03:36 +00:00
public async Task < McpOutcome < McpListQueryResultDto > > QueryListAsync ( McpListQueryRequestDto input )
2026-09-17 09:01:14 +00:00
{
Check . NotNull ( input , nameof ( input ) ) ;
2026-10-10 22:03:36 +00:00
if ( ! ( await EnsureToolAllowedAsync ( McpToolNames . QueryList ) ) . TryGetValue ( out var config , out var problem ) )
{
return problem ;
}
2026-09-17 09:01:14 +00:00
var take = ResolveMaxRows ( config , input . Take ) ;
2026-09-24 11:58:23 +00:00
var permission = await accessFilter . GetPermissionNameAsync ( input . ListFormCode , AuthorizationTypeEnum . Read ) ;
2026-10-10 22:03:36 +00:00
if ( permission is not null & & await CheckPermissionAsync ( McpToolNames . QueryList , permission ) is { } denied )
{
return denied ;
}
if ( ! ToLoadOption ( nameof ( input . Sort ) , input . Sort , McpLoadOptions . ToSort ) . TryGetValue ( out var sort , out problem ) | |
! ToLoadOption ( nameof ( input . Group ) , input . Group , McpLoadOptions . ToGroup ) . TryGetValue ( out var group , out problem ) | |
! ToLoadOption ( nameof ( input . GroupSummary ) , input . GroupSummary , McpLoadOptions . ToSummary ) . TryGetValue ( out var groupSummary , out problem ) | |
! ToLoadOption ( nameof ( input . TotalSummary ) , input . TotalSummary , McpLoadOptions . ToSummary ) . TryGetValue ( out var totalSummary , out problem ) )
2026-09-24 11:58:23 +00:00
{
2026-10-10 22:03:36 +00:00
return problem ;
2026-09-24 11:58:23 +00:00
}
2026-09-17 09:01:14 +00:00
var result = await listFormSelectAppService . GetSelectAsync ( new SelectRequestDto
{
ListFormCode = input . ListFormCode ,
Filter = NullIfEmpty ( input . Filter ) ,
2026-10-10 22:03:36 +00:00
Sort = sort ,
Group = group ,
GroupSummary = groupSummary ,
TotalSummary = totalSummary ,
2026-09-17 09:01:14 +00:00
Skip = Math . Max ( input . Skip , 0 ) ,
Take = take ,
RequireTotalCount = true ,
RequireGroupCount = ! string . IsNullOrWhiteSpace ( input . Group )
} ) ;
return new McpListQueryResultDto
{
Data = result . Data ,
TotalCount = result . TotalCount ,
GroupCount = result . GroupCount ,
Summary = result . Summary ,
Take = take
} ;
}
2026-09-24 11:58:23 +00:00
/// <summary>Bozuk girdi SQL hatasina donusmeden, hangi parametrede oldugu soylenerek reddedilir.</summary>
2026-10-10 22:03:36 +00:00
private McpOutcome < string? > ToLoadOption ( string parameter , string? value , Func < string? , string? > convert )
2026-09-24 11:58:23 +00:00
{
try
{
return convert ( value ) ;
}
catch ( Exception ex ) when ( ex is JsonException or InvalidOperationException )
{
2026-10-10 22:03:36 +00:00
return Problem ( McpErrorCodes . InvalidLoadOption , JsonNamingPolicy . CamelCase . ConvertName ( parameter ) ) ;
2026-09-24 11:58:23 +00:00
}
}
2026-10-10 22:03:36 +00:00
public async Task < McpOutcome < McpQueryResultDto > > RunSelectAsync ( McpSelectRequestDto input )
2026-09-17 09:01:14 +00:00
{
Check . NotNull ( input , nameof ( input ) ) ;
2026-10-10 22:03:36 +00:00
if ( ! ( await EnsureToolAllowedAsync ( McpToolNames . RunSelect ) ) . TryGetValue ( out var config , out var problem ) )
{
return problem ;
}
2026-09-17 09:01:14 +00:00
var sql = ( input . Sql ? ? string . Empty ) . Trim ( ) . TrimEnd ( ';' ) . Trim ( ) ;
2026-10-10 22:03:36 +00:00
if ( sqlGuard . CheckReadOnlyStatement ( sql ) is { } invalid )
{
return invalid ;
}
if ( ! sqlGuard . NormalizeParameters ( input . Parameters ) . TryGetValue ( out var parameters , out problem ) )
{
return problem ;
}
2026-09-17 09:01:14 +00:00
var maxRows = ResolveMaxRows ( config , input . MaxRows ) ;
2026-09-18 13:47:23 +00:00
var context = await queryExecutor . ResolveDataSourceAsync ( ) ;
2026-09-17 09:01:14 +00:00
var limitedSql = sqlGuard . ApplyRowLimit ( sql , maxRows , context . DataSourceType ) ;
2026-10-02 12:46:52 +00:00
// Sorguyu model yazar ve tablonun kolonlarini tahmin eder (her tabloda IsDeleted yok).
// Saglayici hatasi cevrilmezse istemciye yalnizca genel hata gider ve model sorguyu
// duzeltemez. Arac zaten SQL yetkisi istedigi icin hata metni yeni bilgi sizdirmaz.
try
{
return ToResult ( await queryExecutor . ExecuteAsync ( context , limitedSql , parameters , maxRows ) ) ;
}
catch ( DbException ex )
{
2026-10-07 14:34:49 +00:00
var pending = IsUnknownObjectError ( ex ) ? DescribePendingTables ( sql ) : null ;
if ( pending is not null )
2026-10-10 22:03:36 +00:00
return Problem ( McpErrorCodes . SelectTableNotInstalled , ex . Message , pending ) ;
2026-10-07 14:34:49 +00:00
2026-10-07 13:53:08 +00:00
var columns = IsUnknownColumnError ( ex ) ? await DescribeTableColumnsAsync ( context , sql ) : null ;
2026-10-10 22:03:36 +00:00
return columns is null
? Problem ( McpErrorCodes . SelectFailed , ex . Message )
: Problem ( McpErrorCodes . SelectFailedColumns , ex . Message , columns ) ;
2026-10-02 12:46:52 +00:00
}
2026-09-17 09:01:14 +00:00
}
2026-10-07 13:53:08 +00:00
private const int MaxDescribedTables = 5 ;
/// <summary>SQL Server 207 ("Invalid column name"), PostgreSQL 42703 (undefined_column).</summary>
private static bool IsUnknownColumnError ( DbException ex ) = >
ex . SqlState = = "42703" | | ex . Message . Contains ( "Invalid column name" , StringComparison . OrdinalIgnoreCase ) ;
2026-10-07 14:34:49 +00:00
/// <summary>SQL Server 208 ("Invalid object name"), PostgreSQL 42P01 (undefined_table).</summary>
private static bool IsUnknownObjectError ( DbException ex ) = >
ex . SqlState = = "42P01" | | ex . Message . Contains ( "Invalid object name" , StringComparison . OrdinalIgnoreCase ) ;
/// <summary>
/// Sorgunun andigi ve veritabaninda olmayan tablolardan seed scripti olanlar (<c>Tablo(A, B); …</c>).
/// <c>create_table</c> tabloyu yalnizca scripte yazar, kurulum DB Migrate'tedir; model ayni talepte
/// tabloyu sorgularsa kolon tahmini yuzunden degil kurulum beklendigi icin bos doner. Kolonlar
/// scriptten okunur, boylece model sema araci cagirmadan ilerler. Scripti olan tablo yoksa null.
/// </summary>
private string? DescribePendingTables ( string sql )
{
var described = TableReferenceRegex ( ) . Matches ( sql )
. Select ( match = > match . Groups [ "table" ] . Value )
. Distinct ( StringComparer . OrdinalIgnoreCase )
. Take ( MaxDescribedTables )
. Select ( table = > ( Table : table , Columns : PostgreSqlTableScript . ReadSeedColumns ( configuration , table ) ) )
. Where ( item = > item . Columns . Count > 0 )
. Select ( item = > $"{item.Table}({string.Join(" , ", item.Columns.Keys)})" )
. ToList ( ) ;
return described . Count = = 0 ? null : string . Join ( "; " , described ) ;
}
2026-10-07 13:53:08 +00:00
/// <summary>
/// Sorgunun FROM/JOIN ile andigi tablolarin gercek kolonlari (<c>Tablo(A, B, C); …</c>). Model
/// kolonu tahmin ettiyse dogru listeyi ayni cevapta gorur ve ayrica sema araci cagirmadan duzeltir.
/// Kolonlar okunamazsa null doner; asil hata yine iletilir.
/// </summary>
private async Task < string? > DescribeTableColumnsAsync ( McpDataSourceContext context , string sql )
{
var tables = TableReferenceRegex ( ) . Matches ( sql )
. Select ( match = > match . Groups [ "table" ] . Value )
. Distinct ( StringComparer . OrdinalIgnoreCase )
. Take ( MaxDescribedTables )
. ToList ( ) ;
if ( tables . Count = = 0 )
return null ;
var parameters = tables . Select ( ( table , index ) = > ( Name : $"t{index}" , Table : table ) )
. ToDictionary ( p = > p . Name , p = > ( object ) p . Table ) ;
try
{
var rows = await queryExecutor . ExecuteAsync (
context ,
$"SELECT TABLE_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME IN ({string.Join(" , ", parameters.Keys.Select(name => " @" + name))}) ORDER BY TABLE_NAME, ORDINAL_POSITION" ,
parameters ,
int . MaxValue ) ;
var described = rows . Rows
. Select ( row = > ( Table : ReadIgnoreCase ( row , "TABLE_NAME" ) , Column : ReadIgnoreCase ( row , "COLUMN_NAME" ) ) )
. Where ( item = > item . Table is not null & & item . Column is not null )
. GroupBy ( item = > item . Table ! , StringComparer . OrdinalIgnoreCase )
. Select ( group = > $"{group.Key}({string.Join(" , ", group.Select(item => item.Column))})" )
. ToList ( ) ;
return described . Count = = 0 ? null : string . Join ( "; " , described ) ;
}
catch ( DbException )
{
return null ;
}
}
private static string? ReadIgnoreCase ( Dictionary < string , object? > row , string key ) = >
row . FirstOrDefault ( pair = > string . Equals ( pair . Key , key , StringComparison . OrdinalIgnoreCase ) ) . Value ? . ToString ( ) ;
/// <summary>FROM/JOIN'den sonraki ad; sema ve kose parantezi/tirnak atilir (<c>[dbo].[Tablo]</c> → <c>Tablo</c>).</summary>
[GeneratedRegex(@"\b(?:FROM|JOIN)\s+(?:[\[""] ? \ w + [ \ ] "" ] ? \ . ) ? [ \ [ "" ] ? ( ? < table > \ w + ) [ \ ] "" ] ? ", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex TableReferenceRegex ( ) ;
2026-10-10 22:03:36 +00:00
public async Task < McpOutcome < List < McpSavedQueryDto > > > GetSavedQueriesAsync ( string? search = null )
2026-09-17 09:01:14 +00:00
{
2026-10-10 22:03:36 +00:00
if ( await EnsureToolAllowedAsync ( McpToolNames . ListQueries ) is { Problem : { } problem } )
{
return problem ;
}
2026-09-17 09:01:14 +00:00
var results = new List < McpSavedQueryDto > ( ) ;
if ( await AuthorizationService . IsGrantedAsync ( SqlPermission ) )
{
2026-09-18 13:47:23 +00:00
results . AddRange ( await GetDatabaseObjectsAsync ( ) ) ;
2026-09-17 09:01:14 +00:00
}
if ( await AuthorizationService . IsGrantedAsync ( CustomEndpointPermission ) )
{
results . AddRange ( await GetCallableCustomEndpointsAsync ( ) ) ;
}
if ( ! string . IsNullOrWhiteSpace ( search ) )
{
results = [ . . results . Where ( item = >
item . Name . Contains ( search , StringComparison . OrdinalIgnoreCase )
| | ( item . Description ? . Contains ( search , StringComparison . OrdinalIgnoreCase ) ? ? false ) ) ] ;
}
2026-10-10 22:03:36 +00:00
return results
2026-09-17 09:01:14 +00:00
. OrderBy ( item = > item . Kind , StringComparer . Ordinal )
2026-10-10 22:03:36 +00:00
. ThenBy ( item = > item . Name , StringComparer . OrdinalIgnoreCase )
. ToList ( ) ;
2026-09-17 09:01:14 +00:00
}
2026-09-18 13:47:23 +00:00
private async Task < List < McpSavedQueryDto > > GetDatabaseObjectsAsync ( )
2026-09-17 09:01:14 +00:00
{
2026-09-18 13:47:23 +00:00
var context = await queryExecutor . ResolveDataSourceAsync ( ) ;
2026-10-17 18:51:10 +00:00
var catalogSql = SqlCatalogQueries . NativeObjects ( context . DataSourceType ) ;
2026-09-17 09:01:14 +00:00
// Katalog sorgusunun kendi satir siniri yoktur; nesne sayisi tavani asmaz.
var catalog = await queryExecutor . ExecuteAsync ( context , catalogSql , new Dictionary < string , object > ( ) , int . MaxValue ) ;
var results = new List < McpSavedQueryDto > ( ) ;
foreach ( var row in catalog . Rows )
{
var kind = MapCatalogKind ( ReadString ( row , "ObjectKind" ) ) ;
if ( kind is null )
{
continue ;
}
results . Add ( new McpSavedQueryDto
{
Kind = kind ,
SchemaName = ReadString ( row , "SchemaName" ) ,
Name = ReadString ( row , "ObjectName" ) ? ? string . Empty
} ) ;
}
return results ;
}
2026-10-10 22:03:36 +00:00
public async Task < McpOutcome < McpQueryResultDto > > RunSavedQueryAsync ( McpSavedQueryRequestDto input )
2026-09-17 09:01:14 +00:00
{
Check . NotNull ( input , nameof ( input ) ) ;
2026-10-10 22:03:36 +00:00
if ( ! ( await EnsureToolAllowedAsync ( McpToolNames . RunQuery ) ) . TryGetValue ( out var config , out var problem ) )
{
return problem ;
}
2026-09-17 09:01:14 +00:00
2026-10-10 22:03:36 +00:00
var isCustomEndpoint = IsKind ( input . Kind , McpConsts . SavedQueryKinds . CustomEndpoint ) ;
if ( await CheckPermissionAsync ( McpToolNames . RunQuery , isCustomEndpoint ? CustomEndpointPermission : SqlPermission ) is { } denied )
2026-09-17 09:01:14 +00:00
{
2026-10-10 22:03:36 +00:00
return denied ;
2026-09-17 09:01:14 +00:00
}
2026-10-10 22:03:36 +00:00
if ( ! sqlGuard . NormalizeParameters ( input . Parameters ) . TryGetValue ( out var parameters , out problem ) )
{
return problem ;
}
2026-09-17 09:01:14 +00:00
var maxRows = ResolveMaxRows ( config , input . MaxRows ) ;
2026-10-10 22:03:36 +00:00
if ( isCustomEndpoint )
{
return await RunCustomEndpointAsync ( input , parameters , maxRows ) ;
}
2026-09-18 13:47:23 +00:00
var context = await queryExecutor . ResolveDataSourceAsync ( ) ;
2026-09-17 09:01:14 +00:00
var schemaName = string . IsNullOrWhiteSpace ( input . SchemaName )
2026-10-17 18:51:10 +00:00
? SqlIdentifier . DefaultSchema ( context . DataSourceType )
2026-09-17 09:01:14 +00:00
: input . SchemaName ;
2026-10-10 22:03:36 +00:00
if ( ! sqlGuard . QuoteQualifiedName ( schemaName , input . Name , context . DataSourceType ) . TryGetValue ( out var qualifiedName , out problem ) | |
! BuildSavedQuerySql ( input . Kind , qualifiedName , parameters , maxRows , context . DataSourceType ) . TryGetValue ( out var sql , out problem ) )
{
return problem ;
}
2026-09-17 09:01:14 +00:00
return ToResult ( await queryExecutor . ExecuteAsync ( context , sql , parameters , maxRows ) ) ;
}
2026-10-10 22:03:36 +00:00
private async Task < McpOutcome < McpQueryResultDto > > RunCustomEndpointAsync (
2026-09-17 09:01:14 +00:00
McpSavedQueryRequestDto input ,
2026-10-10 22:03:36 +00:00
Dictionary < string , object > parameters ,
int maxRows )
2026-09-17 09:01:14 +00:00
{
var endpoints = await GetCallableEndpointEntitiesAsync ( ) ;
2026-10-10 22:03:36 +00:00
var endpoint = endpoints . Find ( candidate = > string . Equals ( candidate . Name , input . Name , StringComparison . OrdinalIgnoreCase ) ) ;
if ( endpoint is null )
{
return Problem ( McpErrorCodes . CustomEndpointNotFound , input . Name ) ;
}
2026-09-17 09:01:14 +00:00
// Eksik parametre Dapper tarafinda belirsiz bir hataya donusmesin diye burada yakalanir.
var missing = endpoint . Parameters
. Where ( parameter = > parameter . IsRequired & & ! parameters . ContainsKey ( parameter . Name ) )
. Select ( parameter = > parameter . Name )
. ToList ( ) ;
if ( missing . Count > 0 )
{
2026-10-10 22:03:36 +00:00
return Problem ( McpErrorCodes . MissingParameters , string . Join ( ", " , missing ) ) ;
2026-09-17 09:01:14 +00:00
}
var context = await queryExecutor . ResolveDataSourceAsync ( endpoint . DataSourceCode ) ;
return ToResult ( await queryExecutor . ExecuteAsync ( context , endpoint . Sql , parameters , maxRows ) ) ;
}
private async Task < List < McpSavedQueryDto > > GetCallableCustomEndpointsAsync ( )
{
var endpoints = await GetCallableEndpointEntitiesAsync ( ) ;
return [ . . endpoints . Select ( endpoint = > new McpSavedQueryDto
{
Kind = McpConsts . SavedQueryKinds . CustomEndpoint ,
Name = endpoint . Name ,
Description = endpoint . Description ,
ParameterNames = [ . . endpoint . Parameters . Select ( parameter = > parameter . Name ) ]
} ) ] ;
}
/// <summary>
/// Yalnizca okuma amacli (GET) ve istegi yapan kullaniciya acik Custom Endpoint kayitlari.
/// Yetki sozlesmesi endpoint dispatcher'indaki ile birebir aynidir.
/// </summary>
private async Task < List < CustomEndpoint > > GetCallableEndpointEntitiesAsync ( )
{
var query = await customEndpointRepository . GetQueryableAsync ( ) ;
var candidates = query . Where ( endpoint = > endpoint . Method = = "GET" ) . ToList ( ) ;
return [ . . candidates . Where ( endpoint = > endpoint . Permissions . Any ( permission = >
permission . ResourceType = = "Global"
| | ( permission . ResourceType = = "User" & & permission . ResourceId = = CurrentUser . UserName )
| | ( permission . ResourceType = = "Role" & & CurrentUser . Roles . Contains ( permission . ResourceId ) ) ) ) ] ;
}
2026-10-10 22:03:36 +00:00
private McpOutcome < string > BuildSavedQuerySql (
2026-09-17 09:01:14 +00:00
string? kind ,
string qualifiedName ,
Dictionary < string , object > parameters ,
int maxRows ,
DataSourceTypeEnum dataSourceType )
{
var parameterList = string . Join ( ", " , parameters . Keys . Select ( name = > $"@{name}" ) ) ;
if ( IsKind ( kind , McpConsts . SavedQueryKinds . View ) )
{
return sqlGuard . ApplyRowLimit ( $"SELECT * FROM {qualifiedName}" , maxRows , dataSourceType ) ;
}
if ( IsKind ( kind , McpConsts . SavedQueryKinds . TableFunction ) )
{
return sqlGuard . ApplyRowLimit ( $"SELECT * FROM {qualifiedName}({parameterList})" , maxRows , dataSourceType ) ;
}
if ( IsKind ( kind , McpConsts . SavedQueryKinds . ScalarFunction ) )
{
return $"SELECT {qualifiedName}({parameterList}) AS Value" ;
}
if ( IsKind ( kind , McpConsts . SavedQueryKinds . Procedure ) )
{
// Prosedur cagrisina satir tavani yazilamaz; sonuc kumesi okunurken kesilir.
return dataSourceType = = DataSourceTypeEnum . Postgresql
? $"CALL {qualifiedName}({parameterList})"
: $"EXEC {qualifiedName} {string.Join(" , ", parameters.Keys.Select(name => $" @ { name } = @ { name } "))}" . TrimEnd ( ) ;
}
2026-10-10 22:03:36 +00:00
return Problem ( McpErrorCodes . UnsupportedObjectKind , kind ? ? string . Empty ) ;
2026-09-17 09:01:14 +00:00
}
private static string? NullIfEmpty ( string? value )
= > string . IsNullOrWhiteSpace ( value ) ? null : value ;
private static McpQueryResultDto ToResult ( McpQueryRows rows ) = > new ( )
{
Columns = rows . Columns ,
Rows = rows . Rows ,
RowCount = rows . Rows . Count ,
Truncated = rows . Truncated ,
ExecutionTimeMs = rows . ExecutionTimeMs
} ;
private static bool IsKind ( string? kind , string expected )
= > string . Equals ( kind , expected , StringComparison . OrdinalIgnoreCase ) ;
private static string? MapCatalogKind ( string? catalogKind ) = > catalogKind switch
{
McpConsts . CatalogKinds . View = > McpConsts . SavedQueryKinds . View ,
McpConsts . CatalogKinds . Procedure = > McpConsts . SavedQueryKinds . Procedure ,
McpConsts . CatalogKinds . TableFunction = > McpConsts . SavedQueryKinds . TableFunction ,
McpConsts . CatalogKinds . ScalarFunction = > McpConsts . SavedQueryKinds . ScalarFunction ,
_ = > null
} ;
private static string? ReadString ( Dictionary < string , object? > row , string key )
= > row . TryGetValue ( key , out var value ) ? value ? . ToString ( ) : null ;
}