using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text.Json; using System.Threading.Tasks; using OpenIddict.Abstractions; using Sozsoft.Platform; using Volo.Abp.Domain.Repositories; using Volo.Abp.Domain.Services; using Volo.Abp.MultiTenancy; using Volo.Abp.OpenIddict.Applications; namespace Sozsoft.Mcp.Services; /// /// Uzak MCP istemcilerinin OpenIddict uygulamalari. Iki tur istemci vardir: /// /// Bilinen istemci (): geri donus adresleri /// DbMigrator ayarinda sabit olan saglayicilar (Claude, ChatGPT). Onay ekrani gostermez. /// Dinamik istemci (): baska bir MCP /// istemcisi (Cursor, VS Code, Claude Code, ...) kayit ucundan kendi uygulamasini alir. Kayit /// herkese acik oldugu icin bu istemciler acik onay ister: kullanici giris yaptiktan sonra /// hangi uygulamanin erisim istedigini gorup onaylar. /// /// Iki turde de token kullanicinin kendi yetkileriyle uretilir; istemci yetki kazanmaz. /// public interface IMcpOAuthClientManager { /// Bilinen istemciyi olusturur ya da geri donus adreslerini gunceller. Task EnsureKnownClientAsync(IReadOnlyCollection redirectUris); /// /// Istemci kaydi. Adresler bilinen istemcide kayitliysa onu, degilse ayni adres kumesine /// sahip dinamik istemciyi dondurur; yoksa yenisini uretir. /// Task RegisterAsync(string? clientName, IReadOnlyCollection redirectUris); } public class McpOAuthClientManager( IAbpApplicationManager applicationManager, IRepository applicationRepository, ICurrentTenant currentTenant) : DomainService, IMcpOAuthClientManager { private const int MaxClientNameLength = 100; /// Tarayici disi calistirilabilir icerik tasiyan, geri donus adresi olamayacak semalar. private static readonly string[] ForbiddenSchemes = ["javascript", "data", "file", "vbscript", "about", "blob"]; public async Task EnsureKnownClientAsync(IReadOnlyCollection redirectUris) { // OpenIddict uygulamalari host kapsamindadir. using (currentTenant.Change(null)) { var existing = await applicationManager.FindByClientIdAsync(McpConsts.OAuthClientId); var descriptor = BuildDescriptor( McpConsts.OAuthClientId, "MCP Connectors", redirectUris, OpenIddictConstants.ConsentTypes.Implicit); if (existing is null) { await applicationManager.CreateAsync(descriptor); return; } // Tanim tamamen dosyadan gelir; ayar degistiyse (yeni saglayici callback'i, token omru) // kayit yeniden yazilir. Kimlik ve istemci turu degismez. await applicationManager.PopulateAsync(existing, descriptor); await applicationManager.UpdateAsync(existing); } } public async Task RegisterAsync(string? clientName, IReadOnlyCollection redirectUris) { if (redirectUris.Count == 0) { return McpClientRegistration.Rejected(McpClientRegistrationError.InvalidRedirectUri, string.Empty); } var invalid = redirectUris.FirstOrDefault(uri => !IsAcceptableRedirectUri(uri)); if (invalid is not null) { return McpClientRegistration.Rejected(McpClientRegistrationError.InvalidRedirectUri, invalid); } using (currentTenant.Change(null)) { var known = await applicationManager.FindByClientIdAsync(McpConsts.OAuthClientId); if (known is not null) { var knownUris = await applicationManager.GetRedirectUrisAsync(known); if (redirectUris.All(uri => knownUris.Any(registered => SameUri(registered, uri)))) { return McpClientRegistration.Accepted(McpConsts.OAuthClientId); } } var dynamicClients = await GetDynamicClientsAsync(); // Ayni istemci her baglantida yeniden kayit olabilir; ayni adres kumesi tek uygulamaya // baglanir, tablo buyumez. var match = dynamicClients.FirstOrDefault(client => SameUriSet(client.RedirectUris, redirectUris)); if (match.ClientId is not null) { return McpClientRegistration.Accepted(match.ClientId); } if (dynamicClients.Count >= McpConsts.MaxDynamicClients) { return McpClientRegistration.Rejected(McpClientRegistrationError.LimitReached, McpConsts.MaxDynamicClients.ToString(CultureInfo.InvariantCulture)); } var clientId = McpConsts.DynamicClientIdPrefix + GuidGenerator.Create().ToString("N"); await applicationManager.CreateAsync(BuildDescriptor( clientId, DisplayNameOf(clientName, redirectUris.First()), redirectUris, OpenIddictConstants.ConsentTypes.Explicit)); return McpClientRegistration.Accepted(clientId); } } private async Task RedirectUris)>> GetDynamicClientsAsync() { var query = await applicationRepository.GetQueryableAsync(); var rows = query .Where(application => application.ClientId!.StartsWith(McpConsts.DynamicClientIdPrefix)) .Select(application => new { application.ClientId, application.RedirectUris }) .ToList(); return [.. rows.Select(row => ((string?)row.ClientId, ParseUris(row.RedirectUris)))]; } private static AbpApplicationDescriptor BuildDescriptor( string clientId, string displayName, IEnumerable redirectUris, string consentType) { var descriptor = new AbpApplicationDescriptor { ClientId = clientId, ClientType = OpenIddictConstants.ClientTypes.Public, ConsentType = consentType, DisplayName = displayName }; descriptor.Permissions.UnionWith( [ OpenIddictConstants.Permissions.Endpoints.Authorization, OpenIddictConstants.Permissions.Endpoints.Token, OpenIddictConstants.Permissions.Endpoints.Revocation, OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode, OpenIddictConstants.Permissions.GrantTypes.RefreshToken, OpenIddictConstants.Permissions.ResponseTypes.Code, OpenIddictConstants.Permissions.Prefixes.Scope + PlatformConsts.AppName ]); // Genel istemci gizli anahtar tasiyamaz; yetkilendirme kodunun calinmasina karsi PKCE // zorunludur. descriptor.Requirements.Add(OpenIddictConstants.Requirements.Features.ProofKeyForCodeExchange); descriptor.Settings[OpenIddictConstants.Settings.TokenLifetimes.RefreshToken] = McpConsts.RefreshTokenLifetime.ToString("c", CultureInfo.InvariantCulture); foreach (var redirectUri in redirectUris) { descriptor.RedirectUris.Add(new Uri(redirectUri, UriKind.Absolute)); } return descriptor; } /// /// Kabul edilen geri donus adresleri: HTTPS, yerel makineye donen HTTP (RFC 8252 §7.3 — /// masaustu istemciler rastgele portla dinler) ve masaustu uygulamalarin ozel semalari /// (RFC 8252 §7.1, ornek: cursor://...). Yerel olmayan duz HTTP reddedilir. /// private static bool IsAcceptableRedirectUri(string value) { if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || !string.IsNullOrEmpty(uri.Fragment)) { return false; } if (uri.Scheme == Uri.UriSchemeHttps) { return true; } if (uri.Scheme == Uri.UriSchemeHttp) { return uri.IsLoopback; } return !ForbiddenSchemes.Contains(uri.Scheme, StringComparer.OrdinalIgnoreCase); } /// /// Onay ekraninda gorunen ad. Istemci adi kayit isteginden gelir ve dogrulanamaz (herkes /// kendine "Claude" diyebilir); bu yuzden token'in gidecegi adresin sahibi de gosterilir. /// private static string DisplayNameOf(string? clientName, string redirectUri) { var name = string.IsNullOrWhiteSpace(clientName) ? "MCP Client" : clientName.Trim(); if (name.Length > MaxClientNameLength) { name = name[..MaxClientNameLength]; } var uri = new Uri(redirectUri, UriKind.Absolute); var owner = uri.IsLoopback ? "localhost" : string.IsNullOrEmpty(uri.Host) ? uri.Scheme : uri.Host; return $"{name} ({owner})"; } private static IReadOnlyList ParseUris(string? json) => string.IsNullOrWhiteSpace(json) ? [] : JsonSerializer.Deserialize>(json) ?? []; private static bool SameUriSet(IReadOnlyCollection left, IReadOnlyCollection right) => left.Count == right.Count && right.All(uri => left.Any(candidate => SameUri(candidate, uri))); /// OpenIddict adresleri bicimiyle saklar; karsilastirma ayni bicimle yapilir. private static bool SameUri(string left, string right) => Uri.TryCreate(left, UriKind.Absolute, out var a) && Uri.TryCreate(right, UriKind.Absolute, out var b) && string.Equals(a.AbsoluteUri, b.AbsoluteUri, StringComparison.Ordinal); } public enum McpClientRegistrationError { InvalidRedirectUri, LimitReached } /// Kayit sonucu: basariliysa istemci kimligi, degilse hata ve hataya konu deger. public sealed record McpClientRegistration(string? ClientId, McpClientRegistrationError? Error, string? ErrorValue) { public static McpClientRegistration Accepted(string clientId) => new(clientId, null, null); public static McpClientRegistration Rejected(McpClientRegistrationError error, string value) => new(null, error, value); }