413 lines
18 KiB
C#
413 lines
18 KiB
C#
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;
|
|
|
|
/// <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)]
|
|
public partial class McpQueryAppService(
|
|
IMcpServerConfigProvider serverConfigProvider,
|
|
IMcpSqlGuard sqlGuard,
|
|
IMcpQueryExecutor queryExecutor,
|
|
IRepository<CustomEndpoint, Guid> 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<McpListQueryResultDto> 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
|
|
};
|
|
}
|
|
|
|
/// <summary>Bozuk girdi SQL hatasina donusmeden, hangi parametrede oldugu soylenerek reddedilir.</summary>
|
|
private string? ToLoadOption(string parameter, string? value, Func<string?, string?> 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<McpQueryResultDto> 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 pending = IsUnknownObjectError(ex) ? DescribePendingTables(sql) : null;
|
|
if (pending is not null)
|
|
throw new UserFriendlyException(L[McpErrorCodes.SelectTableNotInstalled, ex.Message, pending]);
|
|
|
|
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;
|
|
|
|
/// <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);
|
|
|
|
/// <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);
|
|
}
|
|
|
|
/// <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();
|
|
|
|
public async Task<List<McpSavedQueryDto>> GetSavedQueriesAsync(string? search = null)
|
|
{
|
|
await EnsureToolAllowedAsync(McpToolNames.ListQueries);
|
|
var results = new List<McpSavedQueryDto>();
|
|
|
|
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<List<McpSavedQueryDto>> 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<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;
|
|
}
|
|
|
|
public async Task<McpQueryResultDto> 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<McpQueryResultDto> 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<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))))];
|
|
}
|
|
|
|
private string BuildSavedQuerySql(
|
|
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();
|
|
}
|
|
|
|
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<string, object?> row, string key)
|
|
=> row.TryGetValue(key, out var value) ? value?.ToString() : null;
|
|
}
|