diff --git a/Tharga.Platform.Mcp.Tests/AddMcpPlatformTests.cs b/Tharga.Platform.Mcp.Tests/AddMcpPlatformTests.cs index cea6efb..ddcddd3 100644 --- a/Tharga.Platform.Mcp.Tests/AddMcpPlatformTests.cs +++ b/Tharga.Platform.Mcp.Tests/AddMcpPlatformTests.cs @@ -23,6 +23,35 @@ public void ReplacesDefaultContextAccessorWithHttpContextBacked() Assert.IsType(accessor); } + [Fact] + public void ExposeSystemResources_False_DoesNotRegisterSystemProvider() + { + var services = new ServiceCollection(); + + services.AddThargaMcp(mcp => + { + mcp.AddMcpPlatform(o => o.ExposeSystemResources = false); + }); + + Assert.DoesNotContain(services, d => d.ServiceType == typeof(PlatformSystemResourceProvider)); + } + + [Fact] + public void ExposeSystemResources_True_RegistersSystemProvider() + { + var services = new ServiceCollection(); + + services.AddThargaMcp(mcp => + { + mcp.AddMcpPlatform(o => o.ExposeSystemResources = true); + }); + + Assert.Contains(services, d => d.ServiceType == typeof(PlatformSystemResourceProvider)); + Assert.Contains(services, d => + d.ServiceType == typeof(IMcpResourceProvider) && + d.Lifetime == ServiceLifetime.Transient); + } + [Fact] public void RegistersScopeChecker() { diff --git a/Tharga.Platform.Mcp.Tests/PlatformSystemResourceProviderTests.cs b/Tharga.Platform.Mcp.Tests/PlatformSystemResourceProviderTests.cs new file mode 100644 index 0000000..ce1a557 --- /dev/null +++ b/Tharga.Platform.Mcp.Tests/PlatformSystemResourceProviderTests.cs @@ -0,0 +1,143 @@ +using Microsoft.Extensions.Options; +using Tharga.Mcp; +using Tharga.Platform.Mcp; +using Tharga.Team; +using Tharga.Team.Service.Audit; + +namespace Tharga.Platform.Mcp.Tests; + +public class PlatformSystemResourceProviderTests +{ + private readonly IApiKeyAdministrationService _apiKeyService = Substitute.For(); + private readonly ITenantRoleRegistry _roleRegistry = Substitute.For(); + private readonly CompositeAuditLogger _auditLogger; + + public PlatformSystemResourceProviderTests() + { + _auditLogger = new CompositeAuditLogger( + Enumerable.Empty(), + Options.Create(new AuditOptions())); + } + + private IMcpContext MakeContext(bool isDeveloper) + { + var ctx = Substitute.For(); + ctx.IsDeveloper.Returns(isDeveloper); + ctx.Scope.Returns(McpScope.System); + return ctx; + } + + private static async IAsyncEnumerable ToAsyncEnumerable(params T[] items) + { + foreach (var item in items) yield return item; + await Task.CompletedTask; + } + + [Fact] + public async Task ListResourcesAsync_NonDeveloper_ReturnsEmpty() + { + var sut = new PlatformSystemResourceProvider(_apiKeyService, _roleRegistry, _auditLogger); + + var result = await sut.ListResourcesAsync(MakeContext(isDeveloper: false), default); + + Assert.Empty(result); + } + + [Fact] + public async Task ListResourcesAsync_Developer_ReturnsAllAvailableResources() + { + var sut = new PlatformSystemResourceProvider(_apiKeyService, _roleRegistry, _auditLogger); + + var result = await sut.ListResourcesAsync(MakeContext(isDeveloper: true), default); + + Assert.Equal(3, result.Count); + Assert.Contains(result, r => r.Uri == PlatformSystemResourceProvider.SystemKeysUri); + Assert.Contains(result, r => r.Uri == PlatformSystemResourceProvider.RolesUri); + Assert.Contains(result, r => r.Uri == PlatformSystemResourceProvider.AuditUri); + } + + [Fact] + public async Task ListResourcesAsync_OmitsAuditWhenAuditLoggerNotRegistered() + { + var sut = new PlatformSystemResourceProvider(_apiKeyService, _roleRegistry, auditLogger: null); + + var result = await sut.ListResourcesAsync(MakeContext(isDeveloper: true), default); + + Assert.DoesNotContain(result, r => r.Uri == PlatformSystemResourceProvider.AuditUri); + Assert.Equal(2, result.Count); + } + + [Fact] + public async Task ReadResourceAsync_NonDeveloper_Throws() + { + var sut = new PlatformSystemResourceProvider(_apiKeyService, _roleRegistry, _auditLogger); + + await Assert.ThrowsAsync(() => + sut.ReadResourceAsync(PlatformSystemResourceProvider.RolesUri, MakeContext(isDeveloper: false), default)); + } + + [Fact] + public async Task ReadResourceAsync_UnknownUri_Throws() + { + var sut = new PlatformSystemResourceProvider(_apiKeyService, _roleRegistry, _auditLogger); + + await Assert.ThrowsAsync(() => + sut.ReadResourceAsync("platform://system/unknown", MakeContext(isDeveloper: true), default)); + } + + [Fact] + public async Task ReadResourceAsync_SystemKeys_RedactsRawApiKeyAndHash() + { + var key = Substitute.For(); + key.Key.Returns("key-1"); + key.Name.Returns("mcp-gate"); + key.ApiKey.Returns("SHOULD_NOT_BE_EXPOSED"); + key.SystemScopes.Returns(new[] { "mcp:discover" }); + key.CreatedBy.Returns("daniel"); + _apiKeyService.GetSystemKeysAsync().Returns(ToAsyncEnumerable(key)); + + var sut = new PlatformSystemResourceProvider(_apiKeyService, _roleRegistry, _auditLogger); + + var content = await sut.ReadResourceAsync(PlatformSystemResourceProvider.SystemKeysUri, MakeContext(isDeveloper: true), default); + + Assert.NotNull(content.Text); + Assert.Contains("mcp-gate", content.Text); + Assert.Contains("daniel", content.Text); + Assert.DoesNotContain("SHOULD_NOT_BE_EXPOSED", content.Text); + Assert.DoesNotContain("ApiKeyHash", content.Text); + Assert.Equal("application/json", content.MimeType); + } + + [Fact] + public async Task ReadResourceAsync_Roles_ReturnsRoleNames() + { + var role = new TenantRoleDefinition("Editor", new[] { "feature:read", "feature:write" }); + _roleRegistry.All.Returns(new[] { role }); + + var sut = new PlatformSystemResourceProvider(_apiKeyService, _roleRegistry, _auditLogger); + + var content = await sut.ReadResourceAsync(PlatformSystemResourceProvider.RolesUri, MakeContext(isDeveloper: true), default); + + Assert.Contains("Editor", content.Text); + Assert.Contains("feature:read", content.Text); + } + + [Fact] + public async Task ReadResourceAsync_Audit_ReturnsQueryResult() + { + var sut = new PlatformSystemResourceProvider(_apiKeyService, _roleRegistry, _auditLogger); + + var content = await sut.ReadResourceAsync(PlatformSystemResourceProvider.AuditUri, MakeContext(isDeveloper: true), default); + + Assert.NotNull(content.Text); + Assert.Contains("items", content.Text); + Assert.Equal("application/json", content.MimeType); + } + + [Fact] + public void Scope_IsSystem() + { + var sut = new PlatformSystemResourceProvider(_apiKeyService, _roleRegistry, _auditLogger); + Assert.Equal(McpScope.System, sut.Scope); + } +} diff --git a/Tharga.Platform.Mcp/McpPlatformBuilderExtensions.cs b/Tharga.Platform.Mcp/McpPlatformBuilderExtensions.cs index 6a55eb9..c8c360c 100644 --- a/Tharga.Platform.Mcp/McpPlatformBuilderExtensions.cs +++ b/Tharga.Platform.Mcp/McpPlatformBuilderExtensions.cs @@ -42,6 +42,12 @@ public static IThargaMcpBuilder AddMcpPlatform(this IThargaMcpBuilder builder, A scopes.Register(McpScopes.Discover, AccessLevel.Viewer); }); + // Opt-in system-scope resource providers (diagnostic data for Developers). + if (options.ExposeSystemResources) + { + builder.AddResourceProvider(); + } + return builder; } diff --git a/Tharga.Platform.Mcp/McpPlatformOptions.cs b/Tharga.Platform.Mcp/McpPlatformOptions.cs index c734864..95bc187 100644 --- a/Tharga.Platform.Mcp/McpPlatformOptions.cs +++ b/Tharga.Platform.Mcp/McpPlatformOptions.cs @@ -10,4 +10,11 @@ public sealed class McpPlatformOptions /// Defaults to "Developer" to match Tharga.Platform conventions. /// public string DeveloperRole { get; set; } = "Developer"; + + /// + /// When true, registers read-only system-scope resource providers that expose cross-tenant + /// team, API-key, role, and audit-log data for diagnostic use by Developers. + /// Default false — opt in only if you want diagnostic data surfaced over MCP. + /// + public bool ExposeSystemResources { get; set; } } diff --git a/Tharga.Platform.Mcp/PlatformSystemResourceProvider.cs b/Tharga.Platform.Mcp/PlatformSystemResourceProvider.cs new file mode 100644 index 0000000..01ffa4e --- /dev/null +++ b/Tharga.Platform.Mcp/PlatformSystemResourceProvider.cs @@ -0,0 +1,168 @@ +using System.Text.Json; +using Tharga.Mcp; +using Tharga.Team; +using Tharga.Team.Service.Audit; + +namespace Tharga.Platform.Mcp; + +/// +/// Read-only MCP resource provider that surfaces system-scope Platform data for diagnostic use. +/// Only available to callers with the Developer role (see ). +/// Registered by AddMcpPlatform when is true. +/// +public sealed class PlatformSystemResourceProvider : IMcpResourceProvider +{ + private readonly IApiKeyAdministrationService _apiKeyAdministrationService; + private readonly ITenantRoleRegistry _tenantRoleRegistry; + private readonly CompositeAuditLogger _auditLogger; + + public const string SystemKeysUri = "platform://system/apikeys"; + public const string RolesUri = "platform://system/roles"; + public const string AuditUri = "platform://system/audit"; + + private static readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = true, + }; + + public PlatformSystemResourceProvider( + IApiKeyAdministrationService apiKeyAdministrationService = null, + ITenantRoleRegistry tenantRoleRegistry = null, + CompositeAuditLogger auditLogger = null) + { + _apiKeyAdministrationService = apiKeyAdministrationService; + _tenantRoleRegistry = tenantRoleRegistry; + _auditLogger = auditLogger; + } + + public McpScope Scope => McpScope.System; + + public Task> ListResourcesAsync(IMcpContext context, CancellationToken cancellationToken) + { + if (context?.IsDeveloper != true) + return Task.FromResult>(Array.Empty()); + + var list = new List(); + + if (_apiKeyAdministrationService != null) + { + list.Add(new McpResourceDescriptor + { + Uri = SystemKeysUri, + Name = "System API Keys", + Description = "Cross-tenant system API keys (not bound to a team). Raw key values are redacted.", + MimeType = "application/json", + }); + } + + if (_tenantRoleRegistry != null) + { + list.Add(new McpResourceDescriptor + { + Uri = RolesUri, + Name = "Tenant Roles", + Description = "Registered tenant roles and their granted scopes.", + MimeType = "application/json", + }); + } + + if (_auditLogger != null) + { + list.Add(new McpResourceDescriptor + { + Uri = AuditUri, + Name = "Audit Log", + Description = "Most recent ~100 audit entries from the last 7 days.", + MimeType = "application/json", + }); + } + + return Task.FromResult>(list); + } + + public async Task ReadResourceAsync(string uri, IMcpContext context, CancellationToken cancellationToken) + { + if (context?.IsDeveloper != true) + throw new UnauthorizedAccessException("System resources require the Developer role."); + + return uri switch + { + SystemKeysUri => await ReadSystemKeysAsync(cancellationToken), + RolesUri => ReadRoles(), + AuditUri => await ReadAuditAsync(), + _ => throw new InvalidOperationException($"Unknown resource URI '{uri}'."), + }; + } + + private async Task ReadSystemKeysAsync(CancellationToken cancellationToken) + { + if (_apiKeyAdministrationService == null) + throw new InvalidOperationException("IApiKeyAdministrationService is not registered."); + + var keys = new List(); + await foreach (var key in _apiKeyAdministrationService.GetSystemKeysAsync().WithCancellation(cancellationToken)) + { + keys.Add(new + { + key.Key, + key.Name, + SystemScopes = key.SystemScopes ?? Array.Empty(), + key.ExpiryDate, + key.CreatedAt, + key.CreatedBy, + }); + } + + return new McpResourceContent + { + Uri = SystemKeysUri, + Text = JsonSerializer.Serialize(new { items = keys }, _jsonOptions), + MimeType = "application/json", + }; + } + + private McpResourceContent ReadRoles() + { + if (_tenantRoleRegistry == null) + throw new InvalidOperationException("ITenantRoleRegistry is not registered."); + + var items = _tenantRoleRegistry.All.Select(r => new + { + r.Name, + r.Scopes, + }); + + return new McpResourceContent + { + Uri = RolesUri, + Text = JsonSerializer.Serialize(new { items }, _jsonOptions), + MimeType = "application/json", + }; + } + + private async Task ReadAuditAsync() + { + if (_auditLogger == null) + throw new InvalidOperationException("CompositeAuditLogger is not registered."); + + var query = new AuditQuery + { + From = DateTime.UtcNow.AddDays(-7), + Take = 100, + SortDescending = true, + }; + + var result = await _auditLogger.QueryAsync(query); + + return new McpResourceContent + { + Uri = AuditUri, + Text = JsonSerializer.Serialize(new + { + total = result.TotalCount, + items = result.Items, + }, _jsonOptions), + MimeType = "application/json", + }; + } +} diff --git a/Tharga.Platform.Mcp/README.md b/Tharga.Platform.Mcp/README.md index 31ed46c..b12baaf 100644 --- a/Tharga.Platform.Mcp/README.md +++ b/Tharga.Platform.Mcp/README.md @@ -22,9 +22,33 @@ builder.Services.AddThargaMcp(mcp => // ... other provider packages (e.g. mcp.AddMongoDB()) }); -app.MapMcp(); +app.UseThargaMcp(); ``` +## System-scope diagnostic resources (opt-in) + +Expose read-only diagnostic data under `platform://system/*` for callers with the Developer role. Non-developers see no resources and get `UnauthorizedAccessException` on read. + +```csharp +builder.Services.AddThargaMcp(mcp => +{ + mcp.AddMcpPlatform(o => + { + o.ExposeSystemResources = true; + }); +}); +``` + +Available resources (listed only when the matching dependency is registered): + +| URI | Contents | +|-----|----------| +| `platform://system/apikeys` | System API keys (not bound to a team). Raw key values are redacted. | +| `platform://system/roles` | Tenant roles registered via `AddThargaTenantRoles` | +| `platform://system/audit` | Most recent ~100 audit entries from the last 7 days | + +Cross-tenant team listings and per-team API-key listings are deferred — they require a new `ITeamService` method and are tracked separately. + ## Related packages | Package | Description | diff --git a/Tharga.Platform.Sample/Program.cs b/Tharga.Platform.Sample/Program.cs index 64c5a70..a15075f 100644 --- a/Tharga.Platform.Sample/Program.cs +++ b/Tharga.Platform.Sample/Program.cs @@ -1,5 +1,7 @@ using Radzen; +using Tharga.Mcp; using Tharga.MongoDB; +using Tharga.Platform.Mcp; using Tharga.Platform.Sample.Components; using Tharga.Platform.Sample.Framework; using Tharga.Platform.Sample.Framework.Team; @@ -38,6 +40,10 @@ o.Audit = new AuditOptions(); }); +builder.Services.AddThargaMcp(mcp => +{ + mcp.AddMcpPlatform(); +}); builder.AddMongoDB(); @@ -59,6 +65,7 @@ app.UseAntiforgery(); app.UseThargaPlatform(); +app.UseThargaMcp(); app.MapStaticAssets(); app.MapRazorComponents() diff --git a/Tharga.Platform.Sample/Tharga.Platform.Sample.csproj b/Tharga.Platform.Sample/Tharga.Platform.Sample.csproj index f6df10f..c580efd 100644 --- a/Tharga.Platform.Sample/Tharga.Platform.Sample.csproj +++ b/Tharga.Platform.Sample/Tharga.Platform.Sample.csproj @@ -9,6 +9,7 @@ 1a7983d6-af1a-451e-8272-0986f7ae5272 +