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);
}