diff --git a/README.md b/README.md index 419dc17e..441ca787 100644 --- a/README.md +++ b/README.md @@ -1676,19 +1676,23 @@ servisinin sınıf düzeyindeki `[Authorize]` attribute'undadır; `/mcp` ucu yal ister (ucun kendisinde aranırsa kullanıcı nedeni yazmayan bir 403 alır). Araç listesi aynı kurallarla süzülür (`GetAvailableToolsAsync`), çağrı anında kontrol tekrarlanır. -**Yetkisi olmayan çağrı ne görür.** SDK çevrilmemiş istisnayı "An error occurred invoking '…'" -olarak iletir. `McpToolErrorTranslator` (çağrı ve araç listesi filtresi, `McpHttpApiModule`) iki tür -hatayı mesaja çevirir; çağrı filtresi mesajı istisna atmadan hata sonucu (`CallToolResult.IsError`) -olarak döner — beklenen ret sunucu logunda yığın izli `ERR` üretmez, model nedeni okuyup devam eder: +**Reddedilen çağrı ne görür.** Ret modelle konuşmadır, istisna değildir: MCP'nin kendi kontrolleri +(kiracıya kapalı araç, `AllowedTools` dışı araç, eksik yetki, hatalı girdi, `start_dev_request` durum +reddi…) `McpProblem` döndürür (`McpOutcome`); araç katmanı onu `McpToolResult.From` ile hata +sonucuna (`CallToolResult.IsError`) çevirir. Debugger durmaz, sunucu logunda yığın izli `ERR` oluşmaz, +model nedeni okuyup girdiyi düzeltir: - **Yetki reddi** → `App.Mcp.Error.PermissionDenied`: "'deploy_wizard' için gereken yetkiniz yok: - Wizard (App.Listforms.Wizard). Yöneticinizden bu yetkiyi isteyin." Eksik yetkiler aracın bilinen - yetkileri (`McpToolNames.KnownPermissionsOf`) ve reddin taşıdığı yetki adı kullanıcının - yetkileriyle karşılaştırılarak bulunur. Satır ve `query_list` araçları ekranın yetkisini adıyla - önceden ister (`IMcpListFormAccessFilter.GetPermissionNameAsync`), bu yüzden ekran yetkisi de - adıyla görünür. Eksik yetki belirlenemezse `PermissionDeniedUnspecified` döner. -- **İş kuralı hatası** (`UserFriendlyException`: kiracıya kapalı araç, `AllowedTools` dışı araç, - `start_dev_request` durum reddi…) → yerelleştirilmiş mesajı olduğu gibi. + Wizard (App.Listforms.Wizard). Yöneticinizden bu yetkiyi isteyin." MCP yetkiyi + `McpAppService.CheckPermissionAsync` ile adıyla ister; satır ve `query_list` araçları ekranın + yetkisini de adıyla önceden ister (`IMcpListFormAccessFilter.GetPermissionNameAsync`). +- **İş kuralı / girdi reddi** → ilgili `App.Mcp.Error.*` metni. + +Arkadaki platform servisinin (wizard, custom component, ListForm verisi) attığı istisna ve sınıf +düzeyindeki `[Authorize]` reddi için `McpToolErrorTranslator` (çağrı ve araç listesi filtresi, +`McpHttpApiModule`) devrededir: yetki reddinde eksik yetkiler aracın bilinen yetkileri +(`McpToolNames.KnownPermissionsOf`) ve reddin taşıdığı yetki adı karşılaştırılarak bulunur (belirlenemezse +`PermissionDeniedUnspecified`), `UserFriendlyException` yerelleştirilmiş mesajıyla döner. Başka istisnalar çevrilmez; SQL ve yığın ayrıntısı istemciye sızmaz. Tek istisna `run_select`'tir: sağlayıcı hatası (`DbException`, örn. tabloda olmayan kolon) `App.Mcp.Error.SelectFailed` ile diff --git a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application.Contracts/IMcpDataAppService.cs b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application.Contracts/IMcpDataAppService.cs index 541dcbb6..9dbc8afb 100644 --- a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application.Contracts/IMcpDataAppService.cs +++ b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application.Contracts/IMcpDataAppService.cs @@ -11,9 +11,9 @@ namespace Sozsoft.Mcp; /// public interface IMcpDataAppService : IApplicationService { - Task InsertRowAsync(McpRowRequestDto input); + Task> InsertRowAsync(McpRowRequestDto input); - Task UpdateRowAsync(McpRowRequestDto input); + Task> UpdateRowAsync(McpRowRequestDto input); - Task DeleteRowAsync(McpRowRequestDto input); + Task> DeleteRowAsync(McpRowRequestDto input); } diff --git a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application.Contracts/IMcpDevelopAppService.cs b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application.Contracts/IMcpDevelopAppService.cs index 30f84c16..2a148ec4 100644 --- a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application.Contracts/IMcpDevelopAppService.cs +++ b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application.Contracts/IMcpDevelopAppService.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Threading.Tasks; using Sozsoft.Mcp.Dtos; using Sozsoft.Platform.DeveloperKit; @@ -14,68 +14,68 @@ namespace Sozsoft.Mcp; /// public interface IMcpDevelopAppService : IApplicationService { - Task GetDevGuideAsync(string? document, string? section); + Task> GetDevGuideAsync(string? document, string? section); - Task CreateTableAsync(McpCreateTableRequestDto input); + Task> CreateTableAsync(McpCreateTableRequestDto input); /// View/procedure/function scriptini iki saglayicinin seed klasorune yazar; veritabaninda calistirmaz. - Task SaveSqlObjectAsync(string sqlServerScript, string postgreSqlScript); + Task> SaveSqlObjectAsync(string sqlServerScript, string postgreSqlScript); /// Tablo ya da view icin CRUD endpoint'lerini crud/{Entity}.json seed dosyasina yazar; kaydi DB Migrate ekler. - Task GenerateCrudEndpointsAsync(string entityName, string? operationTypes, string? objectType); + Task> GenerateCrudEndpointsAsync(string entityName, string? operationTypes, string? objectType); - Task> GetWizardsAsync(string? search); + Task>> GetWizardsAsync(string? search); - Task GetWizardAsync(string fileName); + Task> GetWizardAsync(string fileName); /// Deploy edilen wizard'in ozeti. - Task DeployWizardAsync(McpWizardDeployRequestDto input); + Task> DeployWizardAsync(McpWizardDeployRequestDto input); /// Wizard'i siler; acikken ekranin secilebilir tablosunu da dusurur. - Task DeleteWizardAsync(string fileName, bool dropTables); + Task> DeleteWizardAsync(string fileName, bool dropTables); /// Deploy edilmis ekrani veri yazmadan dogrular. - Task VerifyWizardAsync(string fileName); + Task> VerifyWizardAsync(string fileName); /// Id ya da basliga gore bulunan talebi, yalnizca Ready to Start durumundaysa In Progress'e alir. - Task StartDevRequestAsync(string request); + Task> StartDevRequestAsync(string request); /// Talebi durumuna dokunmadan okur; yarim kalan bir talebe donmek icindir. - Task GetDevRequestAsync(string request); + Task> GetDevRequestAsync(string request); /// /// Talebin analizini yazar (var olani degistirir); analiz kosulsuz uretime hazirsa talebi /// Ready to Start'a, degilse Analysis durumuna alir. Yalnizca Backlog ya da Analysis durumundaki /// talep analiz edilir. /// - Task SaveDevRequestAnalysisAsync(string request, string html, bool productionReady); + Task> SaveDevRequestAnalysisAsync(string request, string html, bool productionReady); /// /// Analizin sonuna HTML ekler. Aciklama talebin yazildigi haliyle kalir; uretim notlari /// analize yazilir. /// - Task AppendDevRequestNoteAsync(string request, string html); + Task AppendDevRequestNoteAsync(string request, string html); /// /// Talebin SubTasks listesine tamamlanmis satirlar ekler; atanan kisi cagiran kullanici, /// bitis tarihi bugundur. /// - Task AddDevRequestSubTasksAsync(string request, List titles); + Task AddDevRequestSubTasksAsync(string request, List titles); /// Modul dokumanini adiyla ya da ait oldugu ekranin kodu/wizard dosyasiyla okur. - Task GetModuleDocAsync(string name); + Task> GetModuleDocAsync(string name); /// /// Modul dokumaninin elle yazilan bolumlerini kaydeder; otomatik envanter sunucuda yeniden /// uretilir. /// - Task SaveModuleDocAsync(string name, string content); + Task> SaveModuleDocAsync(string name, string content); - Task> GetComponentsAsync(string? search); + Task>> GetComponentsAsync(string? search); - Task GetComponentAsync(string name); + Task> GetComponentAsync(string name); - Task SaveComponentAsync(McpComponentSaveRequestDto input); + Task> SaveComponentAsync(McpComponentSaveRequestDto input); - Task DeleteComponentAsync(string name); + Task DeleteComponentAsync(string name); } diff --git a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application.Contracts/IMcpQueryAppService.cs b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application.Contracts/IMcpQueryAppService.cs index b5a5a8e9..d31e9202 100644 --- a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application.Contracts/IMcpQueryAppService.cs +++ b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application.Contracts/IMcpQueryAppService.cs @@ -16,17 +16,17 @@ public interface IMcpQueryAppService : IApplicationService /// okuma yetkisi, kiraci/sube filtreleri ve kullanici ozellestirmeleri uygulamadaki gibi /// gecerlidir. Normal kullanicinin veri okuma yolu budur. /// - Task QueryListAsync(McpListQueryRequestDto input); + Task> QueryListAsync(McpListQueryRequestDto input); /// /// Tek bir SELECT ifadesi kosturur. Coklu ifade, DDL ve DML reddedilir; satir sayisi /// sunucu kaydindaki tavana kirpilir. /// - Task RunSelectAsync(McpSelectRequestDto input); + Task> RunSelectAsync(McpSelectRequestDto input); /// Veri kaynagindaki view/procedure/function nesneleri ve Custom Endpoint'ler. - Task> GetSavedQueriesAsync(string? search = null); + Task>> GetSavedQueriesAsync(string? search = null); /// Kayitli bir nesneyi ya da Custom Endpoint'i parametreleriyle cagirir. - Task RunSavedQueryAsync(McpSavedQueryRequestDto input); + Task> RunSavedQueryAsync(McpSavedQueryRequestDto input); } diff --git a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application.Contracts/IMcpReportAppService.cs b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application.Contracts/IMcpReportAppService.cs index 735b03ba..a2ac1bbb 100644 --- a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application.Contracts/IMcpReportAppService.cs +++ b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application.Contracts/IMcpReportAppService.cs @@ -1,4 +1,4 @@ -using System.Threading.Tasks; +using System.Threading.Tasks; using Sozsoft.Mcp.Dtos; using Volo.Abp.Application.Services; @@ -11,16 +11,16 @@ namespace Sozsoft.Mcp; /// public interface IMcpReportAppService : IApplicationService { - Task GetReportTemplatesAsync(string? search); + Task> GetReportTemplatesAsync(string? search); /// Sablonu Id'si ya da adiyla, HTML icerigi ve yer tutuculariyla doner. - Task GetReportTemplateAsync(string template); + Task> GetReportTemplateAsync(string template); /// Kategoriyi ada gore kaydeder: yoksa ekler, varsa gunceller. - Task SaveReportCategoryAsync(McpReportCategorySaveRequestDto input); + Task> SaveReportCategoryAsync(McpReportCategorySaveRequestDto input); /// Sablonu ada gore kaydeder: yoksa ekler, varsa gunceller. - Task SaveReportTemplateAsync(McpReportTemplateSaveRequestDto input); + Task> SaveReportTemplateAsync(McpReportTemplateSaveRequestDto input); - Task DeleteReportTemplateAsync(string template); + Task DeleteReportTemplateAsync(string template); } diff --git a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application.Contracts/IMcpSchemaAppService.cs b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application.Contracts/IMcpSchemaAppService.cs index 67613054..e7abcaa8 100644 --- a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application.Contracts/IMcpSchemaAppService.cs +++ b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application.Contracts/IMcpSchemaAppService.cs @@ -26,13 +26,13 @@ public interface IMcpSchemaAppService : IApplicationService /// Bos birakilirsa tum listeler doner. /// Donecek en fazla liste sayisi. - Task> GetListsAsync(string? search = null, int maxResults = 200); + Task>> GetListsAsync(string? search = null, int maxResults = 200); /// Tek bir listenin sutunlariyla birlikte tanimi; bulunamazsa null. - Task GetListAsync(string listFormCode); + Task> GetListAsync(string listFormCode); /// /// Liste ve sutun adlari ile basliklari uzerinde aksan ve buyuk/kucuk harf duyarsiz arama. /// - Task> SearchAsync(string term, int maxResults = 50); + Task>> SearchAsync(string term, int maxResults = 50); } diff --git a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpAppService.cs b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpAppService.cs index a62b55d5..e03b3f85 100644 --- a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpAppService.cs +++ b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpAppService.cs @@ -5,7 +5,6 @@ using Microsoft.AspNetCore.Authorization; using Sozsoft.Mcp.Dtos; using Sozsoft.Mcp.Services; using Sozsoft.Platform.Localization; -using Volo.Abp; using Volo.Abp.Application.Services; namespace Sozsoft.Mcp; @@ -46,40 +45,49 @@ public abstract class McpAppService : ApplicationService } /// - /// Kaydi zorunlu kilan islemler icin. Kayit yoksa istemcinin nedenini gorebilecegi bir - /// hata atar. - /// - protected async Task RequireServerConfigAsync() - => await ResolveServerConfigAsync() - ?? throw new UserFriendlyException(L[McpErrorCodes.NoServerRecord]); - - /// - /// Kapali bir araca yapilan cagriyi reddeder: arac sunucu kaydinda kapaliysa ya da - /// kullanicinin uygulamada karsilik gelen yetkisi yoksa. Arac listesini suzmek tek basina + /// Kapali bir araca yapilan cagriyi reddeder: sunucu kaydi yoksa, arac sunucu kaydinda kapaliysa + /// ya da kullanicinin uygulamada karsilik gelen yetkisi yoksa. Arac listesini suzmek tek basina /// yeterli degildir — istemci listede olmayan bir araci da cagirabilir. /// - protected async Task EnsureToolAllowedAsync(string toolName) + protected async Task> EnsureToolAllowedAsync(string toolName) { - var config = await RequireServerConfigAsync(); + var config = await ResolveServerConfigAsync(); + if (config is null) + { + return Problem(McpErrorCodes.NoServerRecord); + } if (CurrentTenant.IsAvailable && !McpToolNames.TenantTools.Contains(toolName, StringComparer.Ordinal)) { - throw new UserFriendlyException(L[McpErrorCodes.TenantToolDisabled, toolName]); + return Problem(McpErrorCodes.TenantToolDisabled, toolName); } if (!McpToolPolicy.IsAllowed(true, CurrentTenant.IsAvailable, config.AllowedTools, toolName)) { - throw new UserFriendlyException(L[McpErrorCodes.ToolDisabled, toolName, config.Name]); + return Problem(McpErrorCodes.ToolDisabled, toolName, config.Name); } - if (McpToolNames.RequiredPermissions.TryGetValue(toolName, out var permission)) + if (McpToolNames.RequiredPermissions.TryGetValue(toolName, out var permission) && + await CheckPermissionAsync(toolName, permission) is { } denied) { - await AuthorizationService.CheckAsync(permission); + return denied; } return config; } + /// + /// Yetkiyi adiyla ister; eksikse istemcinin rol ekraninda arayabilecegi ad ve kodla reddeder. + /// AuthorizationService.CheckAsync istisna atar, bu yuzden MCP'de kullanilmaz. + /// + protected async Task CheckPermissionAsync(string toolName, string permission) + => await AuthorizationService.IsGrantedAsync(permission) + ? null + : Problem(McpErrorCodes.PermissionDenied, toolName, McpPermissionText.Describe(L, permission)); + + protected McpProblem Problem(string name, params object[] arguments) + => McpProblem.From(L, name, arguments); + /// Istenen satir sayisini sunucu kaydindaki tavana kirpar. protected static int ResolveMaxRows(McpServerConfigDto config, int requested) { diff --git a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpDataAppService.cs b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpDataAppService.cs index 9a9c6e4b..23f9983e 100644 --- a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpDataAppService.cs +++ b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpDataAppService.cs @@ -31,28 +31,44 @@ public class McpDataAppService( private const string DevRequestStatusField = "Status"; private const string DevRequestTestingStatus = "Testing"; - public async Task InsertRowAsync(McpRowRequestDto input) + public async Task> InsertRowAsync(McpRowRequestDto input) { - var request = await BuildRequestAsync(input, McpToolNames.InsertRow, AuthorizationTypeEnum.Create, requireKeys: false, requireValues: true); + if (!(await BuildRequestAsync(input, McpToolNames.InsertRow, AuthorizationTypeEnum.Create, requireKeys: false, requireValues: true)).TryGetValue(out var request, out var problem)) + { + return problem; + } + var key = await listFormDataAppService.PostInsertAsync(request); await timesheetRecorder.RecordAsync(McpTimesheetTexts.InsertRow, input.ListFormCode); return new McpWriteResultDto { Key = ((object?)key)?.ToString() }; } - public async Task UpdateRowAsync(McpRowRequestDto input) + public async Task> UpdateRowAsync(McpRowRequestDto input) { - var request = await BuildRequestAsync(input, McpToolNames.UpdateRow, AuthorizationTypeEnum.Update, requireKeys: true, requireValues: true); - await EnsureModuleDocsBeforeTestingAsync(input); + if (!(await BuildRequestAsync(input, McpToolNames.UpdateRow, AuthorizationTypeEnum.Update, requireKeys: true, requireValues: true)).TryGetValue(out var request, out var problem)) + { + return problem; + } + + if (await CheckModuleDocsBeforeTestingAsync(input) is { } incomplete) + { + return incomplete; + } + var affected = await listFormDataAppService.PostUpdateAsync(request); await timesheetRecorder.RecordAsync(McpTimesheetTexts.UpdateRow, input.ListFormCode, string.Join(", ", input.Values.Keys)); return new McpWriteResultDto { AffectedRows = affected }; } - public async Task DeleteRowAsync(McpRowRequestDto input) + public async Task> DeleteRowAsync(McpRowRequestDto input) { - var request = await BuildRequestAsync(input, McpToolNames.DeleteRow, AuthorizationTypeEnum.Delete, requireKeys: true, requireValues: false); + if (!(await BuildRequestAsync(input, McpToolNames.DeleteRow, AuthorizationTypeEnum.Delete, requireKeys: true, requireValues: false)).TryGetValue(out var request, out var problem)) + { + return problem; + } + var affected = await listFormDataAppService.PostDeleteAsync(request); await timesheetRecorder.RecordAsync(McpTimesheetTexts.DeleteRow, input.ListFormCode); @@ -65,17 +81,17 @@ public class McpDataAppService( /// Talebi Testing'e alan tek yol update_row oldugu icin kural burada uygulanir. Wizard /// okuma yetkisi olmayan kullanici wizard uretemeyecegi icin kontrol ona uygulanmaz. /// - private async Task EnsureModuleDocsBeforeTestingAsync(McpRowRequestDto input) + private async Task CheckModuleDocsBeforeTestingAsync(McpRowRequestDto input) { if (!string.Equals(input.ListFormCode, PlatformConsts.AppCodes.DevRequests, StringComparison.OrdinalIgnoreCase)) - return; + return null; var status = input.Values?.FirstOrDefault(v => string.Equals(v.Key, DevRequestStatusField, StringComparison.OrdinalIgnoreCase)).Value?.ToString(); if (!string.Equals(status, DevRequestTestingStatus, StringComparison.OrdinalIgnoreCase)) - return; + return null; if (!await AuthorizationService.IsGrantedAsync(PlatformConsts.AppCodes.Listforms.Wizard)) - return; + return null; foreach (var key in input.Keys ?? []) { @@ -88,15 +104,17 @@ public class McpDataAppService( var detail = string.Join("; ", incomplete.Select(doc => $"{doc.Path} ({string.Join(", ", doc.MissingSections)})")); - throw new UserFriendlyException(L[McpErrorCodes.DevRequestModuleDocsIncomplete, detail]); + return Problem(McpErrorCodes.DevRequestModuleDocsIncomplete, detail); } + + return null; } /// /// Istegi ekranin bekledigi bicime cevirir. Data JSON metni olarak verilir; ayni /// bicim ekranin kopyalama yolunda da kullanilir. /// - private async Task BuildRequestAsync( + private async Task> BuildRequestAsync( McpRowRequestDto input, string toolName, AuthorizationTypeEnum operation, @@ -105,29 +123,32 @@ public class McpDataAppService( { Check.NotNull(input, nameof(input)); - await EnsureToolAllowedAsync(toolName); + if (await EnsureToolAllowedAsync(toolName) is { Problem: { } problem }) + { + return problem; + } if (string.IsNullOrWhiteSpace(input.ListFormCode)) { - throw new UserFriendlyException(L[McpErrorCodes.ListFormCodeRequired]); + return Problem(McpErrorCodes.ListFormCodeRequired); } // Ekranin kendi kontrolu reddi adsiz bir hatayla bildirir; yetki burada adiyla istenir ki // istemci hangi yetkinin eksik oldugunu gorsun. var permission = await accessFilter.GetPermissionNameAsync(input.ListFormCode, operation); - if (permission is not null) + if (permission is not null && await CheckPermissionAsync(toolName, permission) is { } denied) { - await AuthorizationService.CheckAsync(permission); + return denied; } if (requireValues && (input.Values is null || input.Values.Count == 0)) { - throw new UserFriendlyException(L[McpErrorCodes.ValuesRequired]); + return Problem(McpErrorCodes.ValuesRequired); } if (requireKeys && (input.Keys is null || input.Keys.Count == 0)) { - throw new UserFriendlyException(L[McpErrorCodes.KeysRequired]); + return Problem(McpErrorCodes.KeysRequired); } return new DataRequestDto diff --git a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpDevelopAppService.cs b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpDevelopAppService.cs index 3755bc95..48d0f616 100644 --- a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpDevelopAppService.cs +++ b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpDevelopAppService.cs @@ -79,16 +79,19 @@ public class McpDevelopAppService( public const string SeedClosure = "seed_closure"; } - public async Task GetDevGuideAsync(string? document, string? section) + public async Task> GetDevGuideAsync(string? document, string? section) { - await EnsureToolAllowedAsync(McpToolNames.GetDevGuide); + if (await EnsureToolAllowedAsync(McpToolNames.GetDevGuide) is { Problem: { } problem }) + { + return problem; + } if (string.IsNullOrWhiteSpace(section)) { var headings = await devGuide.GetHeadingsAsync(); if (headings.Count == 0) { - throw new UserFriendlyException(L[McpErrorCodes.DevGuideUnavailable, McpDevGuide.ConfigurationKey]); + return Problem(McpErrorCodes.DevGuideUnavailable, McpDevGuide.ConfigurationKey); } return new McpDevGuideDto @@ -154,13 +157,20 @@ public class McpDevelopAppService( /// Tablo tasarimcisiyla ayni yetkiyi ister: ham DDL liste yetkilerini asar, Develop yalnizca /// MCP kapisidir. /// - public async Task CreateTableAsync(McpCreateTableRequestDto input) + public async Task> CreateTableAsync(McpCreateTableRequestDto input) { Check.NotNull(input, nameof(input)); - await EnsureToolAllowedAsync(McpToolNames.CreateTable); - await AuthorizationService.CheckAsync(PlatformConsts.AppCodes.SqlQueryManagers.Default); + if (await EnsureToolAllowedAsync(McpToolNames.CreateTable) is { Problem: { } problem }) + { + return problem; + } - var created = await tableCreator.CreateAsync(new McpTableDefinition( + if (await CheckPermissionAsync(McpToolNames.CreateTable, PlatformConsts.AppCodes.SqlQueryManagers.Default) is { } denied) + { + return denied; + } + + var outcome = await tableCreator.CreateAsync(new McpTableDefinition( input.TableName, [.. (input.Columns ?? []).Select(column => new McpTableColumn( column.Name, column.Type, column.MaxLength, column.IsNullable, column.DefaultValue))], @@ -168,6 +178,10 @@ public class McpDevelopAppService( input.MultiTenant, [.. (input.Indexes ?? []).Select(index => new McpTableIndex(index.Columns ?? [], index.Unique))], [.. (input.ForeignKeys ?? []).Select(fk => new McpTableForeignKey(fk.Column, fk.ReferencedTable, fk.ReferencedColumn, fk.OnDelete))])); + if (!outcome.TryGetValue(out var created, out var invalid)) + { + return invalid; + } await timesheetRecorder.RecordAsync(McpTimesheetTexts.CreateTable, created.TableName); @@ -183,12 +197,23 @@ public class McpDevelopAppService( /// Tablo tasarimcisiyla ayni yetkiyi ister: SQL Query Manager'daki view/procedure/function /// kaydinin karsiligidir. Nesneyi DB Migrate kurar. /// - public async Task SaveSqlObjectAsync(string sqlServerScript, string postgreSqlScript) + public async Task> SaveSqlObjectAsync(string sqlServerScript, string postgreSqlScript) { - await EnsureToolAllowedAsync(McpToolNames.SaveSqlObject); - await AuthorizationService.CheckAsync(PlatformConsts.AppCodes.SqlQueryManagers.Default); + if (await EnsureToolAllowedAsync(McpToolNames.SaveSqlObject) is { Problem: { } problem }) + { + return problem; + } + + if (await CheckPermissionAsync(McpToolNames.SaveSqlObject, PlatformConsts.AppCodes.SqlQueryManagers.Default) is { } denied) + { + return denied; + } + + if (!sqlObjectWriter.Save(sqlServerScript, postgreSqlScript).TryGetValue(out var saved, out var invalid)) + { + return invalid; + } - var saved = sqlObjectWriter.Save(sqlServerScript, postgreSqlScript); await timesheetRecorder.RecordAsync(McpTimesheetTexts.SaveSqlObject, saved.Name); return new McpSqlObjectResultDto @@ -204,16 +229,23 @@ public class McpDevelopAppService( /// () yalnizca crud/{Entity}.json seed dosyasina /// yazar; kayitlari DB Migrate ekler (CrudEndpointSeedApplier). Yetki diyalogunkiyle aynidir. /// - public async Task GenerateCrudEndpointsAsync(string entityName, string? operationTypes, string? objectType) + public async Task> GenerateCrudEndpointsAsync(string entityName, string? operationTypes, string? objectType) { - await EnsureToolAllowedAsync(McpToolNames.GenerateCrudEndpoints); - await AuthorizationService.CheckAsync(PlatformConsts.AppCodes.SqlQueryManagers.CrudEndpoints); + if (await EnsureToolAllowedAsync(McpToolNames.GenerateCrudEndpoints) is { Problem: { } problem }) + { + return problem; + } + + if (await CheckPermissionAsync(McpToolNames.GenerateCrudEndpoints, PlatformConsts.AppCodes.SqlQueryManagers.CrudEndpoints) is { } denied) + { + return denied; + } // Tasarimcidaki gibi sema oneki atilir: endpoint yolu yalnizca nesne adini tasir. var entity = entityName?.Split('.')[^1].Trim('[', ']', '"', ' ') ?? string.Empty; if (CrudEndpointSeedFileDto.SafeFileName(entity) is null) { - throw new UserFriendlyException(L[McpErrorCodes.InvalidIdentifier, entityName ?? string.Empty]); + return Problem(McpErrorCodes.InvalidIdentifier, entityName ?? string.Empty); } var seedFile = CrudEndpointSeedFileDto.Generate(entity, operationTypes, objectType, Clock.Now.ToUniversalTime()); @@ -228,9 +260,12 @@ public class McpDevelopAppService( }; } - public async Task> GetWizardsAsync(string? search) + public async Task>> GetWizardsAsync(string? search) { - await EnsureToolAllowedAsync(McpToolNames.ListWizards); + if (await EnsureToolAllowedAsync(McpToolNames.ListWizards) is { Problem: { } problem }) + { + return problem; + } var files = await wizardAppService.GetFiles(); if (string.IsNullOrWhiteSpace(search)) @@ -238,14 +273,17 @@ public class McpDevelopAppService( return files; } - return [.. files.Where(file => + return files.Where(file => Contains(file.FileName, search) || Contains(file.WizardName, search) || - Contains(file.ListFormCode, search) || Contains(file.CustomComponentName, search))]; + Contains(file.ListFormCode, search) || Contains(file.CustomComponentName, search)).ToList(); } - public async Task GetWizardAsync(string fileName) + public async Task> GetWizardAsync(string fileName) { - await EnsureToolAllowedAsync(McpToolNames.GetWizard); + if (await EnsureToolAllowedAsync(McpToolNames.GetWizard) is { Problem: { } problem }) + { + return problem; + } var seed = await wizardAppService.GetFile(fileName); @@ -256,14 +294,24 @@ public class McpDevelopAppService( return new McpWizardFileDto { FileName = fileName, Seed = document.RootElement.Clone() }; } - public async Task DeployWizardAsync(McpWizardDeployRequestDto input) + public async Task> DeployWizardAsync(McpWizardDeployRequestDto input) { Check.NotNull(input, nameof(input)); - await EnsureToolAllowedAsync(McpToolNames.DeployWizard); + if (await EnsureToolAllowedAsync(McpToolNames.DeployWizard) is { Problem: { } problem }) + { + return problem; + } + + if (!ParseWizard(input.Wizard).TryGetValue(out var wizard, out var invalid)) + { + return invalid; + } - var wizard = ParseWizard(input.Wizard); wizard.EditFileName = string.IsNullOrWhiteSpace(input.ReplaceFileName) ? null : input.ReplaceFileName.Trim(); - EnsureDetailedDescription(wizard); + if (CheckDetailedDescription(wizard) is { } shortDescription) + { + return shortDescription; + } // Menu sirasi modelin karari degildir: yeni ekran kardeslerinin sonuna eklenir (deploy // bagimlilik sirasiyla yapildigi icin lookup kaynagi tuketen ekranin ustunde kalir), @@ -290,7 +338,10 @@ public class McpDevelopAppService( editGroups[0].Caption = string.Empty; // Satirlar deploy'dan once dogrulanir; gecersiz girdi yarim bir deploy birakmaz. - var seedRows = await ParseSeedRowsAsync(input.SeedRows, wizard); + if (!(await ParseSeedRowsAsync(input.SeedRows, wizard)).TryGetValue(out var seedRows, out invalid)) + { + return invalid; + } // Wizard dosyasi onu ureten talebi tasir; Wizard Manager talebi gosterir ve talebin // butun wizard'larini birlikte export eder. Bag modelin girdisinden degil etkin talepten gelir. @@ -301,8 +352,8 @@ public class McpDevelopAppService( var files = await wizardAppService.GetFiles(); var file = files.FirstOrDefault(file => string.Equals(file.WizardName, wizard.WizardName?.Trim(), StringComparison.OrdinalIgnoreCase)); - if (file != null && seedRows.Count > 0) - await WriteSeedRowsAsync(file.FileName, seedRows); + if (file != null && seedRows.Count > 0 && await WriteSeedRowsAsync(file.FileName, seedRows) is { } rowsRejected) + return rowsRejected; await timesheetRecorder.RecordAsync(McpTimesheetTexts.DeployWizard, file?.FileName ?? wizard.WizardName, seedRows.Count); @@ -315,31 +366,32 @@ public class McpDevelopAppService( /// hesaplar ve iliskili ekranlar (lowcode §4.4.2). Tek satirlik aciklama bu ihtiyaci karsilamadigi /// icin MCP deploy'u kisa aciklamayi reddeder. /// - private void EnsureDetailedDescription(ListFormWizardDto wizard) + private McpProblem? CheckDetailedDescription(ListFormWizardDto wizard) { if (wizard.ComponentKind == WizardComponentKindEnum.Custom) - return; + return null; static bool IsDetailed(string? text) => !string.IsNullOrWhiteSpace(text) && text.Trim().Length >= MinDescriptionLength && text.Split('\n').Count(line => !string.IsNullOrWhiteSpace(line)) >= MinDescriptionLines; - if (!IsDetailed(wizard.DescTr) || !IsDetailed(wizard.DescEn)) - throw new UserFriendlyException(L[McpErrorCodes.WizardDescriptionTooShort, MinDescriptionLines, MinDescriptionLength]); + return IsDetailed(wizard.DescTr) && IsDetailed(wizard.DescEn) + ? null + : Problem(McpErrorCodes.WizardDescriptionTooShort, MinDescriptionLines, MinDescriptionLength); } /// /// Demo satirlarini okur ve ekranin alanlarina gore dogrular. Satirlar yalnizca SeedData acik /// liste ekraninda kabul edilir: migrate onlari ekranin veri dosyasindan ekler. /// - private async Task>> ParseSeedRowsAsync(string? json, ListFormWizardDto wizard) + private async Task>>> ParseSeedRowsAsync(string? json, ListFormWizardDto wizard) { if (string.IsNullOrWhiteSpace(json)) - return []; + return new List>(); if (wizard.ComponentKind == WizardComponentKindEnum.Custom || !wizard.SeedData) - throw new UserFriendlyException(L[McpErrorCodes.SeedRowsNotAllowed]); + return Problem(McpErrorCodes.SeedRowsNotAllowed); List>? rows; try @@ -352,7 +404,7 @@ public class McpDevelopAppService( } if (rows is null || rows.Any(row => row is null)) - throw new UserFriendlyException(L[McpErrorCodes.SeedRowsInvalid]); + return Problem(McpErrorCodes.SeedRowsInvalid); var fields = wizard.Groups.SelectMany(group => group.Items).Select(item => item.FieldName) .Append(wizard.KeyFieldName) @@ -367,13 +419,13 @@ public class McpDevelopAppService( var screenFields = string.Join(", ", fields); var owners = await FindSameTableScreensAsync(wizard, unknown); if (owners.Count == 0) - throw new UserFriendlyException(L[McpErrorCodes.SeedRowsUnknownFields, string.Join(", ", unknown), screenFields]); + return Problem(McpErrorCodes.SeedRowsUnknownFields, string.Join(", ", unknown), screenFields); // Ayni tablonun baska ekraninda (cogunlukla alt form sekmesi) alan olan kolon migrate'te // bu ekranin veri dosyasindan yazilmaz; model kolonun nerede durdugunu gorurse alani ekrana // ekleyip ya da satirdan cikarip tekrar dener. var placement = string.Join("; ", owners.Select(owner => $"{owner.Key}: {string.Join(", ", owner.Value)}")); - throw new UserFriendlyException(L[McpErrorCodes.SeedRowsFieldsOnOtherScreens, string.Join(", ", unknown), placement, screenFields]); + return Problem(McpErrorCodes.SeedRowsFieldsOnOtherScreens, string.Join(", ", unknown), placement, screenFields); } /// @@ -417,12 +469,12 @@ public class McpDevelopAppService( /// burada uretilir: dosya her uygulandiginda ayni kayda karsilik gelir. Hedefte ayni icerikli satir /// varsa (tekrar deploy) yeniden eklenmez. /// - private async Task WriteSeedRowsAsync(string fileName, List> rows) + private async Task WriteSeedRowsAsync(string fileName, List> rows) { var listForm = (await wizardAppService.GetFile(fileName)).ListForm; var target = ListFormSeedTarget.Create(SeedPathResolver.GetRootPath(configuration), listForm?.SeedFilePath); if (listForm == null || target == null) - throw new UserFriendlyException(L[McpErrorCodes.SeedRowsNotAllowed]); + return Problem(McpErrorCodes.SeedRowsNotAllowed); var keyFieldName = listForm.KeyFieldName; var existing = (await target.ReadRowsAsync()).Select(row => RowContent(row, keyFieldName)).ToHashSet(StringComparer.Ordinal); @@ -441,8 +493,9 @@ public class McpDevelopAppService( } // Satir klasorunde her satir anahtariyla adlanan dosyadadir; anahtarsiz satir yazilamaz. - if (added.Count > 0 && (await target.UpsertRowsAsync(listForm.ListFormCode, keyFieldName, added)).Count > 0) - throw new UserFriendlyException(L[McpErrorCodes.SeedRowsInvalid]); + return added.Count > 0 && (await target.UpsertRowsAsync(listForm.ListFormCode, keyFieldName, added)).Count > 0 + ? Problem(McpErrorCodes.SeedRowsInvalid) + : null; } /// Satirin anahtar disindaki icerigi; tekrar deploy'da ayni satiri tanimak icin. @@ -456,9 +509,12 @@ public class McpDevelopAppService( /// Ekranin tablosu ancak acikken ve Wizard Manager'daki onayla ayni /// kosullarda (secilebilir aday) dusurulur; onayi kullanicidan almak istemcinin isidir. /// - public async Task DeleteWizardAsync(string fileName, bool dropTables) + public async Task> DeleteWizardAsync(string fileName, bool dropTables) { - await EnsureToolAllowedAsync(McpToolNames.DeleteWizard); + if (await EnsureToolAllowedAsync(McpToolNames.DeleteWizard) is { Problem: { } problem }) + { + return problem; + } var input = new WizardDeleteInput { FileName = fileName }; if (dropTables) @@ -480,9 +536,12 @@ public class McpDevelopAppService( /// iz birakirdi. Seed kapanisi engellemez: ekran bu ortamda calisir, eksik dosya yalnizca /// migrate ya da baska bir ortamda ortaya cikar. /// - public async Task VerifyWizardAsync(string fileName) + public async Task> VerifyWizardAsync(string fileName) { - await EnsureToolAllowedAsync(McpToolNames.VerifyWizard); + if (await EnsureToolAllowedAsync(McpToolNames.VerifyWizard) is { Problem: { } problem }) + { + return problem; + } var result = new McpVerifyResultDto { FileName = fileName }; @@ -549,16 +608,26 @@ public class McpDevelopAppService( /// ayni unit of work'te In Progress'e alinir. MCP yetki vermez; ekranin guncelleme yetkisi /// ayrica istenir. /// - public async Task StartDevRequestAsync(string request) + public async Task> StartDevRequestAsync(string request) { - await EnsureToolAllowedAsync(McpToolNames.StartDevRequest); - await AuthorizationService.CheckAsync(DevRequestUpdatePermission); + if (await EnsureToolAllowedAsync(McpToolNames.StartDevRequest) is { Problem: { } problem }) + { + return problem; + } - var devRequest = await FindDevRequestAsync(request); + if (await CheckPermissionAsync(McpToolNames.StartDevRequest, DevRequestUpdatePermission) is { } denied) + { + return denied; + } + + if (!(await FindDevRequestAsync(request)).TryGetValue(out var devRequest, out var notFound)) + { + return notFound; + } if (!string.Equals(devRequest.Status, McpConsts.DevRequestStatuses.ReadyToStart, StringComparison.Ordinal)) { - throw new UserFriendlyException(L[McpErrorCodes.DevRequestNotReady, devRequest.Title ?? devRequest.Id.ToString(), devRequest.Status ?? string.Empty]); + return Problem(McpErrorCodes.DevRequestNotReady, devRequest.Title ?? devRequest.Id.ToString(), devRequest.Status ?? string.Empty); } devRequest.Status = McpConsts.DevRequestStatuses.InProgress; @@ -568,12 +637,22 @@ public class McpDevelopAppService( return ToDto(devRequest); } - public async Task GetDevRequestAsync(string request) + public async Task> GetDevRequestAsync(string request) { - await EnsureToolAllowedAsync(McpToolNames.GetDevRequest); - await AuthorizationService.CheckAsync(PlatformConsts.AppCodes.DevRequests); + if (await EnsureToolAllowedAsync(McpToolNames.GetDevRequest) is { Problem: { } problem }) + { + return problem; + } - var devRequest = await FindDevRequestAsync(request); + if (await CheckPermissionAsync(McpToolNames.GetDevRequest, PlatformConsts.AppCodes.DevRequests) is { } denied) + { + return denied; + } + + if (!(await FindDevRequestAsync(request)).TryGetValue(out var devRequest, out var notFound)) + { + return notFound; + } await timesheetRecorder.BeginAsync(devRequest.Id); return ToDto(devRequest); @@ -585,21 +664,31 @@ public class McpDevelopAppService( /// talep dogrudan Ready to Start'a gecer; sartli ya da Hayir olan analiz kullanicinin kararini /// bekledigi icin Analysis'te kalir. /// - public async Task SaveDevRequestAnalysisAsync(string request, string html, bool productionReady) + public async Task> SaveDevRequestAnalysisAsync(string request, string html, bool productionReady) { - await EnsureToolAllowedAsync(McpToolNames.SaveDevRequestAnalysis); - await AuthorizationService.CheckAsync(DevRequestUpdatePermission); + if (await EnsureToolAllowedAsync(McpToolNames.SaveDevRequestAnalysis) is { Problem: { } problem }) + { + return problem; + } + + if (await CheckPermissionAsync(McpToolNames.SaveDevRequestAnalysis, DevRequestUpdatePermission) is { } denied) + { + return denied; + } if (string.IsNullOrWhiteSpace(html)) { - throw new UserFriendlyException(L[McpErrorCodes.DevRequestAnalysisRequired]); + return Problem(McpErrorCodes.DevRequestAnalysisRequired); } - var devRequest = await FindDevRequestAsync(request); + if (!(await FindDevRequestAsync(request)).TryGetValue(out var devRequest, out var notFound)) + { + return notFound; + } if (devRequest.Status is not (McpConsts.DevRequestStatuses.Backlog or McpConsts.DevRequestStatuses.Analysis or McpConsts.DevRequestStatuses.ReadyToStart)) { - throw new UserFriendlyException(L[McpErrorCodes.DevRequestNotAnalyzable, devRequest.Title ?? devRequest.Id.ToString(), devRequest.Status ?? string.Empty]); + return Problem(McpErrorCodes.DevRequestNotAnalyzable, devRequest.Title ?? devRequest.Id.ToString(), devRequest.Status ?? string.Empty); } devRequest.Analysis = html.Trim(); @@ -616,20 +705,33 @@ public class McpDevelopAppService( /// Aciklama talebin yazildigi haliyle kalir ve base64 gorsel tasir; uretim notlari analize /// eklenir. Ekleme sunucuda, var olan metne dokunmadan yapilir. /// - public async Task AppendDevRequestNoteAsync(string request, string html) + public async Task AppendDevRequestNoteAsync(string request, string html) { - await EnsureToolAllowedAsync(McpToolNames.AppendDevRequestNote); - await AuthorizationService.CheckAsync(DevRequestUpdatePermission); + if (await EnsureToolAllowedAsync(McpToolNames.AppendDevRequestNote) is { Problem: { } problem }) + { + return problem; + } + + if (await CheckPermissionAsync(McpToolNames.AppendDevRequestNote, DevRequestUpdatePermission) is { } denied) + { + return denied; + } if (string.IsNullOrWhiteSpace(html)) { - throw new UserFriendlyException(L[McpErrorCodes.DevRequestNoteRequired]); + return Problem(McpErrorCodes.DevRequestNoteRequired); + } + + if (!(await FindDevRequestAsync(request)).TryGetValue(out var devRequest, out var notFound)) + { + return notFound; } - var devRequest = await FindDevRequestAsync(request); devRequest.Analysis = string.Concat(devRequest.Analysis, html.Trim()); await devRequestRepository.UpdateAsync(devRequest, autoSave: true); await timesheetRecorder.RecordAsync(McpTimesheetTexts.AppendNote); + + return null; } /// @@ -637,29 +739,44 @@ public class McpDevelopAppService( /// (ekranin atanan listesi kullanici adiyla calisir), bitis tarihi yazildigi gundur. /// Var olan satirlara dokunulmaz. /// - public async Task AddDevRequestSubTasksAsync(string request, List titles) + public async Task AddDevRequestSubTasksAsync(string request, List titles) { - await EnsureToolAllowedAsync(McpToolNames.AddDevRequestSubTasks); - await AuthorizationService.CheckAsync(DevRequestUpdatePermission); + if (await EnsureToolAllowedAsync(McpToolNames.AddDevRequestSubTasks) is { Problem: { } problem }) + { + return problem; + } + + if (await CheckPermissionAsync(McpToolNames.AddDevRequestSubTasks, DevRequestUpdatePermission) is { } denied) + { + return denied; + } var items = (titles ?? []).Select(title => title?.Trim()).Where(title => !string.IsNullOrEmpty(title)).ToList(); if (items.Count == 0) { - throw new UserFriendlyException(L[McpErrorCodes.DevRequestSubTasksRequired]); + return Problem(McpErrorCodes.DevRequestSubTasksRequired); } - var devRequest = await FindDevRequestAsync(request); + if (!(await FindDevRequestAsync(request)).TryGetValue(out var devRequest, out var notFound)) + { + return notFound; + } - JsonArray subTasks; + JsonArray? subTasks; try { subTasks = string.IsNullOrWhiteSpace(devRequest.SubTasks) - ? [] - : JsonNode.Parse(devRequest.SubTasks) as JsonArray ?? throw new JsonException(); + ? new JsonArray() + : JsonNode.Parse(devRequest.SubTasks) as JsonArray; } catch (JsonException) { - throw new UserFriendlyException(L[McpErrorCodes.DevRequestSubTasksInvalid, devRequest.Title ?? devRequest.Id.ToString()]); + subTasks = null; + } + + if (subTasks is null) + { + return Problem(McpErrorCodes.DevRequestSubTasksInvalid, devRequest.Title ?? devRequest.Id.ToString()); } var assignee = CurrentUser.UserName ?? string.Empty; @@ -680,17 +797,26 @@ public class McpDevelopAppService( devRequest.SubTasks = subTasks.ToJsonString(); await devRequestRepository.UpdateAsync(devRequest, autoSave: true); await timesheetRecorder.RecordAsync(McpTimesheetTexts.AddSubTasks, items.Count); + + return null; } - public async Task GetModuleDocAsync(string name) + public async Task> GetModuleDocAsync(string name) { - await EnsureToolAllowedAsync(McpToolNames.GetModuleDoc); + if (await EnsureToolAllowedAsync(McpToolNames.GetModuleDoc) is { Problem: { } problem }) + { + return problem; + } + return await moduleDocAppService.GetByNameAsync(name); } - public async Task SaveModuleDocAsync(string name, string content) + public async Task> SaveModuleDocAsync(string name, string content) { - await EnsureToolAllowedAsync(McpToolNames.SaveModuleDoc); + if (await EnsureToolAllowedAsync(McpToolNames.SaveModuleDoc) is { Problem: { } problem }) + { + return problem; + } var doc = await moduleDocAppService.SaveAsync(new ModuleDocSaveInput { Name = name, Content = content }); await timesheetRecorder.RecordAsync(McpTimesheetTexts.SaveModuleDoc, doc.Name); @@ -720,23 +846,23 @@ public class McpDevelopAppService( } /// Id ile ya da tam baslikla bulur; ayni basligi tasiyan birden fazla talep varsa Id istenir. - private async Task FindDevRequestAsync(string? request) + private async Task> FindDevRequestAsync(string? request) { var value = request?.Trim() ?? string.Empty; if (Guid.TryParse(value, out var id)) { - return await devRequestRepository.FindAsync(id) - ?? throw new UserFriendlyException(L[McpErrorCodes.DevRequestNotFound, value]); + var byId = await devRequestRepository.FindAsync(id); + return byId is null ? Problem(McpErrorCodes.DevRequestNotFound, value) : byId; } var matches = value.Length == 0 ? [] : await devRequestRepository.GetListAsync(r => r.Title == value); return matches.Count switch { - 0 => throw new UserFriendlyException(L[McpErrorCodes.DevRequestNotFound, value]), + 0 => Problem(McpErrorCodes.DevRequestNotFound, value), 1 => matches[0], - _ => throw new UserFriendlyException(L[McpErrorCodes.DevRequestAmbiguous, value, string.Join(", ", matches.Select(r => r.Id))]) + _ => Problem(McpErrorCodes.DevRequestAmbiguous, value, string.Join(", ", matches.Select(r => r.Id))) }; } @@ -843,13 +969,16 @@ public class McpDevelopAppService( private static McpVerifyCheckDto Failed(string name, string message) => new() { Name = name, Passed = false, Message = message }; - public async Task> GetComponentsAsync(string? search) + public async Task>> GetComponentsAsync(string? search) { - await EnsureToolAllowedAsync(McpToolNames.ListComponents); + if (await EnsureToolAllowedAsync(McpToolNames.ListComponents) is { Problem: { } problem }) + { + return problem; + } var components = await GetAllComponentsAsync(); - return [.. components + return components .Where(component => string.IsNullOrWhiteSpace(search) || Contains(component.Name, search) || Contains(component.RoutePath, search) @@ -862,24 +991,31 @@ public class McpDevelopAppService( Description = component.Description, IsActive = component.IsActive, IsVisualDesigner = IsVisualDesignerDocument(component.Props) - })]; + }) + .ToList(); } - public async Task GetComponentAsync(string name) + public async Task> GetComponentAsync(string name) { - await EnsureToolAllowedAsync(McpToolNames.GetComponent); + if (await EnsureToolAllowedAsync(McpToolNames.GetComponent) is { Problem: { } problem }) + { + return problem; + } return await FindComponentAsync(name); } - public async Task SaveComponentAsync(McpComponentSaveRequestDto input) + public async Task> SaveComponentAsync(McpComponentSaveRequestDto input) { Check.NotNull(input, nameof(input)); - await EnsureToolAllowedAsync(McpToolNames.SaveComponent); + if (await EnsureToolAllowedAsync(McpToolNames.SaveComponent) is { Problem: { } problem }) + { + return problem; + } if (string.IsNullOrWhiteSpace(input.Name)) { - throw new UserFriendlyException(L[McpErrorCodes.ComponentNameRequired]); + return Problem(McpErrorCodes.ComponentNameRequired); } var existing = await FindComponentAsync(input.Name); @@ -889,14 +1025,14 @@ public class McpDevelopAppService( // kayitta sessizce ezilirdi. Belge MCP'de uretilemedigi icin bu bilesenler tasarimcida kalir. if (IsVisualDesignerDocument(props)) { - throw new UserFriendlyException(L[McpErrorCodes.VisualDesignerComponent, input.Name]); + return Problem(McpErrorCodes.VisualDesignerComponent, input.Name); } if (existing is null) { if (string.IsNullOrWhiteSpace(input.Code)) { - throw new UserFriendlyException(L[McpErrorCodes.ComponentCodeRequired]); + return Problem(McpErrorCodes.ComponentCodeRequired); } var created = await componentAppService.CreateAsync(new CreateUpdateCustomComponentDto @@ -929,15 +1065,23 @@ public class McpDevelopAppService( return updated; } - public async Task DeleteComponentAsync(string name) + public async Task DeleteComponentAsync(string name) { - await EnsureToolAllowedAsync(McpToolNames.DeleteComponent); + if (await EnsureToolAllowedAsync(McpToolNames.DeleteComponent) is { Problem: { } problem }) + { + return problem; + } - var component = await FindComponentAsync(name) - ?? throw new UserFriendlyException(L[McpErrorCodes.ComponentNotFound, name]); + var component = await FindComponentAsync(name); + if (component is null) + { + return Problem(McpErrorCodes.ComponentNotFound, name); + } await componentAppService.DeleteAsync(component.Id); await timesheetRecorder.RecordAsync(McpTimesheetTexts.DeleteComponent, component.Name); + + return null; } /// @@ -946,18 +1090,20 @@ public class McpDevelopAppService( /// ayni anlama gelir. ListForm/Fields bolumleri ekrana katman olarak uygulanir /// (). /// - private ListFormWizardDto ParseWizard(string? json) + private McpOutcome ParseWizard(string? json) { + ListFormWizardDto? wizard; try { - return (string.IsNullOrWhiteSpace(json) ? null : WizardSeedFileDto.FromDeployJson(json)) - ?? throw new UserFriendlyException(L[McpErrorCodes.InvalidWizard]); + wizard = string.IsNullOrWhiteSpace(json) ? null : WizardSeedFileDto.FromDeployJson(json); } catch (JsonException ex) { // Genel mesajla model hangi degerin bozuk oldugunu bilemez ve ayni JSON'u tekrar gonderir. - throw new UserFriendlyException(L[McpErrorCodes.InvalidWizardValue, ex.Path ?? "$", ex.Message]); + return Problem(McpErrorCodes.InvalidWizardValue, ex.Path ?? "$", ex.Message); } + + return wizard is null ? Problem(McpErrorCodes.InvalidWizard) : wizard; } private async Task> GetAllComponentsAsync() diff --git a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpQueryAppService.cs b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpQueryAppService.cs index 04f03851..a92aef88 100644 --- a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpQueryAppService.cs +++ b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpQueryAppService.cs @@ -46,27 +46,39 @@ public partial class McpQueryAppService( private const string SqlPermission = PlatformConsts.AppCodes.SqlQueryManagers.Default; private const string CustomEndpointPermission = PlatformConsts.AppCodes.DeveloperKits.Get; - public async Task QueryListAsync(McpListQueryRequestDto input) + public async Task> QueryListAsync(McpListQueryRequestDto input) { Check.NotNull(input, nameof(input)); - var config = await EnsureToolAllowedAsync(McpToolNames.QueryList); + 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) + if (permission is not null && await CheckPermissionAsync(McpToolNames.QueryList, permission) is { } denied) { - await AuthorizationService.CheckAsync(permission); + 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 = 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), + Sort = sort, + Group = group, + GroupSummary = groupSummary, + TotalSummary = totalSummary, Skip = Math.Max(input.Skip, 0), Take = take, RequireTotalCount = true, @@ -84,7 +96,7 @@ public partial class McpQueryAppService( } /// Bozuk girdi SQL hatasina donusmeden, hangi parametrede oldugu soylenerek reddedilir. - private string? ToLoadOption(string parameter, string? value, Func convert) + private McpOutcome ToLoadOption(string parameter, string? value, Func convert) { try { @@ -92,21 +104,31 @@ public partial class McpQueryAppService( } catch (Exception ex) when (ex is JsonException or InvalidOperationException) { - throw new UserFriendlyException(L[McpErrorCodes.InvalidLoadOption, JsonNamingPolicy.CamelCase.ConvertName(parameter)]); + return Problem(McpErrorCodes.InvalidLoadOption, JsonNamingPolicy.CamelCase.ConvertName(parameter)); } } - public async Task RunSelectAsync(McpSelectRequestDto input) + public async Task> RunSelectAsync(McpSelectRequestDto input) { Check.NotNull(input, nameof(input)); - var config = await EnsureToolAllowedAsync(McpToolNames.RunSelect); + if (!(await EnsureToolAllowedAsync(McpToolNames.RunSelect)).TryGetValue(out var config, out var problem)) + { + return problem; + } var sql = (input.Sql ?? string.Empty).Trim().TrimEnd(';').Trim(); - sqlGuard.EnsureReadOnlyStatement(sql); + 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 parameters = sqlGuard.NormalizeParameters(input.Parameters); var context = await queryExecutor.ResolveDataSourceAsync(); var limitedSql = sqlGuard.ApplyRowLimit(sql, maxRows, context.DataSourceType); @@ -121,12 +143,12 @@ public partial class McpQueryAppService( { var pending = IsUnknownObjectError(ex) ? DescribePendingTables(sql) : null; if (pending is not null) - throw new UserFriendlyException(L[McpErrorCodes.SelectTableNotInstalled, ex.Message, pending]); + return Problem(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]); + return columns is null + ? Problem(McpErrorCodes.SelectFailed, ex.Message) + : Problem(McpErrorCodes.SelectFailedColumns, ex.Message, columns); } } @@ -208,9 +230,13 @@ public partial class McpQueryAppService( [GeneratedRegex(@"\b(?:FROM|JOIN)\s+(?:[\[""]?\w+[\]""]?\.)?[\[""]?(?\w+)[\]""]?", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex TableReferenceRegex(); - public async Task> GetSavedQueriesAsync(string? search = null) + public async Task>> GetSavedQueriesAsync(string? search = null) { - await EnsureToolAllowedAsync(McpToolNames.ListQueries); + if (await EnsureToolAllowedAsync(McpToolNames.ListQueries) is { Problem: { } problem }) + { + return problem; + } + var results = new List(); if (await AuthorizationService.IsGrantedAsync(SqlPermission)) @@ -230,9 +256,10 @@ public partial class McpQueryAppService( || (item.Description?.Contains(search, StringComparison.OrdinalIgnoreCase) ?? false))]; } - return [.. results + return results .OrderBy(item => item.Kind, StringComparer.Ordinal) - .ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase)]; + .ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); } private async Task> GetDatabaseObjectsAsync() @@ -266,44 +293,58 @@ public partial class McpQueryAppService( return results; } - public async Task RunSavedQueryAsync(McpSavedQueryRequestDto input) + public async Task> RunSavedQueryAsync(McpSavedQueryRequestDto input) { Check.NotNull(input, nameof(input)); - var config = await EnsureToolAllowedAsync(McpToolNames.RunQuery); - - if (IsKind(input.Kind, McpConsts.SavedQueryKinds.CustomEndpoint)) + if (!(await EnsureToolAllowedAsync(McpToolNames.RunQuery)).TryGetValue(out var config, out var problem)) { - await AuthorizationService.CheckAsync(CustomEndpointPermission); - return await RunCustomEndpointAsync(input, config); + return problem; } - await AuthorizationService.CheckAsync(SqlPermission); + 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); - var parameters = sqlGuard.NormalizeParameters(input.Parameters); + 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; - var qualifiedName = sqlGuard.QuoteQualifiedName(schemaName, input.Name, context.DataSourceType); - var sql = BuildSavedQuerySql(input.Kind, qualifiedName, parameters, maxRows, context.DataSourceType); + 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( + private async Task> RunCustomEndpointAsync( McpSavedQueryRequestDto input, - McpServerConfigDto config) + Dictionary parameters, + int maxRows) { 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); + 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 @@ -313,7 +354,7 @@ public partial class McpQueryAppService( if (missing.Count > 0) { - throw new UserFriendlyException(L[McpErrorCodes.MissingParameters, string.Join(", ", missing)]); + return Problem(McpErrorCodes.MissingParameters, string.Join(", ", missing)); } var context = await queryExecutor.ResolveDataSourceAsync(endpoint.DataSourceCode); @@ -349,7 +390,7 @@ public partial class McpQueryAppService( || (permission.ResourceType == "Role" && CurrentUser.Roles.Contains(permission.ResourceId))))]; } - private string BuildSavedQuerySql( + private McpOutcome BuildSavedQuerySql( string? kind, string qualifiedName, Dictionary parameters, @@ -381,7 +422,7 @@ public partial class McpQueryAppService( : $"EXEC {qualifiedName} {string.Join(", ", parameters.Keys.Select(name => $"@{name} = @{name}"))}".TrimEnd(); } - throw new UserFriendlyException(L[McpErrorCodes.UnsupportedObjectKind, kind ?? string.Empty]); + return Problem(McpErrorCodes.UnsupportedObjectKind, kind ?? string.Empty); } private static string? NullIfEmpty(string? value) diff --git a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpReportAppService.cs b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpReportAppService.cs index f36f58e6..9da77863 100644 --- a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpReportAppService.cs +++ b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpReportAppService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; @@ -41,10 +41,12 @@ public partial class McpReportAppService( [GeneratedRegex(@"\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}")] private static partial Regex PlaceholderRegex(); - public async Task GetReportTemplatesAsync(string? search) + public async Task> GetReportTemplatesAsync(string? search) { - await EnsureToolAllowedAsync(McpToolNames.ListReportTemplates); - await EnsureCanReadAsync(); + if (await EnsureCanReadAsync(McpToolNames.ListReportTemplates) is { } problem) + { + return problem; + } var categories = await categoryRepository.GetListAsync(); var categoryNames = categories.ToDictionary(x => x.Id, x => x.Name); @@ -70,25 +72,35 @@ public partial class McpReportAppService( }; } - public async Task GetReportTemplateAsync(string template) + public async Task> GetReportTemplateAsync(string template) { - await EnsureToolAllowedAsync(McpToolNames.GetReportTemplate); - await EnsureCanReadAsync(); + if (await EnsureCanReadAsync(McpToolNames.GetReportTemplate) is { } problem) + { + return problem; + } - var entity = await FindTemplateAsync(template) - ?? throw new UserFriendlyException(L[McpErrorCodes.ReportTemplateNotFound, template]); + var entity = await FindTemplateAsync(template); + if (entity is null) + { + return Problem(McpErrorCodes.ReportTemplateNotFound, template); + } return await ToDtoAsync(entity, null, null); } - public async Task SaveReportCategoryAsync(McpReportCategorySaveRequestDto input) + public async Task> SaveReportCategoryAsync(McpReportCategorySaveRequestDto input) { Check.NotNull(input, nameof(input)); - await EnsureToolAllowedAsync(McpToolNames.SaveReportCategory); + const string toolName = McpToolNames.SaveReportCategory; + + if (await EnsureToolAllowedAsync(toolName) is { Problem: { } problem }) + { + return problem; + } if (string.IsNullOrWhiteSpace(input.Name)) { - throw new UserFriendlyException(L[McpErrorCodes.ReportNameRequired]); + return Problem(McpErrorCodes.ReportNameRequired); } var name = input.Name.Trim(); @@ -100,24 +112,33 @@ public partial class McpReportAppService( ["Icon"] = input.Icon ?? existing?.Icon }; - var id = existing is null - ? await InsertAsync(CategoriesListFormCode, values) - : await UpdateAsync(CategoriesListFormCode, existing.Id, values); + var saved = existing is null + ? await InsertAsync(toolName, CategoriesListFormCode, values) + : await UpdateAsync(toolName, CategoriesListFormCode, existing.Id, values); + if (!saved.TryGetValue(out var id, out var rejected)) + { + return rejected; + } await timesheetRecorder.RecordAsync(McpTimesheetTexts.SaveReportCategory, name); - var saved = await categoryRepository.FirstOrDefaultAsync(x => x.Id == id); - return saved is null ? new McpReportCategoryDto { Id = id, Name = name } : ToDto(saved); + var category = await categoryRepository.FirstOrDefaultAsync(x => x.Id == id); + return category is null ? new McpReportCategoryDto { Id = id, Name = name } : ToDto(category); } - public async Task SaveReportTemplateAsync(McpReportTemplateSaveRequestDto input) + public async Task> SaveReportTemplateAsync(McpReportTemplateSaveRequestDto input) { Check.NotNull(input, nameof(input)); - await EnsureToolAllowedAsync(McpToolNames.SaveReportTemplate); + const string toolName = McpToolNames.SaveReportTemplate; + + if (await EnsureToolAllowedAsync(toolName) is { Problem: { } problem }) + { + return problem; + } if (string.IsNullOrWhiteSpace(input.Name)) { - throw new UserFriendlyException(L[McpErrorCodes.ReportNameRequired]); + return Problem(McpErrorCodes.ReportNameRequired); } var name = input.Name.Trim(); @@ -125,22 +146,32 @@ public partial class McpReportAppService( var htmlContent = string.IsNullOrWhiteSpace(input.HtmlContent) ? existing?.HtmlContent : input.HtmlContent; if (string.IsNullOrWhiteSpace(htmlContent)) { - throw new UserFriendlyException(L[McpErrorCodes.ReportHtmlRequired]); + return Problem(McpErrorCodes.ReportHtmlRequired); + } + + var categoryId = existing?.CategoryId; + if (!string.IsNullOrWhiteSpace(input.Category)) + { + categoryId = (await FindCategoryAsync(input.Category))?.Id; + if (categoryId is null) + { + return Problem(McpErrorCodes.ReportCategoryNotFound, input.Category); + } } - var categoryId = string.IsNullOrWhiteSpace(input.Category) - ? existing?.CategoryId - : (await FindCategoryAsync(input.Category))?.Id - ?? throw new UserFriendlyException(L[McpErrorCodes.ReportCategoryNotFound, input.Category]); if (categoryId is null) { - throw new UserFriendlyException(L[McpErrorCodes.ReportCategoryRequired]); + return Problem(McpErrorCodes.ReportCategoryRequired); } - var listForm = await FindListFormAsync(input.ListFormCode); - if (listForm is not null) + if (!(await FindListFormAsync(input.ListFormCode)).TryGetValue(out var listForm, out var notFound)) { - await EnsurePlaceholdersExistAsync(listForm.ListFormCode, htmlContent); + return notFound; + } + + if (listForm is not null && await CheckPlaceholdersExistAsync(listForm.ListFormCode, htmlContent) is { } unknown) + { + return unknown; } var values = new Dictionary @@ -152,44 +183,74 @@ public partial class McpReportAppService( ["Status"] = input.Status ?? existing?.Status ?? ActiveStatus }; - var id = existing is null - ? await InsertAsync(TemplatesListFormCode, values) - : await UpdateAsync(TemplatesListFormCode, existing.Id, values); + var saved = existing is null + ? await InsertAsync(toolName, TemplatesListFormCode, values) + : await UpdateAsync(toolName, TemplatesListFormCode, existing.Id, values); + if (!saved.TryGetValue(out var id, out var rejected)) + { + return rejected; + } await timesheetRecorder.RecordAsync(McpTimesheetTexts.SaveReportTemplate, name); - var saved = await templateRepository.FirstOrDefaultAsync(x => x.Id == id) - ?? throw new UserFriendlyException(L[McpErrorCodes.ReportTemplateNotFound, name]); + var template = await templateRepository.FirstOrDefaultAsync(x => x.Id == id); + if (template is null) + { + return Problem(McpErrorCodes.ReportTemplateNotFound, name); + } - return await ToDtoAsync(saved, listForm, input.ButtonText); + return await ToDtoAsync(template, listForm, input.ButtonText); } - public async Task DeleteReportTemplateAsync(string template) + public async Task DeleteReportTemplateAsync(string template) { - await EnsureToolAllowedAsync(McpToolNames.DeleteReportTemplate); + const string toolName = McpToolNames.DeleteReportTemplate; - var entity = await FindTemplateAsync(template) - ?? throw new UserFriendlyException(L[McpErrorCodes.ReportTemplateNotFound, template]); + if (await EnsureToolAllowedAsync(toolName) is { Problem: { } problem }) + { + return problem; + } - await listFormDataAppService.PostDeleteAsync(await BuildRequestAsync( - TemplatesListFormCode, AuthorizationTypeEnum.Delete, null, entity.Id)); + var entity = await FindTemplateAsync(template); + if (entity is null) + { + return Problem(McpErrorCodes.ReportTemplateNotFound, template); + } + + if (!(await BuildRequestAsync(toolName, TemplatesListFormCode, AuthorizationTypeEnum.Delete, null, entity.Id)).TryGetValue(out var request, out var denied)) + { + return denied; + } + + await listFormDataAppService.PostDeleteAsync(request); await timesheetRecorder.RecordAsync(McpTimesheetTexts.DeleteReportTemplate, entity.Name); + + return null; } /// /// Okuma depodan yapildigi icin ekranin okuma yetkisi burada istenir; MCP kullanicinin /// uygulamada goremedigi sablonu gostermez. /// - private async Task EnsureCanReadAsync() + private async Task EnsureCanReadAsync(string toolName) { + if (await EnsureToolAllowedAsync(toolName) is { Problem: { } problem }) + { + return problem; + } + var permission = await accessFilter.GetPermissionNameAsync(TemplatesListFormCode, AuthorizationTypeEnum.Read); - await AuthorizationService.CheckAsync(permission ?? TemplatesListFormCode); + return await CheckPermissionAsync(toolName, permission ?? TemplatesListFormCode); } - private async Task InsertAsync(string listFormCode, Dictionary values) + private async Task> InsertAsync(string toolName, string listFormCode, Dictionary values) { - var key = await listFormDataAppService.PostInsertAsync(await BuildRequestAsync( - listFormCode, AuthorizationTypeEnum.Create, values, null)); + if (!(await BuildRequestAsync(toolName, listFormCode, AuthorizationTypeEnum.Create, values, null)).TryGetValue(out var request, out var problem)) + { + return problem; + } + + var key = await listFormDataAppService.PostInsertAsync(request); // Insert ucu her veri kaynaginda anahtari dondurmez; bu durumda tekil ad uzerinden bulunur. if (Guid.TryParse(((object?)key)?.ToString(), out var id)) @@ -203,10 +264,14 @@ public partial class McpReportAppService( : (await templateRepository.FirstAsync(x => x.Name == name)).Id; } - private async Task UpdateAsync(string listFormCode, Guid id, Dictionary values) + private async Task> UpdateAsync(string toolName, string listFormCode, Guid id, Dictionary values) { - await listFormDataAppService.PostUpdateAsync(await BuildRequestAsync( - listFormCode, AuthorizationTypeEnum.Update, values, id)); + if (!(await BuildRequestAsync(toolName, listFormCode, AuthorizationTypeEnum.Update, values, id)).TryGetValue(out var request, out var problem)) + { + return problem; + } + + await listFormDataAppService.PostUpdateAsync(request); return id; } @@ -214,16 +279,17 @@ public partial class McpReportAppService( /// Ekranin kendi kontrolu reddi adsiz bir hatayla bildirir; yetki burada adiyla istenir ki /// istemci hangi yetkinin eksik oldugunu gorsun. /// - private async Task BuildRequestAsync( + private async Task> BuildRequestAsync( + string toolName, string listFormCode, AuthorizationTypeEnum operation, Dictionary? values, Guid? key) { var permission = await accessFilter.GetPermissionNameAsync(listFormCode, operation); - if (permission is not null) + if (permission is not null && await CheckPermissionAsync(toolName, permission) is { } denied) { - await AuthorizationService.CheckAsync(permission); + return denied; } return new DataRequestDto @@ -255,32 +321,34 @@ public partial class McpReportAppService( : await categoryRepository.FirstOrDefaultAsync(x => x.Name == value); } - private async Task FindListFormAsync(string? listFormCode) + /// Kod verilmediyse null; verilen kodun ekrani yoksa sorun. + private async Task> FindListFormAsync(string? listFormCode) { if (string.IsNullOrWhiteSpace(listFormCode)) { - return null; + return default(ListForm); } var code = listFormCode.Trim(); - return await listFormRepository.FirstOrDefaultAsync(x => x.ListFormCode == code) - ?? throw new UserFriendlyException(L[McpErrorCodes.ReportListFormNotFound, code]); + var listForm = await listFormRepository.FirstOrDefaultAsync(x => x.ListFormCode == code); + + return listForm is null ? Problem(McpErrorCodes.ReportListFormNotFound, code) : listForm; } /// /// Bilinmeyen yer tutucu raporda oldugu gibi basilir; sertifikada "{{AdSoyad}}" gorunmesin /// diye kayitta reddedilir. /// - private async Task EnsurePlaceholdersExistAsync(string listFormCode, string htmlContent) + private async Task CheckPlaceholdersExistAsync(string listFormCode, string htmlContent) { var fieldNames = (await listFormFieldRepository.GetListAsync(x => x.ListFormCode == listFormCode)) .Select(x => x.FieldName) .ToHashSet(StringComparer.OrdinalIgnoreCase); var unknown = GetPlaceholders(htmlContent).Where(x => !fieldNames.Contains(x)).ToList(); - if (unknown.Count > 0) - { - throw new UserFriendlyException(L[McpErrorCodes.ReportPlaceholdersUnknown, listFormCode, string.Join(", ", unknown)]); - } + + return unknown.Count == 0 + ? null + : Problem(McpErrorCodes.ReportPlaceholdersUnknown, listFormCode, string.Join(", ", unknown)); } private static List GetPlaceholders(string htmlContent) diff --git a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpSchemaAppService.cs b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpSchemaAppService.cs index ec43e793..dbde9fe7 100644 --- a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpSchemaAppService.cs +++ b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpSchemaAppService.cs @@ -93,9 +93,12 @@ public class McpSchemaAppService( }; } - public async Task> GetListsAsync(string? search = null, int maxResults = 200) + public async Task>> GetListsAsync(string? search = null, int maxResults = 200) { - await EnsureToolAllowedAsync(McpToolNames.ListLists); + if (await EnsureToolAllowedAsync(McpToolNames.ListLists) is { Problem: { } problem }) + { + return problem; + } var forms = await GetReadableListFormsAsync(); var normalizedSearch = dictionary.Normalize(search); @@ -103,16 +106,19 @@ public class McpSchemaAppService( var matches = forms.Where(form => normalizedSearch.Length == 0 || dictionary.Normalize(form.SearchText).Contains(normalizedSearch, StringComparison.Ordinal)); - return [.. matches.Take(NormalizeLimit(maxResults, 200)).Select(ToSummary)]; + return matches.Take(NormalizeLimit(maxResults, 200)).Select(ToSummary).ToList(); } - public async Task GetListAsync(string listFormCode) + public async Task> GetListAsync(string listFormCode) { - await EnsureToolAllowedAsync(McpToolNames.DescribeList); + if (await EnsureToolAllowedAsync(McpToolNames.DescribeList) is { Problem: { } problem }) + { + return problem; + } if (string.IsNullOrWhiteSpace(listFormCode)) { - return null; + return default(McpListDetailDto); } var forms = await GetReadableListFormsAsync(); @@ -121,7 +127,7 @@ public class McpSchemaAppService( if (form is null) { - return null; + return default(McpListDetailDto); } var detail = new McpListDetailDto(); @@ -131,14 +137,17 @@ public class McpSchemaAppService( return detail; } - public async Task> SearchAsync(string term, int maxResults = 50) + public async Task>> SearchAsync(string term, int maxResults = 50) { - await EnsureToolAllowedAsync(McpToolNames.SearchSchema); + if (await EnsureToolAllowedAsync(McpToolNames.SearchSchema) is { Problem: { } problem }) + { + return problem; + } var normalized = dictionary.Normalize(term); if (normalized.Length == 0) { - return []; + return new List(); } var limit = NormalizeLimit(maxResults, 50); diff --git a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Domain.Shared/McpOutcome.cs b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Domain.Shared/McpOutcome.cs new file mode 100644 index 00000000..b4a20dc4 --- /dev/null +++ b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Domain.Shared/McpOutcome.cs @@ -0,0 +1,54 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Localization; + +namespace Sozsoft.Mcp; + +/// +/// Aracin modelle konusarak cozulecek reddi: eksik ya da hatali girdi, durum kurali, eksik yetki. +/// Istisna olarak atilmaz — bu durum aracin beklenen cevabidir; atilan istisna debugger'i durdurur +/// ve SDK'da hata logu uretir. Istemciye hata sonucu (IsError) olarak gider, model mesaji +/// okuyup girdiyi duzeltir ve tekrar dener. +/// +public sealed record McpProblem(string Message) +{ + public static McpProblem From(IStringLocalizer localizer, string name, params object[] arguments) + => new(localizer[name, arguments]); +} + +/// Aracin degeri ya da 'i; ikisi birden olmaz. +public readonly struct McpOutcome +{ + private McpOutcome(T? value, McpProblem? problem) + { + Value = value; + Problem = problem; + } + + public T? Value { get; } + + public McpProblem? Problem { get; } + + public bool TryGetValue([MaybeNullWhen(false)] out T value, [NotNullWhen(false)] out McpProblem? problem) + { + value = Value!; + problem = Problem; + return problem is null; + } + + public static implicit operator McpOutcome(T value) => new(value, null); + + public static implicit operator McpOutcome(McpProblem problem) => new(default, problem); +} + +/// Yetki reddi mesajinda yetkinin gorunen adi ve kodu; rol ekraninda aranabilsin diye kod her zaman yazilir. +public static class McpPermissionText +{ + public static string Describe(IStringLocalizer localizer, string permissionName) + { + var displayName = localizer[permissionName]; + + return displayName.ResourceNotFound || displayName.Value == permissionName + ? permissionName + : $"{displayName.Value} ({permissionName})"; + } +} diff --git a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Domain/Services/McpSqlGuard.cs b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Domain/Services/McpSqlGuard.cs index 33d7a83e..d61ca7bc 100644 --- a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Domain/Services/McpSqlGuard.cs +++ b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Domain/Services/McpSqlGuard.cs @@ -4,7 +4,6 @@ using System.Text.RegularExpressions; using Microsoft.Extensions.Localization; using Sozsoft.Platform.Enums; using Sozsoft.Platform.Localization; -using Volo.Abp; using Volo.Abp.Domain.Services; namespace Sozsoft.Mcp.Services; @@ -16,11 +15,11 @@ namespace Sozsoft.Mcp.Services; /// public interface IMcpSqlGuard { - /// Tek bir okuma ifadesi oldugunu dogrular; degilse anlasilir bir hata atar. - void EnsureReadOnlyStatement(string sql); + /// Tek bir okuma ifadesi oldugunu dogrular; degilse nedenini dondurur. + McpProblem? CheckReadOnlyStatement(string sql); /// Sema/nesne adini dogrular ve saglayiciya gore tirnaklar. - string QuoteQualifiedName(string schemaName, string objectName, DataSourceTypeEnum dataSourceType); + McpOutcome QuoteQualifiedName(string schemaName, string objectName, DataSourceTypeEnum dataSourceType); /// Saglayicinin varsayilan semasi. string DefaultSchemaName(DataSourceTypeEnum dataSourceType); @@ -35,7 +34,7 @@ public interface IMcpSqlGuard /// Istemciden gelen parametre adlarini dogrular ve bastaki @ isaretini duserek Dapper'in /// bekledigi bicime cevirir. /// - Dictionary NormalizeParameters(IReadOnlyDictionary? parameters); + McpOutcome> NormalizeParameters(IReadOnlyDictionary? parameters); } public class McpSqlGuard(IStringLocalizer localizer) : DomainService, IMcpSqlGuard @@ -78,18 +77,18 @@ public class McpSqlGuard(IStringLocalizer localizer) : DomainS @"\bLIMIT\s+\d+", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - public void EnsureReadOnlyStatement(string sql) + public McpProblem? CheckReadOnlyStatement(string sql) { if (string.IsNullOrWhiteSpace(sql)) { - throw new UserFriendlyException(localizer[McpErrorCodes.EmptyQuery]); + return McpProblem.From(localizer, McpErrorCodes.EmptyQuery); } var stripped = SqlStringLiteralRegex.Replace(SqlCommentRegex.Replace(sql, " "), "''"); if (stripped.Contains(';', StringComparison.Ordinal)) { - throw new UserFriendlyException(localizer[McpErrorCodes.SingleStatementOnly]); + return McpProblem.From(localizer, McpErrorCodes.SingleStatementOnly); } var trimmed = stripped.TrimStart(); @@ -98,21 +97,25 @@ public class McpSqlGuard(IStringLocalizer localizer) : DomainS if (!startsWithRead) { - throw new UserFriendlyException(localizer[McpErrorCodes.ReadOnlyStatementRequired]); + return McpProblem.From(localizer, McpErrorCodes.ReadOnlyStatementRequired); } var forbidden = ForbiddenStatementRegex.Match(stripped); - if (forbidden.Success) - { - throw new UserFriendlyException( - localizer[McpErrorCodes.ForbiddenKeyword, forbidden.Value.ToUpperInvariant()]); - } + + return forbidden.Success + ? McpProblem.From(localizer, McpErrorCodes.ForbiddenKeyword, forbidden.Value.ToUpperInvariant()) + : null; } - public string QuoteQualifiedName(string schemaName, string objectName, DataSourceTypeEnum dataSourceType) + public McpOutcome QuoteQualifiedName(string schemaName, string objectName, DataSourceTypeEnum dataSourceType) { - EnsureSafeIdentifier(schemaName); - EnsureSafeIdentifier(objectName); + foreach (var name in (string?[])[schemaName, objectName]) + { + if (!SafeIdentifierRegex.IsMatch(name ?? string.Empty)) + { + return McpProblem.From(localizer, McpErrorCodes.InvalidIdentifier, name ?? string.Empty); + } + } return dataSourceType == DataSourceTypeEnum.Postgresql ? $"\"{schemaName}\".\"{objectName}\"" @@ -144,7 +147,7 @@ public class McpSqlGuard(IStringLocalizer localizer) : DomainS : sql; } - public Dictionary NormalizeParameters(IReadOnlyDictionary? parameters) + public McpOutcome> NormalizeParameters(IReadOnlyDictionary? parameters) { var normalized = new Dictionary(StringComparer.OrdinalIgnoreCase); if (parameters is null) @@ -156,7 +159,7 @@ public class McpSqlGuard(IStringLocalizer localizer) : DomainS { if (key is null || !SafeParameterNameRegex.IsMatch(key)) { - throw new UserFriendlyException(localizer[McpErrorCodes.InvalidParameterName, key ?? string.Empty]); + return McpProblem.From(localizer, McpErrorCodes.InvalidParameterName, key ?? string.Empty); } normalized[key.TrimStart('@')] = value!; @@ -164,12 +167,4 @@ public class McpSqlGuard(IStringLocalizer localizer) : DomainS return normalized; } - - private void EnsureSafeIdentifier(string identifier) - { - if (!SafeIdentifierRegex.IsMatch(identifier ?? string.Empty)) - { - throw new UserFriendlyException(localizer[McpErrorCodes.InvalidIdentifier, identifier ?? string.Empty]); - } - } } diff --git a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Domain/Services/McpSqlObjectWriter.cs b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Domain/Services/McpSqlObjectWriter.cs index 9fdc097f..fb28cbfb 100644 --- a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Domain/Services/McpSqlObjectWriter.cs +++ b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Domain/Services/McpSqlObjectWriter.cs @@ -1,11 +1,10 @@ -using System; +using System; using System.IO; using System.Text.RegularExpressions; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Localization; using Sozsoft.Platform.Data.Seeds; using Sozsoft.Platform.Localization; -using Volo.Abp; using Volo.Abp.Domain.Services; namespace Sozsoft.Mcp.Services; @@ -23,7 +22,7 @@ namespace Sozsoft.Mcp.Services; /// public interface IMcpSqlObjectWriter { - McpSavedSqlObject Save(string sqlServerScript, string postgreSqlScript); + McpOutcome Save(string sqlServerScript, string postgreSqlScript); } public partial class McpSqlObjectWriter( @@ -33,17 +32,19 @@ public partial class McpSqlObjectWriter( private const string SqlServerProvider = "SQL Server"; private const string PostgreSqlProvider = "PostgreSQL"; - public McpSavedSqlObject Save(string sqlServerScript, string postgreSqlScript) + public McpOutcome Save(string sqlServerScript, string postgreSqlScript) { - var sqlServer = Canonicalize(sqlServerScript, SqlServerHeaderRegex(), "CREATE OR ALTER", SqlServerProvider); - var postgreSql = Canonicalize(postgreSqlScript, PostgreSqlHeaderRegex(), "CREATE OR REPLACE", PostgreSqlProvider); + var sqlServer = Canonicalize(sqlServerScript, SqlServerHeaderRegex(), "CREATE OR ALTER"); + if (sqlServer is null) + return McpProblem.From(localizer, McpErrorCodes.SqlObjectInvalid, SqlServerProvider); - if (!string.Equals(sqlServer.Name, postgreSql.Name, StringComparison.OrdinalIgnoreCase)) - throw new UserFriendlyException(localizer[McpErrorCodes.SqlObjectInvalid, PostgreSqlProvider]); + var postgreSql = Canonicalize(postgreSqlScript, PostgreSqlHeaderRegex(), "CREATE OR REPLACE"); + if (postgreSql is null || !string.Equals(sqlServer.Name, postgreSql.Name, StringComparison.OrdinalIgnoreCase)) + return McpProblem.From(localizer, McpErrorCodes.SqlObjectInvalid, PostgreSqlProvider); // Tablo scriptinin yerine view yazilirsa migrate tabloyu bir daha kuramazdi. if (PostgreSqlTableScript.ReadSeedColumns(configuration, sqlServer.Name).Count > 0) - throw new UserFriendlyException(localizer[McpErrorCodes.TableAlreadyExists, sqlServer.Name]); + return McpProblem.From(localizer, McpErrorCodes.TableAlreadyExists, sqlServer.Name); var fileName = $"{sqlServer.Name}.sql"; var sqlServerPath = Write(SeedPathResolver.SqlFolder, fileName, sqlServer.Script); @@ -64,13 +65,13 @@ public partial class McpSqlObjectWriter( /// /// Script tek bir nesne tanimlamalidir; basligi tekrar calismaya dayanikli bicime cevrilir. - /// Yorumlar yalnizca aramada yok sayilir, dosyada kalir. + /// Yorumlar yalnizca aramada yok sayilir, dosyada kalir. Tek nesne tanimlamayan script icin null. /// - private (string Script, string Name, string Kind) Canonicalize(string script, Regex headerRegex, string createClause, string provider) + private static CanonicalScript? Canonicalize(string script, Regex headerRegex, string createClause) { var headers = string.IsNullOrWhiteSpace(script) ? null : headerRegex.Matches(CommentRegex().Replace(script, " ")); if (headers is not { Count: 1 }) - throw new UserFriendlyException(localizer[McpErrorCodes.SqlObjectInvalid, provider]); + return null; var name = headers[0].Groups["name"].Value; var kind = headers[0].Groups["kind"].Value.ToUpperInvariant() switch @@ -90,9 +91,11 @@ public partial class McpSqlObjectWriter( return $"{createClause} {kind} {m.Groups["qualified"].Value}"; }); - return (canonical + Environment.NewLine, name, kind); + return new CanonicalScript(canonical + Environment.NewLine, name, kind); } + private sealed record CanonicalScript(string Script, string Name, string Kind); + [GeneratedRegex(@"/\*.*?\*/|--[^\r\n]*", RegexOptions.Singleline)] private static partial Regex CommentRegex(); diff --git a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Domain/Services/McpTableCreator.cs b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Domain/Services/McpTableCreator.cs index 9a73ba3f..cc55c231 100644 --- a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Domain/Services/McpTableCreator.cs +++ b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Domain/Services/McpTableCreator.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -31,7 +31,7 @@ namespace Sozsoft.Mcp.Services; /// public interface IMcpTableCreator { - Task CreateAsync(McpTableDefinition definition); + Task> CreateAsync(McpTableDefinition definition); } public class McpTableCreator( @@ -99,16 +99,22 @@ public class McpTableCreator( new("DeleterId", "uniqueidentifier", null, true, null) ]; - public async Task CreateAsync(McpTableDefinition definition) + public async Task> CreateAsync(McpTableDefinition definition) { Check.NotNull(definition, nameof(definition)); var tableName = (definition.TableName ?? string.Empty).Trim(); - EnsureSafeIdentifier(tableName); + if (CheckIdentifier(tableName) is { } invalidName) + { + return invalidName; + } - var columns = BuildColumns(definition); - var indexes = BuildIndexes(tableName, definition.Indexes, columns); - var foreignKeys = BuildForeignKeys(definition.ForeignKeys, columns); + if (!BuildColumns(definition).TryGetValue(out var columns, out var problem) || + !BuildIndexes(tableName, definition.Indexes, columns).TryGetValue(out var indexes, out problem) || + !BuildForeignKeys(definition.ForeignKeys, columns).TryGetValue(out var foreignKeys, out problem)) + { + return problem; + } var context = await queryExecutor.ResolveDataSourceAsync(); var isPostgreSql = context.DataSourceType == DataSourceTypeEnum.Postgresql; @@ -127,11 +133,16 @@ public class McpTableCreator( // scriptinin ustune yazmaz. if (await TableExistsAsync(tableName) || PostgreSqlTableScript.ReadSeedColumns(configuration, tableName).Count > 0) { - throw new UserFriendlyException(localizer[McpErrorCodes.TableAlreadyExists, tableName]); + return Problem(McpErrorCodes.TableAlreadyExists, tableName); } foreach (var foreignKey in foreignKeys) - await EnsureReferenceInstalledFirstAsync(tableName, foreignKey, TableExistsAsync); + { + if (await CheckReferenceInstalledFirstAsync(tableName, foreignKey, TableExistsAsync) is { } notInstalled) + { + return notInstalled; + } + } // Dosya biçimi tasarımcınınkiyle aynı T-SQL'dir; PostgreSQL eşi ondan üretilir. var script = BuildScript(tableName, columns, indexes, foreignKeys); @@ -147,22 +158,28 @@ public class McpTableCreator( return new McpCreatedTable(tableName, script, seedFilePath); } - private List BuildColumns(McpTableDefinition definition) + private McpOutcome> BuildColumns(McpTableDefinition definition) { if (definition.Columns.Count == 0) { - throw new UserFriendlyException(localizer[McpErrorCodes.TableColumnsRequired]); + return Problem(McpErrorCodes.TableColumnsRequired); } - var userColumns = definition.Columns.Select(NormalizeColumn).ToList(); - var columns = new List { IdColumn }; if (definition.MultiTenant) { columns.Add(TenantColumn); } - columns.AddRange(userColumns); + foreach (var column in definition.Columns) + { + if (!NormalizeColumn(column).TryGetValue(out var normalized, out var problem)) + { + return problem; + } + + columns.Add(normalized); + } if (definition.FullAudited) { @@ -173,21 +190,27 @@ public class McpTableCreator( .GroupBy(column => column.Name, StringComparer.OrdinalIgnoreCase) .FirstOrDefault(group => group.Count() > 1); - if (duplicate is not null) - { - throw new UserFriendlyException(localizer[McpErrorCodes.DuplicateColumn, duplicate.Key]); - } - - return columns; + return duplicate is null ? columns : Problem(McpErrorCodes.DuplicateColumn, duplicate.Key); } /// Index ve unique kolonlari tablonun kolonlari arasinda olmalidir; ad tasarimcinin kalibindadir. - private List BuildIndexes(string tableName, IReadOnlyList indexes, List columns) + private McpOutcome> BuildIndexes(string tableName, IReadOnlyList indexes, List columns) { var result = new List(); foreach (var index in indexes) { - var indexColumns = index.Columns.Select(column => ResolveColumn(column, columns)).Distinct().ToList(); + var indexColumns = new List(); + foreach (var column in index.Columns) + { + if (!ResolveColumn(column, columns).TryGetValue(out var resolved, out var problem)) + { + return problem; + } + + if (!indexColumns.Contains(resolved)) + indexColumns.Add(resolved); + } + var name = $"{(index.Unique ? "UQ" : "IX")}_{tableName}_{string.Join("_", indexColumns)}"; // Kolonsuz ya da ayni kolonlarla tekrar eden index uretilecek bir sey tasimaz. @@ -198,16 +221,22 @@ public class McpTableCreator( return result; } - private List BuildForeignKeys(IReadOnlyList foreignKeys, List columns) + private McpOutcome> BuildForeignKeys(IReadOnlyList foreignKeys, List columns) { var result = new List(); foreach (var foreignKey in foreignKeys) { - var column = ResolveColumn(foreignKey.Column, columns); + if (!ResolveColumn(foreignKey.Column, columns).TryGetValue(out var column, out var problem)) + { + return problem; + } + var referencedTable = (foreignKey.ReferencedTable ?? string.Empty).Trim(); var referencedColumn = string.IsNullOrWhiteSpace(foreignKey.ReferencedColumn) ? IdColumn.Name : foreignKey.ReferencedColumn.Trim(); - EnsureSafeIdentifier(referencedTable); - EnsureSafeIdentifier(referencedColumn); + if ((CheckIdentifier(referencedTable) ?? CheckIdentifier(referencedColumn)) is { } invalid) + { + return invalid; + } // Tanimadigi davranis (Restrict gibi) SQL Server'in varsayilanina, NO ACTION'a duser. var action = ReferentialActions.Keys.FirstOrDefault(key => string.Equals(key, foreignKey.OnDelete?.Trim(), StringComparison.OrdinalIgnoreCase)) @@ -227,47 +256,55 @@ public class McpTableCreator( /// scripti yoksa (EF ile kurulan platform tablosu) veritabaninda bulunmalidir. Aksi halde /// sifirdan kurulumda migrate FK satirinda duserdi. /// - private async Task EnsureReferenceInstalledFirstAsync(string tableName, McpTableForeignKey foreignKey, Func> tableExistsAsync) + private async Task CheckReferenceInstalledFirstAsync(string tableName, McpTableForeignKey foreignKey, Func> tableExistsAsync) { var hasScript = PostgreSqlTableScript.ReadSeedColumns(configuration, foreignKey.ReferencedTable).Count > 0; var installed = hasScript ? Comparer.Default.Compare($"{foreignKey.ReferencedTable}.sql", $"{tableName}.sql") < 0 : await tableExistsAsync(foreignKey.ReferencedTable); - if (!installed) - { - throw new UserFriendlyException(localizer[McpErrorCodes.ForeignKeyTableNotInstalledFirst, foreignKey.Column, foreignKey.ReferencedTable, tableName]); - } + return installed + ? null + : Problem(McpErrorCodes.ForeignKeyTableNotInstalledFirst, foreignKey.Column, foreignKey.ReferencedTable, tableName); } - private string ResolveColumn(string? name, List columns) => - columns.FirstOrDefault(column => string.Equals(column.Name, name?.Trim(), StringComparison.OrdinalIgnoreCase))?.Name - ?? throw new UserFriendlyException(localizer[McpErrorCodes.TableColumnNotFound, name ?? string.Empty]); + private McpOutcome ResolveColumn(string? name, List columns) + { + var match = columns.FirstOrDefault(column => string.Equals(column.Name, name?.Trim(), StringComparison.OrdinalIgnoreCase)); - private McpTableColumn NormalizeColumn(McpTableColumn column) + return match is null ? Problem(McpErrorCodes.TableColumnNotFound, name ?? string.Empty) : match.Name; + } + + private McpOutcome NormalizeColumn(McpTableColumn column) { var name = (column.Name ?? string.Empty).Trim(); - EnsureSafeIdentifier(name); + if (CheckIdentifier(name) is { } invalidName) + { + return invalidName; + } - var (type, sizedLength) = ResolveType(column.Type); + if (!ResolveType(column.Type).TryGetValue(out var resolved, out var problem)) + { + return problem; + } int? maxLength = null; - if (type == "nvarchar") + if (resolved.Type == "nvarchar") { - maxLength = column.MaxLength ?? sizedLength ?? DefaultTextLength; + maxLength = column.MaxLength ?? resolved.MaxLength ?? DefaultTextLength; if (maxLength is < 1 or > MaxTextLength) { - throw new UserFriendlyException(localizer[McpErrorCodes.InvalidColumnLength, name, MaxTextLength]); + return Problem(McpErrorCodes.InvalidColumnLength, name, MaxTextLength); } } var defaultValue = string.IsNullOrWhiteSpace(column.DefaultValue) ? null : column.DefaultValue.Trim(); if (defaultValue is not null && !SafeDefaultRegex.IsMatch(defaultValue)) { - throw new UserFriendlyException(localizer[McpErrorCodes.InvalidDefaultValue, name, defaultValue]); + return Problem(McpErrorCodes.InvalidDefaultValue, name, defaultValue); } - return new McpTableColumn(name, type, maxLength, column.IsNullable, defaultValue); + return new McpTableColumn(name, resolved.Type, maxLength, column.IsNullable, defaultValue); } /// @@ -275,7 +312,7 @@ public class McpTableCreator( /// nvarchar(MAX) kendi tipidir; decimal yalnizca tasarimcinin kesinligiyle kabul edilir — /// baska bir kesinligi 18,4'e cevirmek sessizce veri kaybi riski tasirdi. /// - private (string Type, int? MaxLength) ResolveType(string? rawType) + private McpOutcome ResolveType(string? rawType) { var trimmed = rawType?.Trim() ?? string.Empty; var name = trimmed; @@ -305,14 +342,15 @@ public class McpTableCreator( } } - var type = SupportedTypes.FirstOrDefault(candidate => - string.Equals(candidate, name, StringComparison.OrdinalIgnoreCase)) - ?? throw new UserFriendlyException( - localizer[McpErrorCodes.UnsupportedColumnType, trimmed, string.Join(", ", SupportedTypes)]); + var type = SupportedTypes.FirstOrDefault(candidate => string.Equals(candidate, name, StringComparison.OrdinalIgnoreCase)); - return (type, maxLength); + return type is null + ? Problem(McpErrorCodes.UnsupportedColumnType, trimmed, string.Join(", ", SupportedTypes)) + : new ResolvedType(type, maxLength); } + private sealed record ResolvedType(string Type, int? MaxLength); + private static string BuildScript(string tableName, List columns, List indexes, List foreignKeys) { var fullTableName = $"[{SchemaName}].[{tableName}]"; @@ -388,13 +426,11 @@ public class McpTableCreator( return $" [{column.Name}] {type} {nullPart}{defaultPart}"; } - private void EnsureSafeIdentifier(string identifier) - { - if (!SafeIdentifierRegex.IsMatch(identifier)) - { - throw new UserFriendlyException(localizer[McpErrorCodes.InvalidIdentifier, identifier]); - } - } + private McpProblem? CheckIdentifier(string identifier) + => SafeIdentifierRegex.IsMatch(identifier) ? null : Problem(McpErrorCodes.InvalidIdentifier, identifier); + + private McpProblem Problem(string name, params object[] arguments) + => McpProblem.From(localizer, name, arguments); } /// Tam audit kolonlari (olusturma/degistirme/silme izi) eklensin mi. diff --git a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/McpHttpApiModule.cs b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/McpHttpApiModule.cs index f2dcc9f0..2951be40 100644 --- a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/McpHttpApiModule.cs +++ b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/McpHttpApiModule.cs @@ -80,9 +80,9 @@ public class McpHttpApiModule : AbpModule return result; }); - // Yetki reddi ve is kurali hatasi aracin beklenen sonucudur: firlatilirsa SDK onu - // yigin izli ERR olarak loglar. Hata sonucu (IsError) olarak donulur; model nedeni - // (eksik yetkiyi) okuyup devam eder. + // MCP'nin kendi reddi istisna degildir (McpProblem). Arkadaki platform servisinin yetki + // reddi ve is kurali hatasi ise istisnadir; firlatilirsa SDK onu yigin izli ERR olarak + // loglar. Hata sonucu (IsError) olarak donulur; model nedeni okuyup devam eder. filters.AddCallToolFilter(next => async (requestContext, cancellationToken) => { try @@ -92,7 +92,7 @@ public class McpHttpApiModule : AbpModule catch (System.Exception ex) when (McpToolErrorTranslator.CanTranslate(ex)) { var message = await requestContext.Services!.GetRequiredService().TranslateAsync(ex, requestContext.Params?.Name); - return new CallToolResult { IsError = true, Content = [new TextContentBlock { Text = message }] }; + return McpToolResult.Error(message); } }); }); diff --git a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/McpToolErrorTranslator.cs b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/McpToolErrorTranslator.cs index 7a76da7a..fad90680 100644 --- a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/McpToolErrorTranslator.cs +++ b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/McpToolErrorTranslator.cs @@ -12,10 +12,12 @@ using Volo.Abp.DependencyInjection; namespace Sozsoft.Mcp; /// -/// Arac hatalarini istemcinin gorebilecegi mesaja cevirir. SDK cevrilmemis istisnayi -/// "An error occurred invoking" olarak iletir ve model neden reddedildigini bilemez. Yetki reddinde eksik yetkiler adiyla, is kurali hatasinda -/// () zaten yerellestirilmis mesaj iletilir. Diger istisnalar -/// cevrilmez: ic ayrinti (SQL, yigin) istemciye sizmaz. +/// Aracin arkasindaki platform servislerinden (wizard, custom component, ListForm verisi) ve sinif +/// duzeyindeki [Authorize]'dan gelen istisnalari istemcinin gorebilecegi mesaja cevirir. MCP'nin +/// kendi reddi istisna degildir, olarak doner. SDK cevrilmemis istisnayi +/// "An error occurred invoking" olarak iletir ve model neden reddedildigini bilemez. Yetki reddinde +/// eksik yetkiler adiyla, is kurali hatasinda () zaten +/// yerellestirilmis mesaj iletilir. Diger istisnalar cevrilmez: ic ayrinti (SQL, yigin) istemciye sizmaz. /// public class McpToolErrorTranslator( IPermissionChecker permissionChecker, @@ -44,7 +46,7 @@ public class McpToolErrorTranslator( var grants = await permissionChecker.IsGrantedAsync([.. candidates.Distinct(StringComparer.Ordinal)]); var missing = grants.Result .Where(result => result.Value != PermissionGrantResult.Granted) - .Select(result => Describe(result.Key)) + .Select(result => McpPermissionText.Describe(localizer, result.Key)) .ToList(); var target = toolName ?? McpConsts.EndpointPath; @@ -53,14 +55,4 @@ public class McpToolErrorTranslator( ? localizer[McpErrorCodes.PermissionDeniedUnspecified, target] : localizer[McpErrorCodes.PermissionDenied, target, string.Join(", ", missing)]; } - - /// Yetkinin gorunen adi ve kodu; rol ekraninda aranabilsin diye kod her zaman yazilir. - private string Describe(string permissionName) - { - var displayName = localizer[permissionName]; - - return displayName.ResourceNotFound || displayName.Value == permissionName - ? permissionName - : $"{displayName.Value} ({permissionName})"; - } } diff --git a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/Tools/McpDataTools.cs b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/Tools/McpDataTools.cs index 559d440e..f3dccd61 100644 --- a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/Tools/McpDataTools.cs +++ b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/Tools/McpDataTools.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.Threading.Tasks; +using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Sozsoft.Mcp.Dtos; @@ -16,43 +17,43 @@ public sealed class McpDataTools(IMcpDataAppService dataAppService) { [McpServerTool(Name = McpToolNames.InsertRow, Destructive = false, Idempotent = false)] [Description("Bir listeye yeni kayit ekler. Alan adlari describe_list cikisindaki field degerleridir.")] - public async Task InsertRowAsync( + public async Task InsertRowAsync( [Description("Kaydin eklenecegi listenin kodu.")] string listFormCode, [Description("Alan adi -> deger eslemesi.")] Dictionary values) - => await dataAppService.InsertRowAsync(new McpRowRequestDto + => McpToolResult.From(await dataAppService.InsertRowAsync(new McpRowRequestDto { ListFormCode = listFormCode, Values = values ?? [] - }); + })); [McpServerTool(Name = McpToolNames.UpdateRow, Destructive = true, Idempotent = true)] [Description("Bir listedeki kaydi anahtar degerine gore gunceller. Yalnizca verilen alanlar yazilir.")] - public async Task UpdateRowAsync( + public async Task UpdateRowAsync( [Description("Kaydin bulundugu listenin kodu.")] string listFormCode, [Description("Guncellenecek kaydin anahtar alan degeri; describe_list cikisindaki keyFieldName alanina karsilik gelir.")] List keys, [Description("Alan adi -> yeni deger eslemesi.")] Dictionary values) - => await dataAppService.UpdateRowAsync(new McpRowRequestDto + => McpToolResult.From(await dataAppService.UpdateRowAsync(new McpRowRequestDto { ListFormCode = listFormCode, Keys = keys ?? [], Values = values ?? [] - }); + })); [McpServerTool(Name = McpToolNames.DeleteRow, Destructive = true, Idempotent = true)] [Description("Bir listedeki kaydi anahtar degerine gore siler. Ekranin kendi silme komutu kullanilir; soft delete tasiyan tablolarda kayit pasife cekilir.")] - public async Task DeleteRowAsync( + public async Task DeleteRowAsync( [Description("Kaydin bulundugu listenin kodu.")] string listFormCode, [Description("Silinecek kaydin anahtar alan degeri.")] List keys) - => await dataAppService.DeleteRowAsync(new McpRowRequestDto + => McpToolResult.From(await dataAppService.DeleteRowAsync(new McpRowRequestDto { ListFormCode = listFormCode, Keys = keys ?? [] - }); + })); } diff --git a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/Tools/McpDevelopTools.cs b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/Tools/McpDevelopTools.cs index c9d1badb..90f5aed5 100644 --- a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/Tools/McpDevelopTools.cs +++ b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/Tools/McpDevelopTools.cs @@ -30,16 +30,16 @@ public sealed class McpDevelopTools(IMcpDevelopAppService developAppService, IMc [McpServerTool(Name = McpToolNames.GetDevGuide, ReadOnly = true)] [Description("Ekran ve bilesen uretim sozlesmesini okur: wizard alanlari, enum degerleri, editor/lookup/script sozlesmesi, custom component kurallari. section bos ise baslik listesi doner; once listeyi al, sonra ihtiyac duydugun bolumu numarasiyla iste (ornegin 4.2). Rehberde dosya yazmak anlatilan yerlerde dosya yerine bu sunucunun araclarini kullan.")] - public async Task GetDevGuideAsync( + public async Task GetDevGuideAsync( [Description("'lowcode' (uretim protokolu ve artefakt semalari, varsayilan) ya da 'reference' (JSON kolon sozlesmeleri, tasarimci envanteri).")] string? document = null, [Description("Bolum numarasi (4.2), baslik metninin bir parcasi ya da aranacak terim (dxImageUpload). Baslikla eslesmeyen terimde terimin gectigi bolumler listelenir; bos birakilirsa baslik listesi doner.")] string? section = null) - => await developAppService.GetDevGuideAsync(document, section); + => McpToolResult.From(await developAppService.GetDevGuideAsync(document, section)); [McpServerTool(Name = McpToolNames.CreateTable, Destructive = false, Idempotent = false)] [Description("Ekranin baglanacagi yeni tablonun script'ini SQL Query Manager tablo tasarimcisiyla ayni bicimde uretir ve seed dosyasina (sql/object + postgres/object) yazar; veritabaninda calistirmaz, tabloyu kullanicinin calistirdigi DB Migrate kurar. DB Migrate'i bekleme ve calistirmaya calisma; deploy_wizard kolonlari script'ten okur. Id (uniqueidentifier, PK), TenantId ve tam audit kolonlari (CreationTime, CreatorId, LastModificationTime, LastModifierId, IsDeleted, DeletionTime, DeleterId) otomatik eklenir; columns icine yalnizca is kolonlarini yaz. indexes ile index/unique, foreignKeys ile FK kisitlari ayni scripte yazilir. Yalnizca yeni tablo icindir: tablo ya da script'i varsa reddeder, kolon ekleme/degistirme tablo tasarimcisindan yapilir. View/procedure/function icin save_sql_object kullan. Ardindan deploy_wizard ile ekrani kur.")] - public async Task CreateTableAsync( + public async Task CreateTableAsync( [Description("Tablo adi; harf/rakam/alt cizgi. Adlandirma icin get_dev_guide'daki tablo onek kuralina uy (ornegin Adm_T_...).")] string tableName, [Description("Is kolonlari. type: nvarchar, nvarchar(MAX), int, bigint, decimal (her zaman 18,4 yazilir; boyut verme), float, bit, datetime, datetime2, date, uniqueidentifier, money. maxLength yalnizca nvarchar icin (varsayilan 100). defaultValue: sayi, 'metin' ya da NEWID()/GETDATE()/GETUTCDATE()/SYSUTCDATETIME(). Sabit defaultValue ekranda yeni kaydin form degeri olur (deploy tablodan okur); bu yuzden IsActive bit isNullable false defaultValue \"1\", olcu birimi (UomId) defaultValue \"'Adet'\", para birimi (CurrencyId) defaultValue \"'TRY'\" ve talepteki her sabit varsayilan (Sure = 60 → \"60\") kolona yazilir (get_dev_guide 4.3.5.1).")] @@ -52,7 +52,7 @@ public sealed class McpDevelopTools(IMcpDevelopAppService developAppService, IMc List? indexes = null, [Description("FK'ler: [{\"column\": \"UomId\", \"referencedTable\": \"Adm_T_Uom\", \"referencedColumn\": \"Id\", \"onDelete\": \"NoAction\"}]. onDelete: NoAction (varsayilan), Cascade, SetNull. Migrate scriptleri dosya adi sirasiyla calistirdigi icin hedef tablo ya scripti bu tablonunkinden once siralanan bir seed tablosu ya da veritabaninda kurulu bir platform tablosu olmalidir; degilse reddedilir.")] List? foreignKeys = null) - => await developAppService.CreateTableAsync(new McpCreateTableRequestDto + => McpToolResult.From(await developAppService.CreateTableAsync(new McpCreateTableRequestDto { TableName = tableName, Columns = columns, @@ -60,134 +60,128 @@ public sealed class McpDevelopTools(IMcpDevelopAppService developAppService, IMc MultiTenant = multiTenant, Indexes = indexes ?? [], ForeignKeys = foreignKeys ?? [] - }); + })); [McpServerTool(Name = McpToolNames.SaveSqlObject, Destructive = true, Idempotent = true)] [Description("Ekranin veri kaynagi ya da lookup'i olacak view, stored procedure ya da function scriptini SQL Query Manager'in yazdigi yere (sql/object/{Ad}.sql) ve PostgreSQL esine (postgres/object/{Ad}.sql) yazar; ad scriptteki nesne adidir; veritabaninda calistirmaz, nesneyi kullanicinin calistirdigi DB Migrate kurar. Uygulama iki saglayicida calistigi icin iki script de zorunludur ve ayni nesneyi ayni parametre sirasiyla tanimlar (SQL Server: [dbo], PostgreSQL: public, ceviri kurallari get_dev_guide lowcode 8.3.1). Baslik tekrar calismaya dayanikli bicime cevrilir (CREATE OR ALTER / CREATE OR REPLACE); ayni adla tekrar cagrilirsa script guncellenir. Migrate object scriptlerini dosya adi sirasiyla calistirir: view'in okudugu tablolarin adi view'in adindan once siralanmalidir (Inv_T_Product → Inv_V_Product). Birden fazla tablodan beslenen ekranda Query (SelectCommandType 4) yerine bu aracla view yazip ekrani view'e (SelectCommandType 2) bagla. Tablo icin create_table kullan.")] - public async Task SaveSqlObjectAsync( + public async Task SaveSqlObjectAsync( [Description("SQL Server scripti: tek bir CREATE [OR ALTER] VIEW/PROCEDURE/FUNCTION [dbo].[Ad] tanimi; GO ile biten batch'ler kullanilabilir. Dosya adi nesnenin adidir.")] string sqlServerScript, [Description("PostgreSQL scripti: ayni nesnenin CREATE OR REPLACE VIEW/FUNCTION/PROCEDURE public.\"Ad\" tanimi.")] string postgreSqlScript) - => await developAppService.SaveSqlObjectAsync(sqlServerScript, postgreSqlScript); + => McpToolResult.From(await developAppService.SaveSqlObjectAsync(sqlServerScript, postgreSqlScript)); [McpServerTool(Name = McpToolNames.GenerateCrudEndpoints, Destructive = true, Idempotent = true)] [Description("Bir tablo ya da view icin SQL Query Manager'daki CRUD endpoint diyaloguyla ayni REST endpoint'lerini (/api/app/crudendpoint/{entity}: GetList, GetById, Create, Update, Delete) yalnizca crud/{entity}.json seed dosyasina yazar; kayitlari DB Migrate ekler (migrate'i bekleme). Ayni entity icin tekrar cagrilirsa dosya yeniden yazilir. Custom component'in veri okuyup yazacagi tablo icin kullanilir; ListForm ekranlari bu endpoint'lere ihtiyac duymaz. View/procedure/function icin yalnizca GetList ve GetById uretilir. App.SqlQueryManager.CrudEndpoints yetkisi ister.")] - public async Task GenerateCrudEndpointsAsync( + public async Task GenerateCrudEndpointsAsync( [Description("Tablo ya da view adi, ornegin 'Inv_T_Product' (sema oneki atilir).")] string entityName, [Description("Aktif baslayacak operasyonlar, virgulle: GetList,GetById,Create,Update,Delete. Bos ise hepsi aktif; digerleri pasif kaydedilir.")] string? operationTypes = null, [Description("Nesne turu: table (varsayilan), view, procedure, function.")] string? objectType = null) - => await developAppService.GenerateCrudEndpointsAsync(entityName, operationTypes, objectType); + => McpToolResult.From(await developAppService.GenerateCrudEndpointsAsync(entityName, operationTypes, objectType)); [McpServerTool(Name = McpToolNames.ListWizards, ReadOnly = true)] [Description("Wizard File Manager'daki wizard dosyalarini listeler: dosya adi, ekran kodu, yol (List/Custom), menu yolu ve bagimli wizard'lar.")] - public async Task> ListWizardsAsync( + public async Task ListWizardsAsync( [Description("Dosya adi, wizard adi, ekran kodu ya da bilesen adi icinde aranacak metin. Bos birakilabilir.")] string? search = null) - => await developAppService.GetWizardsAsync(search); + => McpToolResult.From(await developAppService.GetWizardsAsync(search)); [McpServerTool(Name = McpToolNames.GetWizard, ReadOnly = true)] [Description("Bir wizard seed dosyasini diskteki bicimiyle dondurur (Wizard / ListForm / Fields bolumleri). Var olan bir ekrani degistirmeden once oku.")] - public async Task GetWizardAsync( + public async Task GetWizardAsync( [Description("list_wizards cikisindaki fileName degeri, ornegin 'Tags.json'.")] string fileName) - => await developAppService.GetWizardAsync(fileName); + => McpToolResult.From(await developAppService.GetWizardAsync(fileName)); [McpServerTool(Name = McpToolNames.DeployWizard, Destructive = true, Idempotent = false)] [Description("Wizard'i deploy eder: ListForm ekrani (ComponentKind 0) ya da bir custom component'i menuye baglayan kayit (ComponentKind 1), yetkileri, dil anahtarlari ve menusu seed dosyalarina yazilir. Deploy veritabanina yazmaz: ekran, menu ve yetkiler kullanici Wizard Manager'da DB Migrate calistirinca olusur; cikistaki isPending true iken ekran henuz yoktur; migrate'i bekleme, isini bitir ve kullaniciya yalnizca DB Migrate calistirmasini soyle. Eksik modul, ust menu, izin grubu, yetki ve dil satirlari da deploy'da dosyalara yazilir ve migrate onlari dogru sirayla ekler; bunlarin veritabaninda olmamasi beklenen durumdur, uyari olarak raporlama ve kullanicidan elle eklemesini isteme. Ust menu zinciri istenen derinlikte kurulur: MenuParentCode ekranin dogrudan ust menusu, Menu.MenuParentPath onun ustundeki seviyeler (kokten asagi, her biri {Code, Icon, En, Tr}); eksik her seviye bir ustunun altina kurulur, var olana baglanilir. Liste ekraninda Grid, Card, Pivot ve Chart gorunumleri varsayilan aciktir (DefaultLayout grid); kullanici bir gorunumu istemedigini soylemedikce bu bayraklari false yazma. Hedef tablo onceden var olmalidir; yoksa once create_table ile olustur. Istek ornek/demo veri tasiyorsa (prompt'taki tablo/liste ya da ekran goruntusunde gorunen satirlar) SeedData true yaz ve satirlarin hepsini ayni cagrida seedRows ile gonder: satirlar ekranin data/{ListFormCode}.json dosyasina yazilir, migrate ekrani kurduktan sonra ekler. SeedData ekrandan girilen kayitlari dosyaya yansitmaz (SeedSyncInsert/Update/Delete kapali); bunu yalnizca istek acikca isterse SeedSync true yaz. Satirlar icin migrate'i bekleyip insert_row kullanma. replaceFileName verilirse o wizard'in dosyalari yeniden yazilir, onceki kayitlarin yerini migrate'te yenileri alir. Wizard cevaplarinin uretmedigi ekran ayarlari (FormFieldsDefaultValueDto/InsertFieldsDefaultValueDto gibi varsayilan degerler, ValidationRuleDto, Format, Alignment, Width, Visible, BandName, ozet/gruplama, ExtraFilter, Insert/Update/Delete komutlari, PageSize, CommandColumnDto …) ayni JSON'un ListForm ve Fields bolumlerine get_wizard ciktisindaki adlarla yazilir. Yeni kaydin form degeri (Aktif = true, Birim = Adet, Kur = TRY, Sure = 60) ayri yazilmaz: deploy kolonun tablodaki sabit DEFAULT degerini okur; tablodan farkli deger gerekiyorsa alan ogesinde DefaultValue yaz (Groups[].Items[].DefaultValue). StaticData lookup seceneklerinde (RadioGroup, SelectBox, TagBox) name dil anahtaridir (App.StaticLookup.{Key}); EN + TR metni bilinen secenegi Language.Texts'te ver, duz metin yazilirsa deploy onu anahtara cevirip TR'yi metinden yazar (get_dev_guide 4.3.4.1). EditorScript'i elle yazma: Script Builder kurallarini ({v:1, rules:[...]}, get_dev_guide 5.3) ver, deploy kodu Script Builder dialogu ile birebir ayni uretir; govde yazsan da atilir. Onay kutusu alanina setValue text 'true'/'false' (0/1 cevrilir). set ile yazilan alanin kurallari da sirayla calisir (derinlik siniri yok); A->B->A dongusu kuran kural yazma, calisma aninda hata olarak gosterilir. Wizard girdisinde taninmayan anahtar adiyla reddedilir, baska adla tekrar deneme; bolumler kismi olabilir (yalnizca degisen anahtarlar), Fields ogeleri FieldName ile eslesir, *Json adlari (FormFieldsDefaultValueJson) *Dto karsiligina cevrilir. Guncellemede get_wizard ciktisini butunuyle degistirip gonder: tasarimci ayarlari korunur; Wizard bolumunde yazdigin cevap ayni ayarin ListForm'daki eski degerine ustun gelir. Kimlik alanlari (ListFormCode, Name, Title, Description, PermissionDto, FieldName) wizard'dan gelir. Fields'taki her alan Wizard Groups'ta da bulunmalidir; tanınmayan ya da hesaplanan bir anahtar reddedilir. Liste ekraninda DescTr ve DescEn ayrintili olmali (en az 3 satir, 150 karakter): ekran basligindaki bilgi simgesinde gosterilir; ne ise yaradigi, menude nerede ya da hangi ekranin sekmesi oldugu, ana alanlar, otomatik hesaplar ve iliskili ekranlar yazilir (get_dev_guide 4.4.2); kisa aciklama reddedilir. Deploy modulun dokumanini (docs/modules) acar ya da envanterini yeniler; elle yazilan bolumleri save_module_doc ile doldur. Ne yazacagini get_dev_guide 4. bolumunden ogren.")] - public async Task DeployWizardAsync( + public async Task DeployWizardAsync( [Description("Wizard JSON'u: seed dosyasi bicimi ({\"Wizard\": {...}, \"ListForm\": {...}, \"Fields\": [...]}, get_wizard cikisi gibi) ya da duz wizard cevaplari. ListForm/Fields istege baglidir ve kismi olabilir. Enum alanlari sayi olarak yazilir.")] string wizard, [Description("Guncellemede yerine gecilecek dosyanin adi (list_wizards cikisindaki fileName). Yeni wizard icin bos birak.")] string? replaceFileName = null, [Description("Demo satirlar: JSON nesne dizisi, anahtarlar ekranin alan adlari ([{\"Name\": \"...\"}]). Guid anahtar, TenantId ve audit alanlarini yazma; sunucu ve migrate doldurur. Yalnizca SeedData true olan liste ekraninda.")] string? seedRows = null) - => await developAppService.DeployWizardAsync(new McpWizardDeployRequestDto { Wizard = wizard, ReplaceFileName = replaceFileName, SeedRows = seedRows }); + => McpToolResult.From(await developAppService.DeployWizardAsync(new McpWizardDeployRequestDto { Wizard = wizard, ReplaceFileName = replaceFileName, SeedRows = seedRows })); [McpServerTool(Name = McpToolNames.DeleteWizard, Destructive = true, Idempotent = true)] [Description("Bir wizard'i ve yalnizca onun urettigi kayitlari siler; baska yerde kullanilan kayitlar korunur ve raporda gosterilir. Ekranin veri aynasi (data/{ListFormCode}.json) ekranla birlikte gider. Bagimli wizard'i olan bir wizard silinemez, once bagimlilar silinir. Ekranin tablosu varsayilan olarak kalir; dropTables true ise tablo DROP edilir, CRUD endpoint'leri ve sql/object + crud seed dosyalari da silinir. Tablo yalnizca create_table ya da tablo tasarimcisiyla olusturulmussa (sql/object scripti varsa) ve baska ekran, Custom Endpoint ya da bilesen kullanmiyorsa dusurulur; aksi halde raporda neden korundugu yazar. Tablo silmek geri alinamaz ve App.SqlQueryManager yetkisi ister: dropTables true gondermeden once kullaniciya tablonun ve icindeki verinin silinecegini soyle ve acik onay al.")] - public async Task DeleteWizardAsync( + public async Task DeleteWizardAsync( [Description("Silinecek wizard dosyasinin adi (list_wizards cikisindaki fileName).")] string fileName, [Description("Ekranin tablosu da dusurulsun mu. Yalnizca kullanici acikca onayladiysa true.")] bool dropTables = false) - => await developAppService.DeleteWizardAsync(fileName, dropTables); + => McpToolResult.From(await developAppService.DeleteWizardAsync(fileName, dropTables)); [McpServerTool(Name = McpToolNames.VerifyWizard, ReadOnly = true)] [Description("Deploy edilip DB Migrate ile kurulmus bir wizard'i veri yazmadan dogrular (migrate bekleyen wizard'da install kontrolu basarisiz doner ve diger kontroller yapilmaz): menu kaydi (var mi, etkin mi, URL'si wizard'la ayni mi), menunun istedigi yetki tanimi, liste ekraninda ekranin sorgusu (cagiranin yetkisiyle tek satir), Custom yolunda bilesen (var mi, etkin mi, rotasi menuyle ayni mi) ve seed kapanisi (migrate ya da baska ortamda eksik kalacak bagimlilik). passed yalnizca blocking kontrollerden biri kalirsa false olur; seed_closure engellemez ama raporlanmalidir. Istege baglidir: kullanici migrate sonrasi dogrulama isterse cagir; gelistirme talebi dongusunun adimi degildir.")] - public async Task VerifyWizardAsync( + public async Task VerifyWizardAsync( [Description("Dogrulanacak wizard dosyasinin adi (list_wizards ya da deploy_wizard cikisindaki fileName).")] string fileName) - => await developAppService.VerifyWizardAsync(fileName); + => McpToolResult.From(await developAppService.VerifyWizardAsync(fileName)); [McpServerTool(Name = McpToolNames.StartDevRequest, Destructive = false, Idempotent = false)] [Description("App.DevRequests kuyrugundaki bir gelistirme talebini ustlenir: talep Ready to Start durumundaysa In Progress'e alir ve talebin metnini (baslik, HTML aciklama, onaylanmis HTML analiz, oncelik, etiketler, SubTasks) dondurur; baska bir durumdaysa reddeder. Aciklamaya yapistirilmis gorseller ayri resim icerigi olarak gelir, aciklamada yerlerinde [image-N] isareti durur; gorseldeki ekran duzeni ve satirlar talebin parcasidir (demo veri icin deploy_wizard SeedData). Uretim aciklama ile analize birlikte dayanir; analizdeki menu, tablo ve kapsam kararlari onaylanmistir. Bir talep uzerinde calismaya bu aracla baslanir, Status'u update_row ile In Progress yapma. Sonraki adimlar icin get_dev_guide lowcode 0.11.")] - public async Task> StartDevRequestAsync( + public async Task StartDevRequestAsync( [Description("Talebin Id'si (GUID) ya da tam basligi. Ayni basligi tasiyan birden fazla talep varsa Id ver.")] string request) - => ToContent(await developAppService.StartDevRequestAsync(request)); + => McpToolResult.From(await developAppService.StartDevRequestAsync(request), ToContent); [McpServerTool(Name = McpToolNames.GetDevRequest, ReadOnly = true)] [Description("Bir gelistirme talebini durumuna dokunmadan okur; start_dev_request ile ayni cevabi (aciklama + ayri resim icerikleri) dondurur. Analiz oncesi talebi okumak, yarim kalmis (In Progress/Testing) bir talebe donmek ya da gorselleri yeniden gormek icin kullan; talebi ustlenmek icin degil.")] - public async Task> GetDevRequestAsync( + public async Task GetDevRequestAsync( [Description("Talebin Id'si (GUID) ya da tam basligi.")] string request) - => ToContent(await developAppService.GetDevRequestAsync(request)); + => McpToolResult.From(await developAppService.GetDevRequestAsync(request), ToContent); [McpServerTool(Name = McpToolNames.SaveDevRequestAnalysis, Destructive = true, Idempotent = true)] [Description("Backlog, Analysis ya da Ready to Start durumundaki bir talebin analizini (HTML) Analysis alanina yazar; var olan analiz degistirilir, Description'a dokunulmaz. productionReady true ise talep Ready to Start'a, false ise Analysis durumuna alinir. Diger durumlarda reddeder. Once get_dev_request ile talebi ve gorsellerini oku; analizin bolumleri ve standardi get_dev_guide lowcode 0.12'dedir.")] - public async Task> SaveDevRequestAnalysisAsync( + public async Task SaveDevRequestAnalysisAsync( [Description("Talebin Id'si (GUID) ya da tam basligi.")] string request, [Description("Analizin tamami, HTML.")] string html, [Description("Analizin 'Uretime hazir' satiri kosulsuz Evet ise true; sartli (onay bekleyen, 'Evet, ancak …') ya da Hayir ise false. HTML'deki satirla ayni olmak zorundadir.")] bool productionReady) - => ToContent(await developAppService.SaveDevRequestAnalysisAsync(request, html, productionReady)); + => McpToolResult.From(await developAppService.SaveDevRequestAnalysisAsync(request, html, productionReady), ToContent); [McpServerTool(Name = McpToolNames.AppendDevRequestNote, Destructive = false, Idempotent = false)] [Description("Talebin Analysis alaninin sonuna HTML ekler; var olan analize ve Description'a dokunmaz. Description talebin yazildigi haliyle kalir; uretim sonuc notunu (

MCP

+ uretilenler, varsayimlar ve DB Migrate bekleyen wizard'lar) bu aracla ekle. Description'i ya da Analysis'i update_row ile yazma.")] - public async Task AppendDevRequestNoteAsync( + public async Task AppendDevRequestNoteAsync( [Description("Talebin Id'si (GUID) ya da tam basligi.")] string request, [Description("Eklenecek HTML parcasi.")] string html) - { - await developAppService.AppendDevRequestNoteAsync(request, html); - return "ok"; - } + => McpToolResult.From(await developAppService.AppendDevRequestNoteAsync(request, html)); [McpServerTool(Name = McpToolNames.AddDevRequestSubTasks, Destructive = false, Idempotent = false)] [Description("Talebin Sub Tasks listesine uretilen her artefakt icin tamamlanmis bir satir ekler; atanan kisi baglanan kullanici, bitis tarihi bugun olarak sunucuda yazilir, var olan satirlar korunur. SubTasks alanini update_row ile yazma.")] - public async Task AddDevRequestSubTasksAsync( + public async Task AddDevRequestSubTasksAsync( [Description("Talebin Id'si (GUID) ya da tam basligi.")] string request, [Description("Eklenecek satir basliklari, ornegin [\"create_table: Hr_T_Certificate\", \"deploy_wizard: Certificates.json\"].")] List titles) - { - await developAppService.AddDevRequestSubTasksAsync(request, titles); - return "ok"; - } + => McpToolResult.From(await developAppService.AddDevRequestSubTasksAsync(request, titles)); [McpServerTool(Name = McpToolNames.GetModuleDoc, ReadOnly = true)] [Description("Modul dokumanini (docs/modules/{Menu}.md, seed klasoru; kok menu basina tek dosya, adi kok menu kodu: App.Hr, App.Prc, App.Administration) okur: nasil calistigi, analiz/kararlar, ekranlar, hesaplar, yetkiler ve otomatik 'Ek — Ekran envanteri'. Menunun altindaki her dal dosyada bir bolumdur; ekranin ya da dalin kodu (App.Hr.PerformanceReview, App.Hr.Performance) ya da wizard dosya adi verilirse yalnizca o bolum ve envanteri doner, dosya adi verilirse dosyanin tamami. isComplete false ise missingSections doldurulmamis alt bolumlerdir. 'Bu modul/ekran nasil calisiyor?' sorusunda once bunu ekran koduyla oku; deploy_wizard dosyayi ve dalin bolumunu yoksa kendisi acar. Menude henuz olmayan yeni modulun kodu (App.Inventory, App.Inventory.Stock) hata vermez: exists false ve doldurulacak iskelet doner; save_module_doc ayni kodla yazinca dosya acilir.")] - public async Task GetModuleDocAsync( + public async Task GetModuleDocAsync( [Description("Ekran ya da dal kodu, wizard dosya adi (o bolum) veya kok menu kodu (App.Hr; dosyanin tamami).")] string name) - => await developAppService.GetModuleDocAsync(name); + => McpToolResult.From(await developAppService.GetModuleDocAsync(name)); [McpServerTool(Name = McpToolNames.SaveModuleDoc, Destructive = true, Idempotent = true)] [Description("Modul dokumaninin elle yazilan bolumunu kaydeder (Markdown). Uretimin parcasidir: deploy_wizard'dan sonra get_module_doc ile ekranin bolumunu oku, module-doc:todo isaretli her alt bolumu (Amac ve kapsam, Analiz ve kararlar — onaylanmis analiz ve kararlar, Menu ve ekranlar, Veri modeli, Is kurallari ve hesaplar, Yetki ve gorunurluk, Entegrasyonlar, Akis ornegi, Sik sorulanlar, Dosyalar ve geri alma) doldurup isaretleri kaldirarak ayni ekran ya da dal koduyla gonder: yalnizca o bolum yazilir, diger dallara dokunulmaz. Bos kalacak alt bolume 'Yok.' yaz. 'Ek — Ekran envanteri' bolumunu gonderme, sunucu uretir. Bolumun '' beyan satirini gondermezsen mevcut beyan korunur. Kok menu koduyla (App.Hr) cagrilirsa dosyanin tamami yazilir. Talep, bolumu tamamlanmadan update_row ile Testing'e alinamaz. Bolum standardi get_dev_guide lowcode 0.13.")] - public async Task SaveModuleDocAsync( + public async Task SaveModuleDocAsync( [Description("Ekran ya da dal kodu, wizard dosya adi (o bolum) veya kok menu kodu (App.Hr; dosyanin tamami).")] string name, [Description("Bolumun (ya da dosya adi verildiyse dosyanin) tamami, Markdown.")] string content) - => await developAppService.SaveModuleDocAsync(name, content); + => McpToolResult.From(await developAppService.SaveModuleDocAsync(name, content)); /// /// Talep metni JSON olarak, gorseller ayri resim icerigi olarak doner; model resmi ancak bu @@ -233,21 +227,21 @@ public sealed class McpDevelopTools(IMcpDevelopAppService developAppService, IMc [McpServerTool(Name = McpToolNames.ListComponents, ReadOnly = true)] [Description("Custom component'leri ad, rota ve aciklamasiyla listeler; kod dondurmez. isVisualDesigner true olanlarin kodu tasarimci belgesinden uretilir ve MCP ile degistirilemez.")] - public async Task> ListComponentsAsync( + public async Task ListComponentsAsync( [Description("Ad, rota ya da aciklama icinde aranacak metin. Bos birakilabilir.")] string? search = null) - => await developAppService.GetComponentsAsync(search); + => McpToolResult.From(await developAppService.GetComponentsAsync(search)); [McpServerTool(Name = McpToolNames.GetComponent, ReadOnly = true)] [Description("Bir custom component'in kodunu, props'unu ve bagimliliklarini dondurur. Degistirmeden once oku.")] - public async Task GetComponentAsync( + public async Task GetComponentAsync( [Description("Bilesenin adi.")] string name) - => await developAppService.GetComponentAsync(name); + => McpToolResult.From(await developAppService.GetComponentAsync(name)); [McpServerTool(Name = McpToolNames.SaveComponent, Destructive = true, Idempotent = true)] [Description("Custom component'i ada gore kaydeder: yoksa ekler, varsa gunceller. Guncellemede verilmeyen alanlar korunur. Kod kurallari ve kullanilabilir kutuphaneler icin get_dev_guide 7. bolumu. Bilesen menude gorunecekse ardindan ComponentKind 1 ile deploy_wizard cagir.")] - public async Task SaveComponentAsync( + public async Task SaveComponentAsync( [Description("Bilesenin adi; kimligidir.")] string name, [Description("Bilesenin React kodu. Yeni bilesende zorunlu, guncellemede bos birakilirsa mevcut kod korunur.")] @@ -262,7 +256,7 @@ public sealed class McpDevelopTools(IMcpDevelopAppService developAppService, IMc string? dependencies = null, [Description("Bilesen etkin mi. Yeni bilesende varsayilan true.")] bool? isActive = null) - => await developAppService.SaveComponentAsync(new McpComponentSaveRequestDto + => McpToolResult.From(await developAppService.SaveComponentAsync(new McpComponentSaveRequestDto { Name = name, Code = code, @@ -271,12 +265,12 @@ public sealed class McpDevelopTools(IMcpDevelopAppService developAppService, IMc Props = props, Dependencies = dependencies, IsActive = isActive - }); + })); [McpServerTool(Name = McpToolNames.DeleteComponent, Destructive = true, Idempotent = false)] [Description("Custom component'i siler. Bilesene bagli bir Custom wizard varsa once delete_wizard ile o silinir.")] - public async Task DeleteComponentAsync( + public async Task DeleteComponentAsync( [Description("Bilesenin adi.")] string name) - => await developAppService.DeleteComponentAsync(name); + => McpToolResult.From(await developAppService.DeleteComponentAsync(name)); } diff --git a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/Tools/McpQueryTools.cs b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/Tools/McpQueryTools.cs index 8111563e..f5756e41 100644 --- a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/Tools/McpQueryTools.cs +++ b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/Tools/McpQueryTools.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.Threading.Tasks; +using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Sozsoft.Mcp.Dtos; @@ -12,7 +13,7 @@ public sealed class McpQueryTools(IMcpQueryAppService queryAppService) { [McpServerTool(Name = McpToolNames.QueryList, ReadOnly = true)] [Description("Bir listenin verisini uygulamadaki ekranla ayni sorguyla okur; kullanicinin liste ve alan yetkileri, kiraci ve sube filtreleri gecerlidir. Veri okumak icin once bunu kullan. Alan adlari describe_list cikisindaki field degerleridir. Filtre/siralama/gruplama DevExtreme bicimindedir.")] - public async Task QueryListAsync( + public async Task QueryListAsync( [Description("Listenin kodu; list_lists cikisindaki listFormCode degeri.")] string listFormCode, [Description("Filtre, JSON metni. Ornek: [\"Status\",\"=\",\"Open\"] ya da [[\"Amount\",\">\",100],\"and\",[\"Name\",\"contains\",\"abc\"]]. Operatorler: =, <>, >, >=, <, <=, contains, notcontains, startswith, endswith.")] @@ -29,7 +30,7 @@ public sealed class McpQueryTools(IMcpQueryAppService queryAppService) int skip = 0, [Description("Istenen kayit sayisi; sunucu kaydindaki tavandan buyuk olamaz.")] int take = 0) - => await queryAppService.QueryListAsync(new McpListQueryRequestDto + => McpToolResult.From(await queryAppService.QueryListAsync(new McpListQueryRequestDto { ListFormCode = listFormCode, Filter = filter, @@ -39,34 +40,34 @@ public sealed class McpQueryTools(IMcpQueryAppService queryAppService) TotalSummary = totalSummary, Skip = skip, Take = take - }); + })); [McpServerTool(Name = McpToolNames.RunSelect, ReadOnly = true)] [Description("Yalnizca uygulamada SQL Query Manager yetkisi olan kullanicilar icin: tek bir parametreli SELECT ifadesi kosturur. Liste verisi icin query_list tercih edilir. Coklu ifade, DDL ve DML reddedilir; satir sayisi sunucu kaydindaki tavana kirpilir. Sorguya deger gomme, @ad parametresi kullan. Kolonun var oldugunu varsayma: IsDeleted, TenantId, IsActive her tabloda yoktur; emin degilsen once INFORMATION_SCHEMA.COLUMNS (PostgreSQL: information_schema.columns) uzerinden tablonun kolonlarini oku, filtreyi yalnizca var olan kolona yaz.")] - public async Task RunSelectAsync( + public async Task RunSelectAsync( [Description("Tek bir SELECT (ya da WITH ... SELECT) ifadesi. Degerler @ad bicimindeki parametrelerle verilir.")] string sql, [Description("Sorgudaki adli parametreler: anahtar parametre adi, deger ise ham JSON degeri.")] Dictionary? parameters = null, [Description("Istenen en fazla satir sayisi; sunucu kaydindaki tavandan buyuk olamaz.")] int maxRows = 0) - => await queryAppService.RunSelectAsync(new McpSelectRequestDto + => McpToolResult.From(await queryAppService.RunSelectAsync(new McpSelectRequestDto { Sql = sql, Parameters = parameters ?? [], MaxRows = maxRows - }); + })); [McpServerTool(Name = McpToolNames.ListQueries, ReadOnly = true)] [Description("Veri kaynagindaki kayitli nesneleri (view, stored procedure, function) ve cagrilabilir Custom Endpoint'leri dondurur. run_query bunlardan birini cagirir.")] - public async Task> ListQueriesAsync( + public async Task ListQueriesAsync( [Description("Nesne adi ya da aciklamasi icinde aranacak metin. Bos birakilabilir.")] string? search = null) - => await queryAppService.GetSavedQueriesAsync(search); + => McpToolResult.From(await queryAppService.GetSavedQueriesAsync(search)); [McpServerTool(Name = McpToolNames.RunQuery, ReadOnly = true)] [Description("Kayitli bir nesneyi ya da Custom Endpoint'i parametreleriyle cagirir. Nesne turu ve adi list_queries cikisindan alinir.")] - public async Task RunQueryAsync( + public async Task RunQueryAsync( [Description("Nesne turu: view, procedure, tableFunction, scalarFunction ya da customEndpoint.")] string kind, [Description("Nesnenin ya da Custom Endpoint'in adi.")] @@ -77,12 +78,12 @@ public sealed class McpQueryTools(IMcpQueryAppService queryAppService) Dictionary? parameters = null, [Description("Istenen en fazla satir sayisi; sunucu kaydindaki tavandan buyuk olamaz.")] int maxRows = 0) - => await queryAppService.RunSavedQueryAsync(new McpSavedQueryRequestDto + => McpToolResult.From(await queryAppService.RunSavedQueryAsync(new McpSavedQueryRequestDto { Kind = kind, Name = name, SchemaName = schemaName, Parameters = parameters ?? [], MaxRows = maxRows - }); + })); } diff --git a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/Tools/McpReportTools.cs b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/Tools/McpReportTools.cs index 68622e8f..4ca729a7 100644 --- a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/Tools/McpReportTools.cs +++ b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/Tools/McpReportTools.cs @@ -1,5 +1,6 @@ -using System.ComponentModel; +using System.ComponentModel; using System.Threading.Tasks; +using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Sozsoft.Mcp.Dtos; @@ -15,37 +16,37 @@ public sealed class McpReportTools(IMcpReportAppService reportAppService) { [McpServerTool(Name = McpToolNames.ListReportTemplates, ReadOnly = true)] [Description("Rapor kategorilerini ve rapor sablonlarini listeler; HTML dondurmez. Sablon kaydederken kategori adi buradan secilir.")] - public async Task ListReportTemplatesAsync( + public async Task ListReportTemplatesAsync( [Description("Sablon adi ya da aciklamasi icinde aranacak metin. Bos birakilabilir.")] string? search = null) - => await reportAppService.GetReportTemplatesAsync(search); + => McpToolResult.From(await reportAppService.GetReportTemplatesAsync(search)); [McpServerTool(Name = McpToolNames.GetReportTemplate, ReadOnly = true)] [Description("Bir rapor sablonunun HTML icerigini, yer tutucularini ve goruntuleme adresini dondurur. Degistirmeden once oku.")] - public async Task GetReportTemplateAsync( + public async Task GetReportTemplateAsync( [Description("Sablonun Id'si ya da adi.")] string template) - => await reportAppService.GetReportTemplateAsync(template); + => McpToolResult.From(await reportAppService.GetReportTemplateAsync(template)); [McpServerTool(Name = McpToolNames.SaveReportCategory, Destructive = true, Idempotent = true)] [Description("Rapor kategorisini ada gore kaydeder: yoksa ekler, varsa gunceller. Kayit bir ekran kaydindan basilan HTML sablonu ise var olan 'Raporlar' kategorisini kullan; yeni kategori yalnizca istenince acilir.")] - public async Task SaveReportCategoryAsync( + public async Task SaveReportCategoryAsync( [Description("Kategorinin adi; kimligidir.")] string name, [Description("Aciklama.")] string? description = null, [Description("Ikon adi.")] string? icon = null) - => await reportAppService.SaveReportCategoryAsync(new McpReportCategorySaveRequestDto + => McpToolResult.From(await reportAppService.SaveReportCategoryAsync(new McpReportCategorySaveRequestDto { Name = name, Description = description, Icon = icon - }); + })); [McpServerTool(Name = McpToolNames.SaveReportTemplate, Destructive = true, Idempotent = true)] [Description("DynamicReport sablonunu ada gore kaydeder: yoksa ekler, varsa gunceller; guncellemede verilmeyen alanlar korunur. Sablon A4'e basilan HTML'dir (Standard Taahhutname gibi). listFormCode verilirse HTML icindeki {{AlanAdi}} yer tutuculari o ekranin alanlarina gore dogrulanir ve cevapta rowButton doner: bu butonu ekranin CommandColumnJson listesine ekle (deploy_wizard), VisibleExpression ile kosullandir. Sozlesme icin get_dev_guide 9.12.")] - public async Task SaveReportTemplateAsync( + public async Task SaveReportTemplateAsync( [Description("Sablonun adi; kimligidir.")] string name, [Description("Kategori adi ya da Id'si. Yeni sablonda zorunlu; kayit HTML'i icin 'Raporlar'.")] @@ -60,7 +61,7 @@ public sealed class McpReportTools(IMcpReportAppService reportAppService) string? listFormCode = null, [Description("Satir butonunun dil anahtari. Verilmezse App.Platform.View.")] string? buttonText = null) - => await reportAppService.SaveReportTemplateAsync(new McpReportTemplateSaveRequestDto + => McpToolResult.From(await reportAppService.SaveReportTemplateAsync(new McpReportTemplateSaveRequestDto { Name = name, Category = category, @@ -69,12 +70,12 @@ public sealed class McpReportTools(IMcpReportAppService reportAppService) Status = status, ListFormCode = listFormCode, ButtonText = buttonText - }); + })); [McpServerTool(Name = McpToolNames.DeleteReportTemplate, Destructive = true, Idempotent = false)] [Description("Rapor sablonunu siler. Sablonu acan satir butonlari ekranlardan ayrica kaldirilir.")] - public async Task DeleteReportTemplateAsync( + public async Task DeleteReportTemplateAsync( [Description("Sablonun Id'si ya da adi.")] string template) - => await reportAppService.DeleteReportTemplateAsync(template); + => McpToolResult.From(await reportAppService.DeleteReportTemplateAsync(template)); } diff --git a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/Tools/McpSchemaTools.cs b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/Tools/McpSchemaTools.cs index ca3eec3b..b2b5419e 100644 --- a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/Tools/McpSchemaTools.cs +++ b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/Tools/McpSchemaTools.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.Threading.Tasks; +using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Sozsoft.Mcp.Dtos; using Volo.Abp.MultiTenancy; @@ -32,28 +33,28 @@ public sealed class McpSchemaTools(IMcpSchemaAppService schemaAppService, ICurre [McpServerTool(Name = McpToolNames.ListLists, ReadOnly = true)] [Description("Kiracinin listelerini (ekranlarini) ad, baslik, aciklama ve arkasindaki tablo ile dondurur. Arama metni verilmezse tumu doner. Gelistiriciye (host, Wizard yetkisi) DB Migrate bekleyen wizard ekranlari (sourceKind pending) ve yalnizca seed scripti olan tablolar (pendingTable) da doner; lookup, widget, view ve Query SQL'ini yazarken kolon adlarini describe_list ile buradan al, tahmin etme.")] - public async Task> ListListsAsync( + public async Task ListListsAsync( [Description("Liste adi, basligi, aciklamasi ya da tablo adi icinde aranacak metin. Bos birakilabilir.")] string? search = null, [Description("Donecek en fazla liste sayisi. Varsayilan 200.")] int maxResults = 200) - => await schemaAppService.GetListsAsync(search, maxResults); + => McpToolResult.From(await schemaAppService.GetListsAsync(search, maxResults)); [McpServerTool(Name = McpToolNames.DescribeList, ReadOnly = true)] [Description("Tek bir listenin sutunlarini dondurur: alan adi, kullaniciya gorunen basligi, SQL tipi, lookup iliskisi ve sutunun hassas olup olmadigi.")] - public async Task DescribeListAsync( + public async Task DescribeListAsync( [Description("Listenin kodu; list_lists cikisindaki listFormCode degeri.")] string listFormCode) - => await schemaAppService.GetListAsync(listFormCode); + => McpToolResult.From(await schemaAppService.GetListAsync(listFormCode)); [McpServerTool(Name = McpToolNames.SearchSchema, ReadOnly = true)] [Description("Liste ve sutun adlari ile basliklari uzerinde aksan ve buyuk/kucuk harf duyarsiz arama yapar; hangi listenin hangi sutununun arandigini bulmak icin kullanilir.")] - public async Task> SearchSchemaAsync( + public async Task SearchSchemaAsync( [Description("Aranacak metin. Turkce karakterler ve aksanlar onemsizdir.")] string term, [Description("Donecek en fazla eslesme sayisi. Varsayilan 50.")] int maxResults = 50) - => await schemaAppService.SearchAsync(term, maxResults); + => McpToolResult.From(await schemaAppService.SearchAsync(term, maxResults)); } /// Istegin baglami: host ya da tenant. Gelistirme araclari yalnizca host'ta aciktir. diff --git a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/Tools/McpToolResult.cs b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/Tools/McpToolResult.cs new file mode 100644 index 00000000..015f48ff --- /dev/null +++ b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/Tools/McpToolResult.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using ModelContextProtocol; +using ModelContextProtocol.Protocol; + +namespace Sozsoft.Mcp.Tools; + +/// +/// Servis sonucunu arac sonucuna cevirir. Deger SDK'nin donus degerini yazdigi bicimde +/// () JSON metnidir. hata +/// sonucudur (IsError): istisna atilmadan modele ulasir, model nedeni okuyup girdiyi duzeltir. +/// +internal static class McpToolResult +{ + /// Degeri olmayan araclarin (not ekleme, silme) basari cevabi. + private const string Done = "ok"; + + public static CallToolResult From(McpOutcome outcome) + => outcome.TryGetValue(out var value, out var problem) + ? Text(JsonSerializer.Serialize(value, McpJsonUtilities.DefaultOptions)) + : Error(problem.Message); + + public static CallToolResult From(McpProblem? problem) + => problem is null ? Text(Done) : Error(problem.Message); + + /// Degeri metin disinda icerik (resim) tasiyan araclar icin. + public static CallToolResult From(McpOutcome outcome, Func> toContent) + => outcome.TryGetValue(out var value, out var problem) + ? new CallToolResult { Content = toContent(value) } + : Error(problem.Message); + + public static CallToolResult Error(string message) + => new() { IsError = true, Content = [new TextContentBlock { Text = message }] }; + + private static CallToolResult Text(string text) + => new() { Content = [new TextContentBlock { Text = text }] }; +} diff --git a/claude.md b/claude.md index 58975517..6cca4d14 100644 --- a/claude.md +++ b/claude.md @@ -341,13 +341,19 @@ Bir çelişki varsa sıra: `ai.instructions.md` → `dotnet.instructions.md` → çağrıyı reddeder — süzme tek başına yetki sınırı değildir, ikisi birlikte yazılır. `Invoke` uçta değil her MCP servisinin sınıf düzeyindeki `[Authorize]`'dadır; yeni bir MCP AppService'i de bu attribute'la açılır. - **Hata istemciye hata sonucu (`CallToolResult.IsError`) olarak ulaşır**; `McpToolErrorTranslator` - (çağrı filtresi) yetki reddini "şu yetkiniz yok" mesajına, `UserFriendlyException`'ı mesajına - çevirir ve istisna atmadan döner (atılan istisna SDK'da yığın izli `ERR` logudur). - Kullanıcının göreceği her hata bu iki türden biriyle atılır. Yeni aracın arkasındaki servis - bir yetkiyi `[Authorize]` attribute'uyla istiyorsa (ret mesajı yetki adını taşımaz) o yetki - `McpToolNames.ServicePermissions`'a eklenir; ekran yetkisi gereken araç yetkiyi - `IMcpListFormAccessFilter.GetPermissionNameAsync` + `CheckAsync` ile adıyla önceden ister. + **MCP'nin kendi reddi istisna değildir, modelle konuşmadır**: eksik/hatalı girdi, durum kuralı, + kapalı araç ve eksik yetki `McpProblem` olarak döner — MCP AppService'leri `McpOutcome` / + `McpProblem?` döndürür, domain servisleri (`McpSqlGuard`, `McpTableCreator`, `McpSqlObjectWriter`) + de öyle; araç katmanı bunu `McpToolResult.From` ile hata sonucuna (`CallToolResult.IsError`) çevirir, + model mesajı okuyup tekrar dener. `throw new UserFriendlyException` ve `AuthorizationService.CheckAsync` + MCP kodunda kullanılmaz (debugger'ı durdurur, SDK'da yığın izli `ERR` üretir); yetki + `McpAppService.CheckPermissionAsync` ile adıyla istenir. İstisna yalnızca gerçek hatadır (kod hatası, + beklenmeyen durum). Arkadaki platform servisinin (wizard, custom component, ListForm verisi) attığı + `UserFriendlyException`/yetki reddi ile sınıf düzeyindeki `[Authorize]` reddi `McpToolErrorTranslator` + (çağrı filtresi) ile mesaja çevrilir. Yeni aracın arkasındaki servis bir yetkiyi `[Authorize]` + attribute'uyla istiyorsa (ret mesajı yetki adını taşımaz) o yetki `McpToolNames.ServicePermissions`'a + eklenir; ekran yetkisi gereken araç yetkiyi `IMcpListFormAccessFilter.GetPermissionNameAsync` + + `CheckPermissionAsync` ile adıyla önceden ister. Araç → yetki tablosu `README.md` §13.9'dadır; araç ya da yetki değişirse tablo da değişir. Hazır prompt'lar (`McpPromptNames` + `McpDevelopPrompts`) yalnızca metindir; adımları tekrar etmez, `get_dev_guide` bölümüne yönlendirir. Bir kural bağlayıcıysa prompt'a değil araca yazılır