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 Sozsoft.Mcp.Dtos;
using Sozsoft.Mcp.Services;
using Sozsoft.Platform;
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)
: 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));
var config = await EnsureToolAllowedAsync(McpToolNames.QueryList);
var take = ResolveMaxRows(config, input.Take);
var permission = await accessFilter.GetPermissionNameAsync(input.ListFormCode, AuthorizationTypeEnum.Read);
if (permission is not null)
{
await AuthorizationService.CheckAsync(permission);
}
var result = await listFormSelectAppService.GetSelectAsync(new SelectRequestDto
{
ListFormCode = input.ListFormCode,
Filter = NullIfEmpty(input.Filter),
Sort = ToLoadOption(nameof(input.Sort), input.Sort, McpLoadOptions.ToSort),
Group = ToLoadOption(nameof(input.Group), input.Group, McpLoadOptions.ToGroup),
GroupSummary = ToLoadOption(nameof(input.GroupSummary), input.GroupSummary, McpLoadOptions.ToSummary),
TotalSummary = ToLoadOption(nameof(input.TotalSummary), input.TotalSummary, McpLoadOptions.ToSummary),
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 string? ToLoadOption(string parameter, string? value, Func convert)
{
try
{
return convert(value);
}
catch (Exception ex) when (ex is JsonException or InvalidOperationException)
{
throw new UserFriendlyException(L[McpErrorCodes.InvalidLoadOption, JsonNamingPolicy.CamelCase.ConvertName(parameter)]);
}
}
public async Task RunSelectAsync(McpSelectRequestDto input)
{
Check.NotNull(input, nameof(input));
var config = await EnsureToolAllowedAsync(McpToolNames.RunSelect);
var sql = (input.Sql ?? string.Empty).Trim().TrimEnd(';').Trim();
sqlGuard.EnsureReadOnlyStatement(sql);
var maxRows = ResolveMaxRows(config, input.MaxRows);
var parameters = sqlGuard.NormalizeParameters(input.Parameters);
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 columns = IsUnknownColumnError(ex) ? await DescribeTableColumnsAsync(context, sql) : null;
throw new UserFriendlyException(columns is null
? L[McpErrorCodes.SelectFailed, ex.Message]
: L[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);
///
/// 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)
{
await EnsureToolAllowedAsync(McpToolNames.ListQueries);
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)];
}
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));
var config = await EnsureToolAllowedAsync(McpToolNames.RunQuery);
if (IsKind(input.Kind, McpConsts.SavedQueryKinds.CustomEndpoint))
{
await AuthorizationService.CheckAsync(CustomEndpointPermission);
return await RunCustomEndpointAsync(input, config);
}
await AuthorizationService.CheckAsync(SqlPermission);
var maxRows = ResolveMaxRows(config, input.MaxRows);
var parameters = sqlGuard.NormalizeParameters(input.Parameters);
var context = await queryExecutor.ResolveDataSourceAsync();
var schemaName = string.IsNullOrWhiteSpace(input.SchemaName)
? sqlGuard.DefaultSchemaName(context.DataSourceType)
: input.SchemaName;
var qualifiedName = sqlGuard.QuoteQualifiedName(schemaName, input.Name, context.DataSourceType);
var sql = BuildSavedQuerySql(input.Kind, qualifiedName, parameters, maxRows, context.DataSourceType);
return ToResult(await queryExecutor.ExecuteAsync(context, sql, parameters, maxRows));
}
private async Task RunCustomEndpointAsync(
McpSavedQueryRequestDto input,
McpServerConfigDto config)
{
var endpoints = await GetCallableEndpointEntitiesAsync();
var endpoint = endpoints.Find(candidate => string.Equals(candidate.Name, input.Name, StringComparison.OrdinalIgnoreCase))
?? throw new UserFriendlyException(L[McpErrorCodes.CustomEndpointNotFound, input.Name]);
var maxRows = ResolveMaxRows(config, input.MaxRows);
var parameters = sqlGuard.NormalizeParameters(input.Parameters);
// 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)
{
throw new UserFriendlyException(L[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 string 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();
}
throw new UserFriendlyException(L[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;
}