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; using Volo.Abp.DistributedLocking; using Volo.Abp.Domain.Repositories; using Volo.Abp.Domain.Services; using Volo.Abp.MultiTenancy; using Volo.Abp.OpenIddict.Applications; using Volo.Abp.Uow; namespace Sozsoft.Mcp.Services; /// /// Uzak MCP istemcilerinin OpenIddict uygulamalari. Iki tur istemci vardir: /// /// Bilinen istemci: geri donus adresleri DbMigrator ayarinda sabit olan istemciler — /// (Claude, ChatGPT; onay ekrani gostermez) ve /// (yerel mcp-remote proxy'si; acik onayli). /// 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(string clientId, IReadOnlyCollection redirectUris); /// /// Istemci kaydi. Adresler bir 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, IAbpDistributedLock distributedLock, IUnitOfWorkManager unitOfWorkManager) : DomainService, IMcpOAuthClientManager { private const int MaxClientNameLength = 100; private const string RegistrationLockName = "Mcp:OAuthClientRegistration"; private static readonly TimeSpan RegistrationLockTimeout = TimeSpan.FromSeconds(15); private static readonly Dictionary KnownClients = new(StringComparer.Ordinal) { [McpConsts.OAuthClientId] = ("MCP Connectors", OpenIddictConstants.ConsentTypes.Implicit), [McpConsts.LocalOAuthClientId] = ("MCP Local Proxy", OpenIddictConstants.ConsentTypes.Explicit) }; /// 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(string clientId, IReadOnlyCollection redirectUris) { var (displayName, consentType) = KnownClients[clientId]; // OpenIddict uygulamalari host kapsamindadir. using (currentTenant.Change(null)) { var existing = await applicationManager.FindByClientIdAsync(clientId); var descriptor = BuildDescriptor(clientId, displayName, redirectUris, consentType); 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)) { foreach (var knownClientId in KnownClients.Keys) { var known = await applicationManager.FindByClientIdAsync(knownClientId); if (known is null) { continue; } var knownUris = await applicationManager.GetRedirectUrisAsync(known); if (redirectUris.All(uri => knownUris.Any(registered => SameUri(registered, uri)))) { return McpClientRegistration.Accepted(knownClientId); } } // Masaustu istemcisi ayni anda birden cok proxy sureci baslatir ve hepsi ayni adreslerle // kayit olur. Kontrol ile ekleme kilitsiz ve istegin UoW'sinde kalirsa hicbiri digerinin // kaydini goremez, ayni adres kumesi icin birden cok uygulama acilir. Kilit altinda // calisip kendi UoW'sinde commit eden kayit, sonraki istegin onu bulmasini saglar. await using var handle = await distributedLock.TryAcquireAsync(RegistrationLockName, RegistrationLockTimeout) ?? throw new AbpException($"Could not acquire '{RegistrationLockName}' lock."); using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: true); var registration = await RegisterDynamicClientAsync(clientName, redirectUris); await uow.CompleteAsync(); return registration; } } private async Task RegisterDynamicClientAsync(string? clientName, IReadOnlyCollection redirectUris) { 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); }