292 lines
12 KiB
C#
292 lines
12 KiB
C#
|
|
using System;
|
|||
|
|
using System.Collections.Generic;
|
|||
|
|
using System.Linq;
|
|||
|
|
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;
|
|||
|
|
|
|||
|
|
/// <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 class McpQueryAppService(
|
|||
|
|
IMcpServerConfigProvider serverConfigProvider,
|
|||
|
|
IMcpSqlGuard sqlGuard,
|
|||
|
|
IMcpQueryExecutor queryExecutor,
|
|||
|
|
IRepository<CustomEndpoint, Guid> customEndpointRepository,
|
|||
|
|
IListFormSelectAppService listFormSelectAppService)
|
|||
|
|
: 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 result = await listFormSelectAppService.GetSelectAsync(new SelectRequestDto
|
|||
|
|
{
|
|||
|
|
ListFormCode = input.ListFormCode,
|
|||
|
|
Filter = NullIfEmpty(input.Filter),
|
|||
|
|
Sort = NullIfEmpty(input.Sort),
|
|||
|
|
Group = NullIfEmpty(input.Group),
|
|||
|
|
GroupSummary = NullIfEmpty(input.GroupSummary),
|
|||
|
|
TotalSummary = NullIfEmpty(input.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
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
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(config.DataSourceCode);
|
|||
|
|
var limitedSql = sqlGuard.ApplyRowLimit(sql, maxRows, context.DataSourceType);
|
|||
|
|
|
|||
|
|
return ToResult(await queryExecutor.ExecuteAsync(context, limitedSql, parameters, maxRows));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public async Task<List<McpSavedQueryDto>> GetSavedQueriesAsync(string? search = null)
|
|||
|
|
{
|
|||
|
|
var config = await EnsureToolAllowedAsync(McpToolNames.ListQueries);
|
|||
|
|
var results = new List<McpSavedQueryDto>();
|
|||
|
|
|
|||
|
|
if (await AuthorizationService.IsGrantedAsync(SqlPermission))
|
|||
|
|
{
|
|||
|
|
results.AddRange(await GetDatabaseObjectsAsync(config));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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(McpServerConfigDto config)
|
|||
|
|
{
|
|||
|
|
var context = await queryExecutor.ResolveDataSourceAsync(config.DataSourceCode);
|
|||
|
|
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(config.DataSourceCode);
|
|||
|
|
|
|||
|
|
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;
|
|||
|
|
}
|