using System; using System.Collections.Generic; using System.Data.Common; using System.Linq; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Configuration; using Sozsoft.Mcp.Dtos; using Sozsoft.Mcp.Services; using Sozsoft.Platform; using Sozsoft.Platform.Data.Seeds; using Sozsoft.Platform.Entities; using Sozsoft.Platform.Enums; using Sozsoft.Platform.ListForms.Select; using Volo.Abp; using Volo.Abp.Domain.Repositories; namespace Sozsoft.Mcp; /// /// Salt-okunur sorgu yuzeyi. MCP kullaniciya yetki vermez, uygulamadaki yetkisini tasir: /// /// query_list ekranin kendi sorgusundan gecer; liste ve alan yetkileri, kiraci ve /// sube filtreleri aynen uygulanir. /// Ham SQL ve veritabani nesneleri (view/procedure/function) liste yetkilerini asar; bu /// yuzden yalnizca uygulamada SQL Query Manager yetkisi olan kullaniciya aciktir. /// Custom Endpoint'ler endpoint dispatcher'inin yetkisini ve endpoint'in kendi /// kullanici/rol listesini ister. /// /// SQL kurallari , veri kaynagi cozumu ve calistirma /// icindedir. /// [Authorize(PlatformConsts.AppCodes.DeveloperKits.McpServers.Invoke)] public partial class McpQueryAppService( IMcpServerConfigProvider serverConfigProvider, IMcpSqlGuard sqlGuard, IMcpQueryExecutor queryExecutor, IRepository customEndpointRepository, IListFormSelectAppService listFormSelectAppService, IMcpListFormAccessFilter accessFilter, IConfiguration configuration) : McpAppService(serverConfigProvider), IMcpQueryAppService { private const string SqlPermission = PlatformConsts.AppCodes.SqlQueryManagers.Default; private const string CustomEndpointPermission = PlatformConsts.AppCodes.DeveloperKits.Get; public async Task> QueryListAsync(McpListQueryRequestDto input) { Check.NotNull(input, nameof(input)); if (!(await EnsureToolAllowedAsync(McpToolNames.QueryList)).TryGetValue(out var config, out var problem)) { return problem; } var take = ResolveMaxRows(config, input.Take); var permission = await accessFilter.GetPermissionNameAsync(input.ListFormCode, AuthorizationTypeEnum.Read); 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)) { return problem; } var result = await listFormSelectAppService.GetSelectAsync(new SelectRequestDto { ListFormCode = input.ListFormCode, Filter = NullIfEmpty(input.Filter), Sort = sort, Group = group, GroupSummary = groupSummary, TotalSummary = totalSummary, 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 }; } /// Bozuk girdi SQL hatasina donusmeden, hangi parametrede oldugu soylenerek reddedilir. private McpOutcome ToLoadOption(string parameter, string? value, Func convert) { try { return convert(value); } catch (Exception ex) when (ex is JsonException or InvalidOperationException) { return Problem(McpErrorCodes.InvalidLoadOption, JsonNamingPolicy.CamelCase.ConvertName(parameter)); } } public async Task> RunSelectAsync(McpSelectRequestDto input) { Check.NotNull(input, nameof(input)); if (!(await EnsureToolAllowedAsync(McpToolNames.RunSelect)).TryGetValue(out var config, out var problem)) { return problem; } var sql = (input.Sql ?? string.Empty).Trim().TrimEnd(';').Trim(); if (sqlGuard.CheckReadOnlyStatement(sql) is { } invalid) { return invalid; } if (!sqlGuard.NormalizeParameters(input.Parameters).TryGetValue(out var parameters, out problem)) { return problem; } var maxRows = ResolveMaxRows(config, input.MaxRows); var context = await queryExecutor.ResolveDataSourceAsync(); var limitedSql = sqlGuard.ApplyRowLimit(sql, maxRows, context.DataSourceType); // 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) { var pending = IsUnknownObjectError(ex) ? DescribePendingTables(sql) : null; if (pending is not null) return Problem(McpErrorCodes.SelectTableNotInstalled, ex.Message, pending); var columns = IsUnknownColumnError(ex) ? await DescribeTableColumnsAsync(context, sql) : null; return columns is null ? Problem(McpErrorCodes.SelectFailed, ex.Message) : Problem(McpErrorCodes.SelectFailedColumns, ex.Message, columns); } } private const int MaxDescribedTables = 5; /// SQL Server 207 ("Invalid column name"), PostgreSQL 42703 (undefined_column). private static bool IsUnknownColumnError(DbException ex) => ex.SqlState == "42703" || ex.Message.Contains("Invalid column name", StringComparison.OrdinalIgnoreCase); /// SQL Server 208 ("Invalid object name"), PostgreSQL 42P01 (undefined_table). private static bool IsUnknownObjectError(DbException ex) => ex.SqlState == "42P01" || ex.Message.Contains("Invalid object name", StringComparison.OrdinalIgnoreCase); /// /// Sorgunun andigi ve veritabaninda olmayan tablolardan seed scripti olanlar (Tablo(A, B); …). /// create_table 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. /// 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); } /// /// Sorgunun FROM/JOIN ile andigi tablolarin gercek kolonlari (Tablo(A, B, C); …). Model /// kolonu tahmin ettiyse dogru listeyi ayni cevapta gorur ve ayrica sema araci cagirmadan duzeltir. /// Kolonlar okunamazsa null doner; asil hata yine iletilir. /// private async Task 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 row, string key) => row.FirstOrDefault(pair => string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase)).Value?.ToString(); /// FROM/JOIN'den sonraki ad; sema ve kose parantezi/tirnak atilir ([dbo].[Tablo] → Tablo). [GeneratedRegex(@"\b(?:FROM|JOIN)\s+(?:[\[""]?\w+[\]""]?\.)?[\[""]?(?\w+)[\]""]?", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex TableReferenceRegex(); public async Task>> GetSavedQueriesAsync(string? search = null) { if (await EnsureToolAllowedAsync(McpToolNames.ListQueries) is { Problem: { } problem }) { return problem; } var results = new List(); if (await AuthorizationService.IsGrantedAsync(SqlPermission)) { results.AddRange(await GetDatabaseObjectsAsync()); } 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))]; } return results .OrderBy(item => item.Kind, StringComparer.Ordinal) .ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase) .ToList(); } private async Task> GetDatabaseObjectsAsync() { var context = await queryExecutor.ResolveDataSourceAsync(); var catalogSql = context.DataSourceType == DataSourceTypeEnum.Postgresql ? McpCatalogQueries.Postgres : McpCatalogQueries.SqlServer; // Katalog sorgusunun kendi satir siniri yoktur; nesne sayisi tavani asmaz. var catalog = await queryExecutor.ExecuteAsync(context, catalogSql, new Dictionary(), int.MaxValue); var results = new List(); 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; } public async Task> RunSavedQueryAsync(McpSavedQueryRequestDto input) { Check.NotNull(input, nameof(input)); if (!(await EnsureToolAllowedAsync(McpToolNames.RunQuery)).TryGetValue(out var config, out var problem)) { return problem; } var isCustomEndpoint = IsKind(input.Kind, McpConsts.SavedQueryKinds.CustomEndpoint); if (await CheckPermissionAsync(McpToolNames.RunQuery, isCustomEndpoint ? CustomEndpointPermission : SqlPermission) is { } denied) { return denied; } if (!sqlGuard.NormalizeParameters(input.Parameters).TryGetValue(out var parameters, out problem)) { return problem; } var maxRows = ResolveMaxRows(config, input.MaxRows); if (isCustomEndpoint) { return await RunCustomEndpointAsync(input, parameters, maxRows); } var context = await queryExecutor.ResolveDataSourceAsync(); var schemaName = string.IsNullOrWhiteSpace(input.SchemaName) ? sqlGuard.DefaultSchemaName(context.DataSourceType) : input.SchemaName; 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; } return ToResult(await queryExecutor.ExecuteAsync(context, sql, parameters, maxRows)); } private async Task> RunCustomEndpointAsync( McpSavedQueryRequestDto input, Dictionary parameters, int maxRows) { var endpoints = await GetCallableEndpointEntitiesAsync(); var endpoint = endpoints.Find(candidate => string.Equals(candidate.Name, input.Name, StringComparison.OrdinalIgnoreCase)); if (endpoint is null) { return Problem(McpErrorCodes.CustomEndpointNotFound, input.Name); } // 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) { return Problem(McpErrorCodes.MissingParameters, string.Join(", ", missing)); } var context = await queryExecutor.ResolveDataSourceAsync(endpoint.DataSourceCode); return ToResult(await queryExecutor.ExecuteAsync(context, endpoint.Sql, parameters, maxRows)); } private async Task> 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)] })]; } /// /// Yalnizca okuma amacli (GET) ve istegi yapan kullaniciya acik Custom Endpoint kayitlari. /// Yetki sozlesmesi endpoint dispatcher'indaki ile birebir aynidir. /// private async Task> 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))))]; } private McpOutcome BuildSavedQuerySql( string? kind, string qualifiedName, Dictionary 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(); } return Problem(McpErrorCodes.UnsupportedObjectKind, kind ?? string.Empty); } 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 row, string key) => row.TryGetValue(key, out var value) ? value?.ToString() : null; }