From 599825b8fb76f68bb4b68b961309ff669848460c Mon Sep 17 00:00:00 2001 From: Daniel Bohlin Date: Mon, 11 May 2026 14:52:15 +0200 Subject: [PATCH 1/2] feat: MCP user-scope and team-scope resource providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the Tharga.Platform.Mcp bridge beyond the system slice with two always-on resource providers that surface the authenticated caller's own Platform data. - PlatformUserResourceProvider (McpScope.User) — platform://me with the caller's IUser + memberships across teams. - PlatformTeamResourceProvider (McpScope.Team) — platform://team, platform://team/members, platform://team/apikeys rooted at the caller's current team via the TeamKey claim. ApiKey raw values are redacted. - New ITeamService.GetMembersAsync(teamKey) returning IAsyncEnumerable, with a default TeamServiceBase implementation that reuses the existing reflection helper. The AuditingTeamServiceDecorator forwards the call through. Both providers register automatically in AddPlatform(); they self-gate on principal claims, so anonymous and system-only callers see nothing. 17 new tests, 313 / 313 passing. Cross-tenant team listing (system slice platform://teams) remains deferred — needs ITeamService.GetAllTeamsAsync. --- .../PlatformTeamResourceProviderTests.cs | 172 ++++++++++++++++++ .../PlatformUserResourceProviderTests.cs | 126 +++++++++++++ .../McpPlatformBuilderExtensions.cs | 5 + .../PlatformTeamResourceProvider.cs | 172 ++++++++++++++++++ .../PlatformUserResourceProvider.cs | 106 +++++++++++ Tharga.Platform.Mcp/README.md | 24 ++- .../TeamServiceBaseGetMembersAsyncTests.cs | 45 +++++ .../Audit/AuditingTeamServiceDecorator.cs | 1 + Tharga.Team/ITeamService.cs | 1 + Tharga.Team/README.md | 2 +- Tharga.Team/TeamServiceBase.cs | 11 ++ plan/feature.md | 55 ++++++ plan/plan.md | 90 +++++++++ 13 files changed, 808 insertions(+), 2 deletions(-) create mode 100644 Tharga.Platform.Mcp.Tests/PlatformTeamResourceProviderTests.cs create mode 100644 Tharga.Platform.Mcp.Tests/PlatformUserResourceProviderTests.cs create mode 100644 Tharga.Platform.Mcp/PlatformTeamResourceProvider.cs create mode 100644 Tharga.Platform.Mcp/PlatformUserResourceProvider.cs create mode 100644 Tharga.Team.Service.Tests/TeamServiceBaseGetMembersAsyncTests.cs create mode 100644 plan/feature.md create mode 100644 plan/plan.md diff --git a/Tharga.Platform.Mcp.Tests/PlatformTeamResourceProviderTests.cs b/Tharga.Platform.Mcp.Tests/PlatformTeamResourceProviderTests.cs new file mode 100644 index 0000000..8e13964 --- /dev/null +++ b/Tharga.Platform.Mcp.Tests/PlatformTeamResourceProviderTests.cs @@ -0,0 +1,172 @@ +using System.Text.Json; +using Tharga.Mcp; +using Tharga.Platform.Mcp; +using Tharga.Team; + +namespace Tharga.Platform.Mcp.Tests; + +public class PlatformTeamResourceProviderTests +{ + private readonly ITeamService _teamService = Substitute.For(); + private readonly IApiKeyAdministrationService _apiKeyService = Substitute.For(); + + private IMcpContext MakeContext(string teamId) + { + var ctx = Substitute.For(); + ctx.TeamId.Returns(teamId); + ctx.Scope.Returns(McpScope.Team); + 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_NoTeamId_ReturnsEmpty() + { + var sut = new PlatformTeamResourceProvider(_teamService, _apiKeyService); + + var result = await sut.ListResourcesAsync(MakeContext(teamId: null), TestContext.Current.CancellationToken); + + Assert.Empty(result); + } + + [Fact] + public async Task ListResourcesAsync_WithTeamId_Returns_Three_When_ApiKeyServiceRegistered() + { + var sut = new PlatformTeamResourceProvider(_teamService, _apiKeyService); + + var result = await sut.ListResourcesAsync(MakeContext("T-1"), TestContext.Current.CancellationToken); + + Assert.Equal(3, result.Count); + Assert.Contains(result, r => r.Uri == PlatformTeamResourceProvider.TeamUri); + Assert.Contains(result, r => r.Uri == PlatformTeamResourceProvider.MembersUri); + Assert.Contains(result, r => r.Uri == PlatformTeamResourceProvider.ApiKeysUri); + } + + [Fact] + public async Task ListResourcesAsync_WithTeamId_OmitsApiKeys_When_ApiKeyServiceNotRegistered() + { + var sut = new PlatformTeamResourceProvider(_teamService, apiKeyAdministrationService: null); + + var result = await sut.ListResourcesAsync(MakeContext("T-1"), TestContext.Current.CancellationToken); + + Assert.Equal(2, result.Count); + Assert.DoesNotContain(result, r => r.Uri == PlatformTeamResourceProvider.ApiKeysUri); + } + + [Fact] + public async Task ReadResourceAsync_NoTeamId_Throws() + { + var sut = new PlatformTeamResourceProvider(_teamService, _apiKeyService); + + await Assert.ThrowsAsync(() => + sut.ReadResourceAsync(PlatformTeamResourceProvider.TeamUri, MakeContext(teamId: null), TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ReadResourceAsync_UnknownUri_Throws() + { + var sut = new PlatformTeamResourceProvider(_teamService, _apiKeyService); + + await Assert.ThrowsAsync(() => + sut.ReadResourceAsync("platform://team/bogus", MakeContext("T-1"), TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ReadResourceAsync_TeamUri_ReturnsTeamMetadata() + { + var team = Substitute.For(); + team.Key.Returns("T-1"); + team.Name.Returns("Acme"); + team.Icon.Returns((string)null); + team.ConsentedRoles.Returns(new[] { "viewer" }); + _teamService.GetTeamsAsync().Returns(ToAsyncEnumerable(team)); + + var sut = new PlatformTeamResourceProvider(_teamService, _apiKeyService); + var content = await sut.ReadResourceAsync(PlatformTeamResourceProvider.TeamUri, MakeContext("T-1"), TestContext.Current.CancellationToken); + + using var doc = JsonDocument.Parse(content.Text); + var root = doc.RootElement; + Assert.Equal("T-1", root.GetProperty("key").GetString()); + Assert.Equal("Acme", root.GetProperty("name").GetString()); + Assert.Equal(1, root.GetProperty("consentedRoles").GetArrayLength()); + } + + [Fact] + public async Task ReadResourceAsync_TeamUri_NotInCallerTeams_Throws() + { + // The MCP caller's TeamKey claim says T-1 but GetTeamsAsync() returns a different team. + var otherTeam = Substitute.For(); + otherTeam.Key.Returns("T-OTHER"); + _teamService.GetTeamsAsync().Returns(ToAsyncEnumerable(otherTeam)); + + var sut = new PlatformTeamResourceProvider(_teamService, _apiKeyService); + + await Assert.ThrowsAsync(() => + sut.ReadResourceAsync(PlatformTeamResourceProvider.TeamUri, MakeContext("T-1"), TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ReadResourceAsync_MembersUri_ReturnsMembers() + { + var m1 = Substitute.For(); + m1.Key.Returns("u-1"); + m1.Name.Returns("One"); + m1.AccessLevel.Returns(AccessLevel.Owner); + m1.State.Returns(MembershipState.Member); + var m2 = Substitute.For(); + m2.Key.Returns("u-2"); + m2.AccessLevel.Returns(AccessLevel.User); + m2.Invitation.Returns(new Invitation { InviteKey = "inv-abc", EMail = "two@example.com", InviteTime = DateTime.UtcNow }); + _teamService.GetMembersAsync("T-1").Returns(ToAsyncEnumerable(m1, m2)); + + var sut = new PlatformTeamResourceProvider(_teamService, _apiKeyService); + var content = await sut.ReadResourceAsync(PlatformTeamResourceProvider.MembersUri, MakeContext("T-1"), TestContext.Current.CancellationToken); + + using var doc = JsonDocument.Parse(content.Text); + var root = doc.RootElement; + Assert.Equal("T-1", root.GetProperty("teamKey").GetString()); + var items = root.GetProperty("items"); + Assert.Equal(2, items.GetArrayLength()); + Assert.False(items[0].GetProperty("invited").GetBoolean()); + Assert.True(items[1].GetProperty("invited").GetBoolean()); + } + + [Fact] + public async Task ReadResourceAsync_ApiKeysUri_ReturnsRedactedKeys() + { + var key = Substitute.For(); + key.Key.Returns("k-1"); + key.Name.Returns("Default"); + key.ApiKey.Returns("RAW-SECRET-VALUE-SHOULD-BE-REDACTED"); + key.AccessLevel.Returns(AccessLevel.Administrator); + key.Roles.Returns(new[] { "Editor" }); + key.CreatedBy.Returns("alice"); + _apiKeyService.GetKeysAsync("T-1").Returns(ToAsyncEnumerable(key)); + + var sut = new PlatformTeamResourceProvider(_teamService, _apiKeyService); + var content = await sut.ReadResourceAsync(PlatformTeamResourceProvider.ApiKeysUri, MakeContext("T-1"), TestContext.Current.CancellationToken); + + Assert.DoesNotContain("RAW-SECRET-VALUE", content.Text); + + using var doc = JsonDocument.Parse(content.Text); + var item = doc.RootElement.GetProperty("items")[0]; + Assert.Equal("k-1", item.GetProperty("key").GetString()); + Assert.Equal("Default", item.GetProperty("name").GetString()); + Assert.Equal("alice", item.GetProperty("createdBy").GetString()); + Assert.False(item.TryGetProperty("apiKey", out _)); + } + + [Fact] + public async Task ReadResourceAsync_ApiKeysUri_NoApiKeyService_Throws() + { + var sut = new PlatformTeamResourceProvider(_teamService, apiKeyAdministrationService: null); + + await Assert.ThrowsAsync(() => + sut.ReadResourceAsync(PlatformTeamResourceProvider.ApiKeysUri, MakeContext("T-1"), TestContext.Current.CancellationToken)); + } +} diff --git a/Tharga.Platform.Mcp.Tests/PlatformUserResourceProviderTests.cs b/Tharga.Platform.Mcp.Tests/PlatformUserResourceProviderTests.cs new file mode 100644 index 0000000..ca882dc --- /dev/null +++ b/Tharga.Platform.Mcp.Tests/PlatformUserResourceProviderTests.cs @@ -0,0 +1,126 @@ +using System.Security.Claims; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Tharga.Mcp; +using Tharga.Platform.Mcp; +using Tharga.Team; + +namespace Tharga.Platform.Mcp.Tests; + +public class PlatformUserResourceProviderTests +{ + private readonly IUserService _userService = Substitute.For(); + private readonly ITeamService _teamService = Substitute.For(); + private readonly IHttpContextAccessor _httpContextAccessor = Substitute.For(); + + private IMcpContext MakeContext(string userId) + { + var ctx = Substitute.For(); + ctx.UserId.Returns(userId); + ctx.Scope.Returns(McpScope.User); + return ctx; + } + + private static async IAsyncEnumerable ToAsyncEnumerable(params T[] items) + { + foreach (var item in items) yield return item; + await Task.CompletedTask; + } + + private PlatformUserResourceProvider CreateSut() + => new(_userService, _teamService, _httpContextAccessor); + + [Fact] + public async Task ListResourcesAsync_AnonymousContext_ReturnsEmpty() + { + var sut = CreateSut(); + + var result = await sut.ListResourcesAsync(MakeContext(userId: null), TestContext.Current.CancellationToken); + + Assert.Empty(result); + } + + [Fact] + public async Task ListResourcesAsync_AuthenticatedContext_ReturnsMeResource() + { + var sut = CreateSut(); + + var result = await sut.ListResourcesAsync(MakeContext(userId: "u-1"), TestContext.Current.CancellationToken); + + var descriptor = Assert.Single(result); + Assert.Equal(PlatformUserResourceProvider.MeUri, descriptor.Uri); + Assert.Equal("application/json", descriptor.MimeType); + } + + [Fact] + public async Task ReadResourceAsync_UnknownUri_Throws() + { + var sut = CreateSut(); + + await Assert.ThrowsAsync(() => + sut.ReadResourceAsync("platform://nope", MakeContext("u-1"), TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ReadResourceAsync_NoCurrentUser_Throws() + { + _userService.GetCurrentUserAsync(Arg.Any()).Returns((IUser)null); + var sut = CreateSut(); + + await Assert.ThrowsAsync(() => + sut.ReadResourceAsync(PlatformUserResourceProvider.MeUri, MakeContext("u-1"), TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ReadResourceAsync_ReturnsUserAndMemberships() + { + var user = Substitute.For(); + user.Key.Returns("u-alice"); + user.Identity.Returns("alice@example.com"); + user.Name.Returns("Alice"); + user.EMail.Returns("alice@example.com"); + _userService.GetCurrentUserAsync(Arg.Any()).Returns(user); + + var team1 = Substitute.For(); + team1.Key.Returns("T-1"); + team1.Name.Returns("First"); + var team2 = Substitute.For(); + team2.Key.Returns("T-2"); + team2.Name.Returns("Second"); + _teamService.GetTeamsAsync().Returns(ToAsyncEnumerable(team1, team2)); + + var aliceInT1 = Substitute.For(); + aliceInT1.Key.Returns("u-alice"); + aliceInT1.AccessLevel.Returns(AccessLevel.Owner); + aliceInT1.State.Returns(MembershipState.Member); + var bobInT1 = Substitute.For(); + bobInT1.Key.Returns("u-bob"); + _teamService.GetMembersAsync("T-1").Returns(ToAsyncEnumerable(bobInT1, aliceInT1)); + + var aliceInT2 = Substitute.For(); + aliceInT2.Key.Returns("u-alice"); + aliceInT2.AccessLevel.Returns(AccessLevel.User); + aliceInT2.State.Returns(MembershipState.Member); + _teamService.GetMembersAsync("T-2").Returns(ToAsyncEnumerable(aliceInT2)); + + var sut = CreateSut(); + var content = await sut.ReadResourceAsync(PlatformUserResourceProvider.MeUri, MakeContext("u-alice"), TestContext.Current.CancellationToken); + + Assert.Equal(PlatformUserResourceProvider.MeUri, content.Uri); + Assert.Equal("application/json", content.MimeType); + + using var doc = JsonDocument.Parse(content.Text); + var root = doc.RootElement; + var userJson = root.GetProperty("user"); + Assert.Equal("u-alice", userJson.GetProperty("key").GetString()); + Assert.Equal("Alice", userJson.GetProperty("name").GetString()); + Assert.Equal("alice@example.com", userJson.GetProperty("email").GetString()); + + var memberships = root.GetProperty("memberships"); + Assert.Equal(2, memberships.GetArrayLength()); + var first = memberships[0]; + Assert.Equal("T-1", first.GetProperty("teamKey").GetString()); + Assert.Equal("First", first.GetProperty("teamName").GetString()); + Assert.Equal((int)AccessLevel.Owner, first.GetProperty("accessLevel").GetInt32()); + } +} diff --git a/Tharga.Platform.Mcp/McpPlatformBuilderExtensions.cs b/Tharga.Platform.Mcp/McpPlatformBuilderExtensions.cs index faa717f..913b7a1 100644 --- a/Tharga.Platform.Mcp/McpPlatformBuilderExtensions.cs +++ b/Tharga.Platform.Mcp/McpPlatformBuilderExtensions.cs @@ -42,6 +42,11 @@ public static IThargaMcpBuilder AddPlatform(this IThargaMcpBuilder builder, Acti scopes.Register(McpScopes.Discover, AccessLevel.Viewer); }); + // Always-on user-scope and team-scope resource providers. They self-gate on the + // principal's UserId / TeamKey claim, so anonymous and system-only callers see nothing. + builder.AddResourceProvider(); + builder.AddResourceProvider(); + // Opt-in system-scope resource providers (diagnostic data for Developers). if (options.ExposeSystemResources) { diff --git a/Tharga.Platform.Mcp/PlatformTeamResourceProvider.cs b/Tharga.Platform.Mcp/PlatformTeamResourceProvider.cs new file mode 100644 index 0000000..afeac84 --- /dev/null +++ b/Tharga.Platform.Mcp/PlatformTeamResourceProvider.cs @@ -0,0 +1,172 @@ +using System.Text.Json; +using Tharga.Mcp; +using Tharga.Team; + +namespace Tharga.Platform.Mcp; + +/// +/// MCP resource provider that surfaces the caller's *current* team under platform://team*. +/// Scope is ; the resource set is gated on a TeamKey claim being +/// present on the principal — anonymous or non-team callers see no resources and read attempts throw +/// . +/// +/// Cross-tenant enumeration (reading other teams the caller does not belong to) is intentionally +/// not supported here — that lives in a System-scope provider once ITeamService.GetAllTeamsAsync +/// is added. +/// +public sealed class PlatformTeamResourceProvider : IMcpResourceProvider +{ + private readonly ITeamService _teamService; + private readonly IApiKeyAdministrationService _apiKeyAdministrationService; + + public const string TeamUri = "platform://team"; + public const string MembersUri = "platform://team/members"; + public const string ApiKeysUri = "platform://team/apikeys"; + + private static readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = true, + }; + + public PlatformTeamResourceProvider( + ITeamService teamService, + IApiKeyAdministrationService apiKeyAdministrationService = null) + { + _teamService = teamService; + _apiKeyAdministrationService = apiKeyAdministrationService; + } + + public McpScope Scope => McpScope.Team; + + public Task> ListResourcesAsync(IMcpContext context, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(context?.TeamId)) + return Task.FromResult>(Array.Empty()); + + var list = new List + { + new() + { + Uri = TeamUri, + Name = "Current Team", + Description = "Metadata for the caller's current team (Key, Name, Icon, ConsentedRoles).", + MimeType = "application/json", + }, + new() + { + Uri = MembersUri, + Name = "Current Team Members", + Description = "Members of the caller's current team.", + MimeType = "application/json", + }, + }; + + if (_apiKeyAdministrationService != null) + { + list.Add(new McpResourceDescriptor + { + Uri = ApiKeysUri, + Name = "Current Team API Keys", + Description = "API keys for the caller's current team. Raw key values are redacted.", + MimeType = "application/json", + }); + } + + return Task.FromResult>(list); + } + + public async Task ReadResourceAsync(string uri, IMcpContext context, CancellationToken cancellationToken) + { + var teamKey = context?.TeamId; + if (string.IsNullOrEmpty(teamKey)) + throw new UnauthorizedAccessException("No team selected."); + + return uri switch + { + TeamUri => await ReadTeamAsync(teamKey, cancellationToken), + MembersUri => await ReadMembersAsync(teamKey, cancellationToken), + ApiKeysUri => await ReadApiKeysAsync(teamKey, cancellationToken), + _ => throw new InvalidOperationException($"Unknown resource URI '{uri}'."), + }; + } + + private async Task ReadTeamAsync(string teamKey, CancellationToken cancellationToken) + { + ITeam team = null; + await foreach (var candidate in _teamService.GetTeamsAsync().WithCancellation(cancellationToken)) + { + if (candidate.Key == teamKey) { team = candidate; break; } + } + if (team == null) throw new InvalidOperationException($"Team '{teamKey}' not found for the caller."); + + var payload = new + { + key = team.Key, + name = team.Name, + icon = team.Icon, + consentedRoles = team.ConsentedRoles ?? Array.Empty(), + }; + + return new McpResourceContent + { + Uri = TeamUri, + Text = JsonSerializer.Serialize(payload, _jsonOptions), + MimeType = "application/json", + }; + } + + private async Task ReadMembersAsync(string teamKey, CancellationToken cancellationToken) + { + var items = new List(); + await foreach (var member in _teamService.GetMembersAsync(teamKey).WithCancellation(cancellationToken)) + { + items.Add(new + { + key = member.Key, + name = member.Name, + accessLevel = member.AccessLevel, + state = member.State, + tenantRoles = member.TenantRoles ?? Array.Empty(), + scopeOverrides = member.ScopeOverrides ?? Array.Empty(), + invited = member.Invitation != null, + }); + } + + return new McpResourceContent + { + Uri = MembersUri, + Text = JsonSerializer.Serialize(new { teamKey, items }, _jsonOptions), + MimeType = "application/json", + }; + } + + private async Task ReadApiKeysAsync(string teamKey, CancellationToken cancellationToken) + { + if (_apiKeyAdministrationService == null) + throw new InvalidOperationException("IApiKeyAdministrationService is not registered."); + + var items = new List(); + await foreach (var key in _apiKeyAdministrationService.GetKeysAsync(teamKey).WithCancellation(cancellationToken)) + { + items.Add(new + { + key = key.Key, + name = key.Name, + accessLevel = key.AccessLevel, + roles = key.Roles ?? Array.Empty(), + scopeOverrides = key.ScopeOverrides ?? Array.Empty(), + expiryDate = key.ExpiryDate, + createdAt = key.CreatedAt, + createdBy = key.CreatedBy, + // Raw ApiKey value intentionally omitted — redaction pattern. + }); + } + + return new McpResourceContent + { + Uri = ApiKeysUri, + Text = JsonSerializer.Serialize(new { teamKey, items }, _jsonOptions), + MimeType = "application/json", + }; + } +} diff --git a/Tharga.Platform.Mcp/PlatformUserResourceProvider.cs b/Tharga.Platform.Mcp/PlatformUserResourceProvider.cs new file mode 100644 index 0000000..079731a --- /dev/null +++ b/Tharga.Platform.Mcp/PlatformUserResourceProvider.cs @@ -0,0 +1,106 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Tharga.Mcp; +using Tharga.Team; + +namespace Tharga.Platform.Mcp; + +/// +/// MCP resource provider that surfaces the authenticated caller's own user identity and +/// team memberships under platform://me. Scope is — +/// the dispatcher's hierarchy filter lets Team and System callers see this too. +/// +public sealed class PlatformUserResourceProvider : IMcpResourceProvider +{ + private readonly IUserService _userService; + private readonly ITeamService _teamService; + private readonly IHttpContextAccessor _httpContextAccessor; + + public const string MeUri = "platform://me"; + + private static readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = true, + }; + + public PlatformUserResourceProvider( + IUserService userService, + ITeamService teamService, + IHttpContextAccessor httpContextAccessor) + { + _userService = userService; + _teamService = teamService; + _httpContextAccessor = httpContextAccessor; + } + + public McpScope Scope => McpScope.User; + + public Task> ListResourcesAsync(IMcpContext context, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(context?.UserId)) + return Task.FromResult>(Array.Empty()); + + return Task.FromResult>(new[] + { + new McpResourceDescriptor + { + Uri = MeUri, + Name = "Current User", + Description = "The authenticated caller's user identity and team memberships.", + MimeType = "application/json", + } + }); + } + + public async Task ReadResourceAsync(string uri, IMcpContext context, CancellationToken cancellationToken) + { + if (uri != MeUri) + throw new InvalidOperationException($"Unknown resource URI '{uri}'."); + + var principal = _httpContextAccessor.HttpContext?.User; + var user = await _userService.GetCurrentUserAsync(principal); + if (user == null) + throw new UnauthorizedAccessException("Authentication required."); + + var memberships = new List(); + await foreach (var team in _teamService.GetTeamsAsync().WithCancellation(cancellationToken)) + { + var memberRow = await FindMemberAsync(team.Key, user.Key, cancellationToken); + memberships.Add(new + { + teamKey = team.Key, + teamName = team.Name, + accessLevel = memberRow?.AccessLevel, + state = memberRow?.State, + }); + } + + var payload = new + { + user = new + { + key = user.Key, + identity = user.Identity, + name = user.Name, + email = user.EMail, + }, + memberships, + }; + + return new McpResourceContent + { + Uri = MeUri, + Text = JsonSerializer.Serialize(payload, _jsonOptions), + MimeType = "application/json", + }; + } + + private async Task FindMemberAsync(string teamKey, string userKey, CancellationToken cancellationToken) + { + await foreach (var member in _teamService.GetMembersAsync(teamKey).WithCancellation(cancellationToken)) + { + if (member.Key == userKey) return member; + } + return null; + } +} diff --git a/Tharga.Platform.Mcp/README.md b/Tharga.Platform.Mcp/README.md index 8de738e..e1e9647 100644 --- a/Tharga.Platform.Mcp/README.md +++ b/Tharga.Platform.Mcp/README.md @@ -25,6 +25,28 @@ builder.Services.AddThargaMcp(mcp => app.UseThargaMcp(); ``` +## User and team resources + +Always-on resource providers that surface the authenticated caller's own data. Both providers self-gate on the principal's claims, so anonymous and system-only callers see no resources. + +### User scope (`McpScope.User`) + +| URI | Contents | +|-----|----------| +| `platform://me` | The caller's `IUser` (`key`, `identity`, `name`, `email`) and a `memberships` array — for each team the caller is in, its `teamKey`, `teamName`, plus the caller's `accessLevel` and membership `state`. | + +Listed when the principal carries a `NameIdentifier` (or equivalent) claim. Read fails with `UnauthorizedAccessException` if `IUserService.GetCurrentUserAsync` returns null. + +### Team scope (`McpScope.Team`) + +| URI | Contents | +|-----|----------| +| `platform://team` | Metadata for the caller's *current* team (from the `TeamKey` claim): `key`, `name`, `icon`, `consentedRoles`. | +| `platform://team/members` | Members of the current team: `key`, `name`, `accessLevel`, `state`, `tenantRoles`, `scopeOverrides`, and an `invited` flag. | +| `platform://team/apikeys` | API keys for the current team. Raw key values are redacted (the `apiKey` property is omitted entirely). Listed only when `IApiKeyAdministrationService` is registered. | + +Listed only when the principal carries a `TeamKey` claim. Read fails with `UnauthorizedAccessException` if no team is selected. Cross-tenant team listing (reading other teams) is intentionally not supported here — that's a future system-scope provider once `ITeamService.GetAllTeamsAsync` is added. + ## 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. @@ -47,7 +69,7 @@ Available resources (listed only when the matching dependency is registered): | `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. +Per-team API-key listings now ship under `platform://team/apikeys` (see "Team scope" above). Cross-tenant team listings remain deferred — they require a new `ITeamService.GetAllTeamsAsync` method. ## Related packages diff --git a/Tharga.Team.Service.Tests/TeamServiceBaseGetMembersAsyncTests.cs b/Tharga.Team.Service.Tests/TeamServiceBaseGetMembersAsyncTests.cs new file mode 100644 index 0000000..6d15c00 --- /dev/null +++ b/Tharga.Team.Service.Tests/TeamServiceBaseGetMembersAsyncTests.cs @@ -0,0 +1,45 @@ +namespace Tharga.Team.Service.Tests; + +/// +/// Verifies the default implementation +/// — which uses reflection internally to read the typed team's Members array — +/// yields the underlying members as without forcing the caller +/// to know the consumer-specific TMember type. +/// +public class TeamServiceBaseGetMembersAsyncTests +{ + private readonly IUserService _userService = Substitute.For(); + + [Fact] + public async Task GetMembersAsync_ReturnsAllMembers_AsITeamMember() + { + var sut = new TestTeamService(_userService); + sut.AddTeam("team-1", "Test Team", + new TestMember { Key = "u-1", AccessLevel = AccessLevel.Owner, State = MembershipState.Member }, + new TestMember { Key = "u-2", AccessLevel = AccessLevel.User, State = MembershipState.Member }); + + var members = new List(); + await foreach (var member in sut.GetMembersAsync("team-1")) + { + members.Add(member); + } + + Assert.Equal(2, members.Count); + Assert.Equal("u-1", members[0].Key); + Assert.Equal("u-2", members[1].Key); + } + + [Fact] + public async Task GetMembersAsync_UnknownTeam_ReturnsEmpty() + { + var sut = new TestTeamService(_userService); + + var members = new List(); + await foreach (var member in sut.GetMembersAsync("does-not-exist")) + { + members.Add(member); + } + + Assert.Empty(members); + } +} diff --git a/Tharga.Team.Service/Audit/AuditingTeamServiceDecorator.cs b/Tharga.Team.Service/Audit/AuditingTeamServiceDecorator.cs index 491e3bd..29f088a 100644 --- a/Tharga.Team.Service/Audit/AuditingTeamServiceDecorator.cs +++ b/Tharga.Team.Service/Audit/AuditingTeamServiceDecorator.cs @@ -41,6 +41,7 @@ public event EventHandler SelectTeamEvent public IAsyncEnumerable> GetTeamsAsync() where TMember : ITeamMember => _inner.GetTeamsAsync(); public Task> GetTeamAsync(string teamKey) where TMember : ITeamMember => _inner.GetTeamAsync(teamKey); public Task GetTeamMemberAsync(string teamKey, string userKey) => _inner.GetTeamMemberAsync(teamKey, userKey); + public IAsyncEnumerable GetMembersAsync(string teamKey) => _inner.GetMembersAsync(teamKey); public IAsyncEnumerable GetConsentedTeamsAsync(string[] userRoles) => _inner.GetConsentedTeamsAsync(userRoles); public Task SetMemberLastSeenAsync(string teamKey) => _inner.SetMemberLastSeenAsync(teamKey); diff --git a/Tharga.Team/ITeamService.cs b/Tharga.Team/ITeamService.cs index 5099f50..04f7349 100644 --- a/Tharga.Team/ITeamService.cs +++ b/Tharga.Team/ITeamService.cs @@ -12,6 +12,7 @@ public interface ITeamService Task RenameTeamAsync(string teamKey, string name) where TMember : ITeamMember; Task DeleteTeamAsync(string teamKey) where TMember : ITeamMember; Task GetTeamMemberAsync(string teamKey, string userKey); + IAsyncEnumerable GetMembersAsync(string teamKey); Task AddMemberAsync(string teamKey, InviteUserModel model); Task RemoveMemberAsync(string teamKey, string userKey); Task SetMemberRoleAsync(string teamKey, string userKey, AccessLevel accessLevel); diff --git a/Tharga.Team/README.md b/Tharga.Team/README.md index 128623a..20145b6 100644 --- a/Tharga.Team/README.md +++ b/Tharga.Team/README.md @@ -14,7 +14,7 @@ Domain models, service abstractions, and authorization primitives for multi-tena - `Invitation`, `InviteUserModel`, `MembershipState`. ### Service interfaces -- `ITeamService` - Team CRUD, member management, invitations. +- `ITeamService` - Team CRUD, member management, invitations. Includes `GetMembersAsync(teamKey)` returning `IAsyncEnumerable` for consumers that need to enumerate members without knowing the per-consumer `TMember` type. - `ITeamManagementService` - Scope-enforced mutations (create, rename, delete, invite, etc.). - `IUserService` - Current user resolution. - `IApiKeyAdministrationService` / `IApiKeyManagementService` - API key management. diff --git a/Tharga.Team/TeamServiceBase.cs b/Tharga.Team/TeamServiceBase.cs index 94645c9..163f5e4 100644 --- a/Tharga.Team/TeamServiceBase.cs +++ b/Tharga.Team/TeamServiceBase.cs @@ -111,6 +111,17 @@ public async Task GetTeamMemberAsync(string teamKey, string userKey return teamMember; } + public virtual async IAsyncEnumerable GetMembersAsync(string teamKey) + { + var team = await GetTeamAsync(teamKey); + var members = GetMembersFromTeam(team); + if (members == null) yield break; + foreach (var member in members) + { + yield return member; + } + } + public async Task AddMemberAsync(string teamKey, InviteUserModel model) { await AddTeamMemberAsync(teamKey, model); diff --git a/plan/feature.md b/plan/feature.md new file mode 100644 index 0000000..493b321 --- /dev/null +++ b/plan/feature.md @@ -0,0 +1,55 @@ +# Feature: MCP user-scope and team-scope resources + +Tracks `Requests.md` → *"Tharga.Platform — MCP / MCP Provider for Team/User data"* (Status: Partial). System slice shipped via PR #51 (2026-04-20); this feature extends the bridge to user-scope and team-scope. + +## Goal + +Surface the most useful per-caller Platform data over MCP so an LLM agent operating on behalf of an authenticated user can answer questions like "what teams am I a member of?" or "show the members of my current team" without the consumer having to wire its own MCP provider. + +Two new resource providers in `Tharga.Platform.Mcp`, registered automatically by `AddPlatform()` when the required dependencies are present: + +- **`PlatformUserResourceProvider`** (`McpScope.User`): exposes `platform://me`. +- **`PlatformTeamResourceProvider`** (`McpScope.Team`): exposes `platform://team`, `platform://team/members`, `platform://team/apikeys` — each one rooted at the caller's *current* team (from the `TeamKey` claim). No cross-tenant enumeration. + +The system-scope cross-tenant team listing (`platform://system/teams` / `platform://teams/{teamKey}/...`) is **out of scope** for this feature — it requires a new `ITeamService.GetAllTeamsAsync()` method and is tracked as a separate follow-up. + +## Scope + +Three pieces: + +1. **`PlatformUserResourceProvider`** — single resource `platform://me` listing the caller's `IUser` (UserId, Name, EMail, Identity) and a memberships array built from `ITeamService.GetTeamsAsync()` (already current-user-scoped). +2. **`PlatformTeamResourceProvider`** — three resources rooted at the caller's current team: + - `platform://team` — team metadata: Key, Name, Icon, ConsentedRoles. + - `platform://team/members` — members of the current team (Key, Name, AccessLevel, State, TenantRoles, ScopeOverrides, Invitation presence flag). + - `platform://team/apikeys` — `IApiKeyAdministrationService.GetKeysAsync(teamKey)` results with raw `ApiKey` values redacted (the same redaction pattern the system slice uses). +3. **New `ITeamService.GetMembersAsync(string teamKey)`** returning `IAsyncEnumerable`. Default implementation in `TeamServiceBase` reads the typed team via the existing protected non-generic `GetTeamAsync(teamKey)` and extracts `Members` via a one-line reflection helper. Consumers subclassing `TeamServiceBase` get this for free; consumers implementing `ITeamService` directly need to add it (rare). + +## Behaviour + +- Both providers are registered unconditionally by `AddPlatform()` — no new opt-in option. They are scope-gated by `McpScope.User` / `McpScope.Team`, which is enforced by the dispatcher's hierarchy filter; an anonymous or System-only caller doesn't see them. +- `PlatformUserResourceProvider.ReadResourceAsync("platform://me")` requires an authenticated user (the dispatcher should have already ensured this; we add a defensive null-check that throws `UnauthorizedAccessException` if missing). +- `PlatformTeamResourceProvider` requires a `TeamKey` claim on the principal. If the caller doesn't have one (no current team selected), `ListResourcesAsync` returns an empty list and `ReadResourceAsync` throws `UnauthorizedAccessException` with a "No team selected" message. +- All resource payloads are JSON, MimeType `application/json`, using the same indented-JSON pattern as `PlatformSystemResourceProvider`. + +## Acceptance criteria + +1. `AddPlatform()` registers `PlatformUserResourceProvider` and `PlatformTeamResourceProvider`. Existing system-scope provider registration is unchanged. +2. New `Task> GetMembersAsync(string teamKey)` (or `IAsyncEnumerable` shape — confirm during step 1) on `ITeamService` with a default `TeamServiceBase` implementation that uses reflection on the non-generic `GetTeamAsync` result. The reflection happens *once* inside `TeamServiceBase`; callers get a typed enumerable. +3. `platform://me` returns JSON shaped `{ user: { key, identity, name, email }, memberships: [{ teamKey, teamName, accessLevel, state }] }`. +4. `platform://team` returns `{ key, name, icon, consentedRoles }` for the caller's current team. +5. `platform://team/members` returns `{ teamKey, items: [{ key, name, accessLevel, state, tenantRoles, scopeOverrides, invited }] }`. +6. `platform://team/apikeys` returns `{ teamKey, items: [{ key, name, accessLevel, expiryDate, createdAt, createdBy }] }` — raw `ApiKey` values redacted (omitted from output entirely, matching the system slice's pattern). +7. Unit tests cover: provider listing returns empty for missing principal/TeamKey; provider read throws `UnauthorizedAccessException` for missing prereqs; happy-path read returns expected JSON shape; `TeamServiceBase.GetMembersAsync` returns the typed enumerable. +8. `Tharga.Platform.Mcp/README.md` updated with a "User and team resources" section. +9. `dotnet build -c Release` clean; `dotnet test -c Release` green. + +## Done condition + +PR opened from `feature/mcp-user-and-team-scope` → `master`, all CI checks green, user has confirmed. + +## Out of scope + +- `platform://system/teams` cross-tenant team enumeration — requires a new `ITeamService.GetAllTeamsAsync()` method. Filed as a future follow-up; the current MCP partial entry in `Requests.md` stays open for that piece (downgraded to "cross-tenant enumeration only"). +- User-level "personal" API keys — Tharga.Team doesn't model these; only team and system keys exist. +- Tool providers (`IMcpToolProvider`) — this feature is read-only resources only. Mutating operations (create team, change role, etc.) are intentionally not exposed. +- New scope constants in `McpScopes` — the existing `mcp:discover` covers listing; no per-resource read scope is added because the providers self-gate on principal/TeamKey presence. diff --git a/plan/plan.md b/plan/plan.md new file mode 100644 index 0000000..c4b42a3 --- /dev/null +++ b/plan/plan.md @@ -0,0 +1,90 @@ +# Plan: MCP user-scope and team-scope resources + +## Steps + +- [x] **1. Add `GetMembersAsync` to `ITeamService` + default `TeamServiceBase` implementation** + - Declare `IAsyncEnumerable GetMembersAsync(string teamKey)` on `ITeamService`. Use `IAsyncEnumerable` (not `Task`) so the dispatcher can stream large member lists if needed; matches `GetTeamsAsync()` shape. + - In `TeamServiceBase`, provide a non-abstract implementation: + ```csharp + public virtual async IAsyncEnumerable GetMembersAsync(string teamKey) + { + var team = await GetTeamAsync(teamKey); // protected non-generic + if (team == null) yield break; + var membersProperty = team.GetType().GetProperty("Members"); + if (membersProperty?.GetValue(team) is System.Collections.IEnumerable members) + { + foreach (var member in members.OfType()) + yield return member; + } + } + ``` + Reflection is contained inside `TeamServiceBase`; callers see a typed `IAsyncEnumerable`. + - The `TeamManagementService` decorator (`Tharga.Team/TeamManagementService.cs`) — verify whether it implements `ITeamService` directly. If yes, add a forwarding pass-through. (Probably no — it's a separate `ITeamManagementService` decorator.) + - `TestTeamService` (in `Tharga.Team.Service.Tests`) and `StubTeamService` (in `Tharga.Team.Blazor.Tests`) inherit `TeamServiceBase` — they'll pick up the default implementation. Verify nothing breaks. + +- [x] **2. `PlatformUserResourceProvider`** + - New file `Tharga.Platform.Mcp/PlatformUserResourceProvider.cs`. Implements `IMcpResourceProvider`, `Scope = McpScope.User`. + - Constructor injects `IUserService` and `ITeamService`. Plus `IHttpContextAccessor` for the current `ClaimsPrincipal` (the `IMcpContext` has UserId but `IUserService.GetCurrentUserAsync` wants a `ClaimsPrincipal` — pass through HttpContext). + - `ListResourcesAsync` — if `context?.UserId == null` return empty array; else return single descriptor for `platform://me`. + - `ReadResourceAsync("platform://me")`: + - Get current user via `_userService.GetCurrentUserAsync()` (HttpContext-derived). + - Get memberships via `await foreach var t in _teamService.GetTeamsAsync()`. For each, project `{ teamKey: t.Key, teamName: t.Name }`. Access level comes from the member row matching the current user — use the new `GetMembersAsync(t.Key)` and find the entry by user.Key. + - Serialize with the same `_jsonOptions` pattern as `PlatformSystemResourceProvider`. + - URI constant: `public const string MeUri = "platform://me";` + +- [x] **3. `PlatformTeamResourceProvider`** + - New file `Tharga.Platform.Mcp/PlatformTeamResourceProvider.cs`. Implements `IMcpResourceProvider`, `Scope = McpScope.Team`. + - Constructor injects `ITeamService` and `IApiKeyAdministrationService` (the latter optional — only enables the apikeys resource when registered). + - URI constants: + ```csharp + public const string TeamUri = "platform://team"; + public const string MembersUri = "platform://team/members"; + public const string ApiKeysUri = "platform://team/apikeys"; + ``` + - `ListResourcesAsync` — if `context?.TeamId` (the `TeamKey` claim) is null/empty, return empty array. Otherwise return descriptors for `TeamUri`, `MembersUri`, and (if `_apiKeyAdministrationService != null`) `ApiKeysUri`. + - `ReadResourceAsync`: + - Require `context?.TeamId` non-null; throw `UnauthorizedAccessException("No team selected.")` if missing. + - `TeamUri` → fetch non-generic `ITeam` via `_teamService.GetTeamsAsync().FirstOrDefaultAsync(t => t.Key == context.TeamId)` (avoids needing a new non-generic `GetTeamAsync` on `ITeamService` — uses existing `GetTeamsAsync()` filtered by Key). Serialize `{ key, name, icon, consentedRoles }`. + - `MembersUri` → `await foreach var m in _teamService.GetMembersAsync(context.TeamId)`. Project `{ key, name, accessLevel, state, tenantRoles, scopeOverrides, invited: m.Invitation != null }`. + - `ApiKeysUri` → `await foreach var k in _apiKeyAdministrationService.GetKeysAsync(context.TeamId)`. Project `{ key, name, accessLevel, expiryDate, createdAt, createdBy }` — deliberately omit raw `ApiKey` value (redaction pattern). + +- [x] **4. Register both providers in `McpPlatformBuilderExtensions.AddPlatform`** + - Always register `PlatformUserResourceProvider` (always available; `IUserService` and `ITeamService` are core Platform services). + - Always register `PlatformTeamResourceProvider`. The provider self-gates on TeamKey claim presence; `IApiKeyAdministrationService` is optional via the existing nullable-constructor pattern. + - Existing `ExposeSystemResources` opt-in for `PlatformSystemResourceProvider` is unchanged. + +- [x] **5. Tests** + - Add tests to existing `Tharga.Platform.Mcp.Tests` project. + - `PlatformUserResourceProviderTests`: empty list when context.UserId is null; happy-path list returns one descriptor; happy-path read returns user + memberships JSON; missing user (race / race between auth and read) throws or returns empty. + - `PlatformTeamResourceProviderTests`: empty list when context.TeamId is null; list returns 2 descriptors when ApiKey service absent, 3 when present; read throws `UnauthorizedAccessException` when TeamId null; happy-path read for each of the three URIs returns expected JSON shape with redacted apikey values. + - `TeamServiceBaseGetMembersAsyncTests`: in `Tharga.Team.Service.Tests`. Build a `TestTeamService` with a team containing members; assert `GetMembersAsync(teamKey)` yields them in order. + - Verify the existing `PlatformSystemResourceProviderTests` still pass. + +- [x] **6. Update `Tharga.Platform.Mcp/README.md`** + - Add a new "User and team resources" section before "System-scope diagnostic resources". Document the three new URIs + payload shapes. Move the existing system-scope section's deferred-work note to reflect that user+team are now shipped; cross-tenant is the only remaining piece. + +- [x] **7. Update `Tharga.Team/README.md`** + - Mention the new `GetMembersAsync` on `ITeamService` in the "Service interfaces" bullet (one-line addition). + +- [x] **8. Build + full test suite** + - `dotnet build c:/dev/tharga/Toolkit/Platform/Tharga.Platform.sln -c Release` clean. + - `dotnet test c:/dev/tharga/Toolkit/Platform/Tharga.Platform.sln -c Release` green. + +- [x] **9. Commit + push the feature branch** + - Conventional prefix: `feat:` — this adds new MCP surface (not just a fix). + - Suggested message: `feat: MCP user-scope and team-scope resource providers`. + +- [x] **10. Pause for user verification.** Plan/ stays on the feature branch; deleted in the close-out commit before the PR opens (per the principle in shared-instructions). + +## Verification approach + +- After step 1, run `Tharga.Team.Service.Tests` to confirm the new `GetMembersAsync` default works with existing TestTeamService. +- After step 4, run `Tharga.Platform.Mcp.Tests` to confirm the existing system-slice tests still pass with the new providers registered. +- Build between every step that adds a new file. + +## Open questions + +(none — three design choices were locked via `AskUserQuestion` during planning: scope = user+team, member API = new ITeamService method, URI shape = `platform://team*`) + +## Last session +2026-05-11 — All implementation steps complete. 17 new tests (5 user-provider, 10 team-provider, 2 GetMembersAsync); 313 total green. READMEs updated. Ready for commit + push + user verification. From b87206e27059c5593cdd7a03ad3844f61a1c6dc8 Mon Sep 17 00:00:00 2001 From: Daniel Bohlin Date: Mon, 11 May 2026 14:57:22 +0200 Subject: [PATCH 2/2] feat: mcp-user-and-team-scope complete --- plan/feature.md | 55 ------------------------------ plan/plan.md | 90 ------------------------------------------------- 2 files changed, 145 deletions(-) delete mode 100644 plan/feature.md delete mode 100644 plan/plan.md diff --git a/plan/feature.md b/plan/feature.md deleted file mode 100644 index 493b321..0000000 --- a/plan/feature.md +++ /dev/null @@ -1,55 +0,0 @@ -# Feature: MCP user-scope and team-scope resources - -Tracks `Requests.md` → *"Tharga.Platform — MCP / MCP Provider for Team/User data"* (Status: Partial). System slice shipped via PR #51 (2026-04-20); this feature extends the bridge to user-scope and team-scope. - -## Goal - -Surface the most useful per-caller Platform data over MCP so an LLM agent operating on behalf of an authenticated user can answer questions like "what teams am I a member of?" or "show the members of my current team" without the consumer having to wire its own MCP provider. - -Two new resource providers in `Tharga.Platform.Mcp`, registered automatically by `AddPlatform()` when the required dependencies are present: - -- **`PlatformUserResourceProvider`** (`McpScope.User`): exposes `platform://me`. -- **`PlatformTeamResourceProvider`** (`McpScope.Team`): exposes `platform://team`, `platform://team/members`, `platform://team/apikeys` — each one rooted at the caller's *current* team (from the `TeamKey` claim). No cross-tenant enumeration. - -The system-scope cross-tenant team listing (`platform://system/teams` / `platform://teams/{teamKey}/...`) is **out of scope** for this feature — it requires a new `ITeamService.GetAllTeamsAsync()` method and is tracked as a separate follow-up. - -## Scope - -Three pieces: - -1. **`PlatformUserResourceProvider`** — single resource `platform://me` listing the caller's `IUser` (UserId, Name, EMail, Identity) and a memberships array built from `ITeamService.GetTeamsAsync()` (already current-user-scoped). -2. **`PlatformTeamResourceProvider`** — three resources rooted at the caller's current team: - - `platform://team` — team metadata: Key, Name, Icon, ConsentedRoles. - - `platform://team/members` — members of the current team (Key, Name, AccessLevel, State, TenantRoles, ScopeOverrides, Invitation presence flag). - - `platform://team/apikeys` — `IApiKeyAdministrationService.GetKeysAsync(teamKey)` results with raw `ApiKey` values redacted (the same redaction pattern the system slice uses). -3. **New `ITeamService.GetMembersAsync(string teamKey)`** returning `IAsyncEnumerable`. Default implementation in `TeamServiceBase` reads the typed team via the existing protected non-generic `GetTeamAsync(teamKey)` and extracts `Members` via a one-line reflection helper. Consumers subclassing `TeamServiceBase` get this for free; consumers implementing `ITeamService` directly need to add it (rare). - -## Behaviour - -- Both providers are registered unconditionally by `AddPlatform()` — no new opt-in option. They are scope-gated by `McpScope.User` / `McpScope.Team`, which is enforced by the dispatcher's hierarchy filter; an anonymous or System-only caller doesn't see them. -- `PlatformUserResourceProvider.ReadResourceAsync("platform://me")` requires an authenticated user (the dispatcher should have already ensured this; we add a defensive null-check that throws `UnauthorizedAccessException` if missing). -- `PlatformTeamResourceProvider` requires a `TeamKey` claim on the principal. If the caller doesn't have one (no current team selected), `ListResourcesAsync` returns an empty list and `ReadResourceAsync` throws `UnauthorizedAccessException` with a "No team selected" message. -- All resource payloads are JSON, MimeType `application/json`, using the same indented-JSON pattern as `PlatformSystemResourceProvider`. - -## Acceptance criteria - -1. `AddPlatform()` registers `PlatformUserResourceProvider` and `PlatformTeamResourceProvider`. Existing system-scope provider registration is unchanged. -2. New `Task> GetMembersAsync(string teamKey)` (or `IAsyncEnumerable` shape — confirm during step 1) on `ITeamService` with a default `TeamServiceBase` implementation that uses reflection on the non-generic `GetTeamAsync` result. The reflection happens *once* inside `TeamServiceBase`; callers get a typed enumerable. -3. `platform://me` returns JSON shaped `{ user: { key, identity, name, email }, memberships: [{ teamKey, teamName, accessLevel, state }] }`. -4. `platform://team` returns `{ key, name, icon, consentedRoles }` for the caller's current team. -5. `platform://team/members` returns `{ teamKey, items: [{ key, name, accessLevel, state, tenantRoles, scopeOverrides, invited }] }`. -6. `platform://team/apikeys` returns `{ teamKey, items: [{ key, name, accessLevel, expiryDate, createdAt, createdBy }] }` — raw `ApiKey` values redacted (omitted from output entirely, matching the system slice's pattern). -7. Unit tests cover: provider listing returns empty for missing principal/TeamKey; provider read throws `UnauthorizedAccessException` for missing prereqs; happy-path read returns expected JSON shape; `TeamServiceBase.GetMembersAsync` returns the typed enumerable. -8. `Tharga.Platform.Mcp/README.md` updated with a "User and team resources" section. -9. `dotnet build -c Release` clean; `dotnet test -c Release` green. - -## Done condition - -PR opened from `feature/mcp-user-and-team-scope` → `master`, all CI checks green, user has confirmed. - -## Out of scope - -- `platform://system/teams` cross-tenant team enumeration — requires a new `ITeamService.GetAllTeamsAsync()` method. Filed as a future follow-up; the current MCP partial entry in `Requests.md` stays open for that piece (downgraded to "cross-tenant enumeration only"). -- User-level "personal" API keys — Tharga.Team doesn't model these; only team and system keys exist. -- Tool providers (`IMcpToolProvider`) — this feature is read-only resources only. Mutating operations (create team, change role, etc.) are intentionally not exposed. -- New scope constants in `McpScopes` — the existing `mcp:discover` covers listing; no per-resource read scope is added because the providers self-gate on principal/TeamKey presence. diff --git a/plan/plan.md b/plan/plan.md deleted file mode 100644 index c4b42a3..0000000 --- a/plan/plan.md +++ /dev/null @@ -1,90 +0,0 @@ -# Plan: MCP user-scope and team-scope resources - -## Steps - -- [x] **1. Add `GetMembersAsync` to `ITeamService` + default `TeamServiceBase` implementation** - - Declare `IAsyncEnumerable GetMembersAsync(string teamKey)` on `ITeamService`. Use `IAsyncEnumerable` (not `Task`) so the dispatcher can stream large member lists if needed; matches `GetTeamsAsync()` shape. - - In `TeamServiceBase`, provide a non-abstract implementation: - ```csharp - public virtual async IAsyncEnumerable GetMembersAsync(string teamKey) - { - var team = await GetTeamAsync(teamKey); // protected non-generic - if (team == null) yield break; - var membersProperty = team.GetType().GetProperty("Members"); - if (membersProperty?.GetValue(team) is System.Collections.IEnumerable members) - { - foreach (var member in members.OfType()) - yield return member; - } - } - ``` - Reflection is contained inside `TeamServiceBase`; callers see a typed `IAsyncEnumerable`. - - The `TeamManagementService` decorator (`Tharga.Team/TeamManagementService.cs`) — verify whether it implements `ITeamService` directly. If yes, add a forwarding pass-through. (Probably no — it's a separate `ITeamManagementService` decorator.) - - `TestTeamService` (in `Tharga.Team.Service.Tests`) and `StubTeamService` (in `Tharga.Team.Blazor.Tests`) inherit `TeamServiceBase` — they'll pick up the default implementation. Verify nothing breaks. - -- [x] **2. `PlatformUserResourceProvider`** - - New file `Tharga.Platform.Mcp/PlatformUserResourceProvider.cs`. Implements `IMcpResourceProvider`, `Scope = McpScope.User`. - - Constructor injects `IUserService` and `ITeamService`. Plus `IHttpContextAccessor` for the current `ClaimsPrincipal` (the `IMcpContext` has UserId but `IUserService.GetCurrentUserAsync` wants a `ClaimsPrincipal` — pass through HttpContext). - - `ListResourcesAsync` — if `context?.UserId == null` return empty array; else return single descriptor for `platform://me`. - - `ReadResourceAsync("platform://me")`: - - Get current user via `_userService.GetCurrentUserAsync()` (HttpContext-derived). - - Get memberships via `await foreach var t in _teamService.GetTeamsAsync()`. For each, project `{ teamKey: t.Key, teamName: t.Name }`. Access level comes from the member row matching the current user — use the new `GetMembersAsync(t.Key)` and find the entry by user.Key. - - Serialize with the same `_jsonOptions` pattern as `PlatformSystemResourceProvider`. - - URI constant: `public const string MeUri = "platform://me";` - -- [x] **3. `PlatformTeamResourceProvider`** - - New file `Tharga.Platform.Mcp/PlatformTeamResourceProvider.cs`. Implements `IMcpResourceProvider`, `Scope = McpScope.Team`. - - Constructor injects `ITeamService` and `IApiKeyAdministrationService` (the latter optional — only enables the apikeys resource when registered). - - URI constants: - ```csharp - public const string TeamUri = "platform://team"; - public const string MembersUri = "platform://team/members"; - public const string ApiKeysUri = "platform://team/apikeys"; - ``` - - `ListResourcesAsync` — if `context?.TeamId` (the `TeamKey` claim) is null/empty, return empty array. Otherwise return descriptors for `TeamUri`, `MembersUri`, and (if `_apiKeyAdministrationService != null`) `ApiKeysUri`. - - `ReadResourceAsync`: - - Require `context?.TeamId` non-null; throw `UnauthorizedAccessException("No team selected.")` if missing. - - `TeamUri` → fetch non-generic `ITeam` via `_teamService.GetTeamsAsync().FirstOrDefaultAsync(t => t.Key == context.TeamId)` (avoids needing a new non-generic `GetTeamAsync` on `ITeamService` — uses existing `GetTeamsAsync()` filtered by Key). Serialize `{ key, name, icon, consentedRoles }`. - - `MembersUri` → `await foreach var m in _teamService.GetMembersAsync(context.TeamId)`. Project `{ key, name, accessLevel, state, tenantRoles, scopeOverrides, invited: m.Invitation != null }`. - - `ApiKeysUri` → `await foreach var k in _apiKeyAdministrationService.GetKeysAsync(context.TeamId)`. Project `{ key, name, accessLevel, expiryDate, createdAt, createdBy }` — deliberately omit raw `ApiKey` value (redaction pattern). - -- [x] **4. Register both providers in `McpPlatformBuilderExtensions.AddPlatform`** - - Always register `PlatformUserResourceProvider` (always available; `IUserService` and `ITeamService` are core Platform services). - - Always register `PlatformTeamResourceProvider`. The provider self-gates on TeamKey claim presence; `IApiKeyAdministrationService` is optional via the existing nullable-constructor pattern. - - Existing `ExposeSystemResources` opt-in for `PlatformSystemResourceProvider` is unchanged. - -- [x] **5. Tests** - - Add tests to existing `Tharga.Platform.Mcp.Tests` project. - - `PlatformUserResourceProviderTests`: empty list when context.UserId is null; happy-path list returns one descriptor; happy-path read returns user + memberships JSON; missing user (race / race between auth and read) throws or returns empty. - - `PlatformTeamResourceProviderTests`: empty list when context.TeamId is null; list returns 2 descriptors when ApiKey service absent, 3 when present; read throws `UnauthorizedAccessException` when TeamId null; happy-path read for each of the three URIs returns expected JSON shape with redacted apikey values. - - `TeamServiceBaseGetMembersAsyncTests`: in `Tharga.Team.Service.Tests`. Build a `TestTeamService` with a team containing members; assert `GetMembersAsync(teamKey)` yields them in order. - - Verify the existing `PlatformSystemResourceProviderTests` still pass. - -- [x] **6. Update `Tharga.Platform.Mcp/README.md`** - - Add a new "User and team resources" section before "System-scope diagnostic resources". Document the three new URIs + payload shapes. Move the existing system-scope section's deferred-work note to reflect that user+team are now shipped; cross-tenant is the only remaining piece. - -- [x] **7. Update `Tharga.Team/README.md`** - - Mention the new `GetMembersAsync` on `ITeamService` in the "Service interfaces" bullet (one-line addition). - -- [x] **8. Build + full test suite** - - `dotnet build c:/dev/tharga/Toolkit/Platform/Tharga.Platform.sln -c Release` clean. - - `dotnet test c:/dev/tharga/Toolkit/Platform/Tharga.Platform.sln -c Release` green. - -- [x] **9. Commit + push the feature branch** - - Conventional prefix: `feat:` — this adds new MCP surface (not just a fix). - - Suggested message: `feat: MCP user-scope and team-scope resource providers`. - -- [x] **10. Pause for user verification.** Plan/ stays on the feature branch; deleted in the close-out commit before the PR opens (per the principle in shared-instructions). - -## Verification approach - -- After step 1, run `Tharga.Team.Service.Tests` to confirm the new `GetMembersAsync` default works with existing TestTeamService. -- After step 4, run `Tharga.Platform.Mcp.Tests` to confirm the existing system-slice tests still pass with the new providers registered. -- Build between every step that adds a new file. - -## Open questions - -(none — three design choices were locked via `AskUserQuestion` during planning: scope = user+team, member API = new ITeamService method, URI shape = `platform://team*`) - -## Last session -2026-05-11 — All implementation steps complete. 17 new tests (5 user-provider, 10 team-provider, 2 GetMembersAsync); 313 total green. READMEs updated. Ready for commit + push + user verification.