Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions Tharga.Platform.Mcp.Tests/AddMcpPlatformTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,35 @@ public void ReplacesDefaultContextAccessorWithHttpContextBacked()
Assert.IsType<HttpContextMcpContextAccessor>(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()
{
Expand Down
143 changes: 143 additions & 0 deletions Tharga.Platform.Mcp.Tests/PlatformSystemResourceProviderTests.cs
Original file line number Diff line number Diff line change
@@ -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<IApiKeyAdministrationService>();
private readonly ITenantRoleRegistry _roleRegistry = Substitute.For<ITenantRoleRegistry>();
private readonly CompositeAuditLogger _auditLogger;

public PlatformSystemResourceProviderTests()
{
_auditLogger = new CompositeAuditLogger(
Enumerable.Empty<IAuditLogger>(),
Options.Create(new AuditOptions()));
}

private IMcpContext MakeContext(bool isDeveloper)
{
var ctx = Substitute.For<IMcpContext>();
ctx.IsDeveloper.Returns(isDeveloper);
ctx.Scope.Returns(McpScope.System);
return ctx;
}

private static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(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<UnauthorizedAccessException>(() =>
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<InvalidOperationException>(() =>
sut.ReadResourceAsync("platform://system/unknown", MakeContext(isDeveloper: true), default));
}

[Fact]
public async Task ReadResourceAsync_SystemKeys_RedactsRawApiKeyAndHash()
{
var key = Substitute.For<IApiKey>();
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);
}
}
6 changes: 6 additions & 0 deletions Tharga.Platform.Mcp/McpPlatformBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PlatformSystemResourceProvider>();
}

return builder;
}

Expand Down
7 changes: 7 additions & 0 deletions Tharga.Platform.Mcp/McpPlatformOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,11 @@ public sealed class McpPlatformOptions
/// Defaults to <c>"Developer"</c> to match Tharga.Platform conventions.
/// </summary>
public string DeveloperRole { get; set; } = "Developer";

/// <summary>
/// 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.
/// </summary>
public bool ExposeSystemResources { get; set; }
}
168 changes: 168 additions & 0 deletions Tharga.Platform.Mcp/PlatformSystemResourceProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
using System.Text.Json;
using Tharga.Mcp;
using Tharga.Team;
using Tharga.Team.Service.Audit;

namespace Tharga.Platform.Mcp;

/// <summary>
/// Read-only MCP resource provider that surfaces system-scope Platform data for diagnostic use.
/// Only available to callers with the Developer role (see <see cref="IMcpContext.IsDeveloper"/>).
/// Registered by <c>AddMcpPlatform</c> when <see cref="McpPlatformOptions.ExposeSystemResources"/> is true.
/// </summary>
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<IReadOnlyList<McpResourceDescriptor>> ListResourcesAsync(IMcpContext context, CancellationToken cancellationToken)
{
if (context?.IsDeveloper != true)
return Task.FromResult<IReadOnlyList<McpResourceDescriptor>>(Array.Empty<McpResourceDescriptor>());

var list = new List<McpResourceDescriptor>();

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<IReadOnlyList<McpResourceDescriptor>>(list);
}

public async Task<McpResourceContent> 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<McpResourceContent> ReadSystemKeysAsync(CancellationToken cancellationToken)
{
if (_apiKeyAdministrationService == null)
throw new InvalidOperationException("IApiKeyAdministrationService is not registered.");

var keys = new List<object>();
await foreach (var key in _apiKeyAdministrationService.GetSystemKeysAsync().WithCancellation(cancellationToken))
{
keys.Add(new
{
key.Key,
key.Name,
SystemScopes = key.SystemScopes ?? Array.Empty<string>(),
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<McpResourceContent> 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",
};
}
}
Loading
Loading