Skip to content

Commit 9d803b1

Browse files
merge: resolve conflict in appsettings.json
Keep detailed SystemPrompt from PR branch; drop AllowedMcpTools (dead config - MCP client is commented out, removed in prior commit).
2 parents 74ed475 + 0d2cb75 commit 9d803b1

25 files changed

Lines changed: 967 additions & 352 deletions

.github/workflows/Build-Test-And-Deploy.yml

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -63,23 +63,6 @@ jobs:
6363
- name: Set up Docker Buildx
6464
uses: docker/setup-buildx-action@v4
6565

66-
- name: Generate NuGet auth config for Docker build
67-
if: github.event_name != 'pull_request' && github.event_name != 'merge_group'
68-
env:
69-
NUGET_AUTH_TOKEN: ${{ secrets.AZURE_DEVOPS_PAT }}
70-
run: |
71-
cat > /tmp/nuget-auth.config << EOF
72-
<?xml version="1.0" encoding="utf-8"?>
73-
<configuration>
74-
<packageSourceCredentials>
75-
<EssentialCSharp>
76-
<add key="Username" value="docker" />
77-
<add key="ClearTextPassword" value="${NUGET_AUTH_TOKEN}" />
78-
</EssentialCSharp>
79-
</packageSourceCredentials>
80-
</configuration>
81-
EOF
82-
8366
# Build but no push with a PR
8467
- name: Docker build (no push)
8568
if: github.event_name == 'pull_request' || github.event_name == 'merge_group'
@@ -99,7 +82,7 @@ jobs:
9982
file: ./EssentialCSharp.Web/Dockerfile
10083
context: .
10184
secrets: |
102-
"id=nugetconfig,src=/tmp/nuget-auth.config"
85+
"nuget_pat=${{ secrets.AZURE_DEVOPS_PAT }}"
10386
outputs: type=docker,dest=${{ github.workspace }}/essentialcsharpwebimage.tar
10487
cache-from: type=gha
10588
cache-to: type=gha,mode=max
@@ -109,10 +92,6 @@ jobs:
10992
name: essentialcsharpwebimage
11093
path: ${{ github.workspace }}/essentialcsharpwebimage.tar
11194

112-
- name: Clean up NuGet auth config
113-
if: always()
114-
run: rm -f /tmp/nuget-auth.config
115-
11695
deploy-development:
11796
if: github.event_name != 'pull_request_target' && github.event_name != 'pull_request'
11897
runs-on: ubuntu-latest

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ TestResults/
1010
~$*.do[ct]x
1111
~*.tmp
1212
~$*.dotm
13+
.playwright-mcp/
1314

1415
# Files to keep (primarily book content)
1516
!.gitignore

Directory.Packages.props

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,10 @@
44
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
55
<ToolingPackagesVersion>1.1.1.19025</ToolingPackagesVersion>
66
<AccessToNugetFeed Condition="'$(AccessToNugetFeed)' == ''">false</AccessToNugetFeed>
7-
<RestoreSources>
8-
https://api.nuget.org/v3/index.json;
9-
</RestoreSources>
10-
<RestoreSources Condition="$(AccessToNugetFeed)">
11-
$(RestoreSources);
12-
https://pkgs.dev.azure.com/intelliTect/_packaging/EssentialCSharp/nuget/v3/index.json;
13-
</RestoreSources>
7+
<!-- Disable NuGet vulnerability audit when the private feed is unavailable (e.g. CI without credentials).
8+
NuGet audit queries all sources in nuget.config regardless of RestoreSources, causing NU1900 which is
9+
escalated to an error by TreatWarningsAsErrors. -->
10+
<NuGetAudit Condition="'$(AccessToNugetFeed)' != 'true'">false</NuGetAudit>
1411
</PropertyGroup>
1512
<PropertyGroup>
1613
<SemanticKernelVersion>1.72.0</SemanticKernelVersion>

EssentialCSharp.Chat.Shared/Models/AIOptions.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,20 @@ public class AIOptions
2222
/// </summary>
2323
public string Endpoint { get; set; } = string.Empty;
2424

25+
/// <summary>
26+
/// Static allowlist of MCP tool names the AI agent is permitted to invoke.
27+
/// Tools not on this list are neither advertised to the model nor executed.
28+
/// </summary>
29+
/// <remarks>
30+
/// When empty and <see cref="AllowAllMcpTools"/> is <c>false</c> (the default), all MCP tool
31+
/// calls are denied — fail-secure. Set <see cref="AllowAllMcpTools"/> to <c>true</c> to allow
32+
/// all tools without an explicit list (useful in development environments only).
33+
/// </remarks>
34+
public List<string> AllowedMcpTools { get; set; } = [];
35+
36+
/// <summary>
37+
/// When <c>true</c>, bypasses the <see cref="AllowedMcpTools"/> allowlist and permits all
38+
/// MCP tools. Should only be set in non-production environments.
39+
/// </summary>
40+
public bool AllowAllMcpTools { get; set; }
2541
}

EssentialCSharp.Chat.Shared/Services/AIChatService.cs

Lines changed: 159 additions & 38 deletions
Large diffs are not rendered by default.

EssentialCSharp.Chat/Program.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ static int Main(string[] args)
218218
var fullResponse = new System.Text.StringBuilder();
219219

220220
await foreach (var (text, responseId) in aiChatService.GetChatCompletionStream(
221-
prompt: userInput/*, mcpClient: mcpClient*/, previousResponseId: previousResponseId, systemPrompt: customSystemPrompt, cancellationToken: cancellationToken))
221+
prompt: userInput/*, mcpClient: mcpClient*/, previousResponseId: previousResponseId, systemPrompt: customSystemPrompt, endUserId: "console-local", cancellationToken: cancellationToken))
222222
{
223223
if (!string.IsNullOrEmpty(text))
224224
{
@@ -238,7 +238,7 @@ static int Main(string[] args)
238238
{
239239
// Non-streaming response with optional tools and conversation context
240240
var (response, responseId) = await aiChatService.GetChatCompletion(
241-
prompt: userInput, previousResponseId: previousResponseId, systemPrompt: customSystemPrompt, cancellationToken: cancellationToken);
241+
prompt: userInput, previousResponseId: previousResponseId, systemPrompt: customSystemPrompt, endUserId: "console-local", cancellationToken: cancellationToken);
242242

243243
Console.WriteLine(response);
244244
conversationHistory.Add(("Assistant", response));
Lines changed: 119 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using EssentialCSharp.Web.Models;
12
using EssentialCSharp.Web.Services;
23
using Microsoft.AspNetCore.Mvc.Testing;
34
using Microsoft.Extensions.DependencyInjection;
@@ -8,13 +9,41 @@ namespace EssentialCSharp.Web.Tests;
89
[ClassDataSource<WebApplicationFactory>(Shared = SharedType.PerClass)]
910
public class McpApiTokenServiceTests(WebApplicationFactory factory)
1011
{
11-
[Test]
12-
public async Task CreateTokenAsync_WithoutExpiry_UsesSixMonthDefault()
12+
private readonly List<IServiceScope> _scopes = [];
13+
14+
[After(Test)]
15+
public void DisposeScopes()
1316
{
14-
string userId = await McpTestHelper.CreateUserAsync(factory, "mcp-default-expiry");
17+
foreach (var scope in _scopes)
18+
scope.Dispose();
19+
_scopes.Clear();
20+
}
21+
22+
private async Task<(string UserId, McpApiTokenService TokenService)> ArrangeAsync(string prefix)
23+
{
24+
string userId = await McpTestHelper.CreateUserAsync(factory, prefix);
25+
var scope = factory.Services.CreateScope();
26+
_scopes.Add(scope);
27+
var tokenService = scope.ServiceProvider.GetRequiredService<McpApiTokenService>();
28+
return (userId, tokenService);
29+
}
1530

16-
using var scope = factory.Services.CreateScope();
31+
private async Task<McpApiTokenService> FillToLimitAsync(string userId)
32+
{
33+
var scope = factory.Services.CreateScope();
34+
_scopes.Add(scope);
1735
var tokenService = scope.ServiceProvider.GetRequiredService<McpApiTokenService>();
36+
for (int i = 0; i < McpApiTokenService.MaxTokensPerUser; i++)
37+
{
38+
await tokenService.CreateTokenAsync(userId, $"token-{i}");
39+
}
40+
return tokenService;
41+
}
42+
43+
[Test]
44+
public async Task CreateTokenAsync_WithoutExpiry_UsesSixMonthDefault()
45+
{
46+
var (userId, tokenService) = await ArrangeAsync("mcp-default-expiry");
1847

1948
(_, var entity) = await tokenService.CreateTokenAsync(userId, "default-expiry");
2049

@@ -26,10 +55,7 @@ await Assert.That(entity.ExpiresAt!.Value)
2655
[Test]
2756
public async Task CreateTokenAsync_WithExpiryWithinSixMonths_UsesRequestedExpiry()
2857
{
29-
string userId = await McpTestHelper.CreateUserAsync(factory, "mcp-custom-expiry");
30-
31-
using var scope = factory.Services.CreateScope();
32-
var tokenService = scope.ServiceProvider.GetRequiredService<McpApiTokenService>();
58+
var (userId, tokenService) = await ArrangeAsync("mcp-custom-expiry");
3359
DateTime requestedExpiry = DateTime.UtcNow.AddMonths(3);
3460

3561
(_, var entity) = await tokenService.CreateTokenAsync(userId, "custom-expiry", requestedExpiry);
@@ -41,10 +67,7 @@ public async Task CreateTokenAsync_WithExpiryWithinSixMonths_UsesRequestedExpiry
4167
[Test]
4268
public async Task CreateTokenAsync_WithExpiryBeyondSixMonths_Throws()
4369
{
44-
string userId = await McpTestHelper.CreateUserAsync(factory, "mcp-max-expiry");
45-
46-
using var scope = factory.Services.CreateScope();
47-
var tokenService = scope.ServiceProvider.GetRequiredService<McpApiTokenService>();
70+
var (userId, tokenService) = await ArrangeAsync("mcp-max-expiry");
4871
DateTime requestedExpiry = McpApiTokenService.GetDefaultExpirationUtc(DateTime.UtcNow).AddDays(2);
4972

5073
await Assert.That(() => tokenService.CreateTokenAsync(userId, "too-long", requestedExpiry))
@@ -55,20 +78,97 @@ await Assert.That(() => tokenService.CreateTokenAsync(userId, "too-long", reques
5578
[Test]
5679
public async Task CreateTokenAsync_WithExplicitCreatedAt_UsesReferenceTimeForDefaultExpiry()
5780
{
58-
string userId = await McpTestHelper.CreateUserAsync(factory, "mcp-explicit-created-at");
59-
60-
using var scope = factory.Services.CreateScope();
61-
var tokenService = scope.ServiceProvider.GetRequiredService<McpApiTokenService>();
81+
var (userId, tokenService) = await ArrangeAsync("mcp-explicit-created-at");
6282
DateTime createdAtUtc = new(2026, 4, 30, 23, 59, 59, DateTimeKind.Utc);
6383

6484
(_, var entity) = await tokenService.CreateTokenAsync(
65-
userId,
66-
"explicit-created-at",
67-
createdAtUtc: createdAtUtc);
85+
userId, "explicit-created-at", createdAtUtc: createdAtUtc);
6886

6987
await Assert.That(entity.CreatedAt).IsEqualTo(createdAtUtc);
7088
await Assert.That(entity.ExpiresAt).IsNotNull();
7189
await Assert.That(entity.ExpiresAt!.Value)
7290
.IsEqualTo(McpApiTokenService.GetDefaultExpirationUtc(createdAtUtc));
7391
}
92+
93+
[Test]
94+
public async Task GetActiveTokenCountAsync_NoTokens_ReturnsZero()
95+
{
96+
var (userId, tokenService) = await ArrangeAsync("mcp-count-zero");
97+
98+
int count = await tokenService.GetActiveTokenCountAsync(userId);
99+
100+
await Assert.That(count).IsEqualTo(0);
101+
}
102+
103+
[Test]
104+
public async Task GetActiveTokenCountAsync_ActiveTokens_CountsAll()
105+
{
106+
var (userId, tokenService) = await ArrangeAsync("mcp-count-active");
107+
108+
await tokenService.CreateTokenAsync(userId, "token-1");
109+
await tokenService.CreateTokenAsync(userId, "token-2");
110+
await tokenService.CreateTokenAsync(userId, "token-3");
111+
112+
int count = await tokenService.GetActiveTokenCountAsync(userId);
113+
114+
await Assert.That(count).IsEqualTo(3);
115+
}
116+
117+
[Test]
118+
public async Task GetActiveTokenCountAsync_RevokedToken_ExcludedFromCount()
119+
{
120+
var (userId, tokenService) = await ArrangeAsync("mcp-count-revoked");
121+
122+
await tokenService.CreateTokenAsync(userId, "active-token");
123+
(_, var revokedEntity) = await tokenService.CreateTokenAsync(userId, "revoked-token");
124+
await tokenService.RevokeTokenAsync(revokedEntity.Id, userId);
125+
126+
int count = await tokenService.GetActiveTokenCountAsync(userId);
127+
128+
await Assert.That(count).IsEqualTo(1);
129+
}
130+
131+
[Test]
132+
public async Task GetActiveTokenCountAsync_ExpiredToken_ExcludedFromCount()
133+
{
134+
var (userId, tokenService) = await ArrangeAsync("mcp-count-expired");
135+
136+
// createdAt 7 months ago → max expiry = 1 month ago; use 2 months ago as expiresAt
137+
DateTime createdAt = DateTime.UtcNow.AddMonths(-7);
138+
DateTime pastExpiry = DateTime.UtcNow.AddMonths(-2);
139+
await tokenService.CreateTokenAsync(userId, "expired-token",
140+
expiresAt: pastExpiry, createdAtUtc: createdAt);
141+
await tokenService.CreateTokenAsync(userId, "valid-token");
142+
143+
int count = await tokenService.GetActiveTokenCountAsync(userId);
144+
145+
await Assert.That(count).IsEqualTo(1);
146+
}
147+
148+
[Test]
149+
public async Task CreateTokenAsync_AtMaxLimit_ThrowsTokenLimitExceededException()
150+
{
151+
var (userId, _) = await ArrangeAsync("mcp-at-limit");
152+
var tokenService = await FillToLimitAsync(userId);
153+
154+
await Assert.That(() => tokenService.CreateTokenAsync(userId, "one-too-many"))
155+
.Throws<TokenLimitExceededException>();
156+
}
157+
158+
[Test]
159+
public async Task CreateTokenAsync_AfterRevokingAtLimit_AllowsNewToken()
160+
{
161+
var (userId, _) = await ArrangeAsync("mcp-revoke-then-create");
162+
var tokenService = await FillToLimitAsync(userId);
163+
164+
// Revoke the last token to free a slot
165+
var tokens = await tokenService.GetUserTokensAsync(userId);
166+
await tokenService.RevokeTokenAsync(tokens[0].Id, userId);
167+
168+
// Should now succeed — active count dropped below max
169+
(_, var newEntity) = await tokenService.CreateTokenAsync(userId, "replacement");
170+
await Assert.That(newEntity).IsNotNull();
171+
int activeCount = await tokenService.GetActiveTokenCountAsync(userId);
172+
await Assert.That(activeCount).IsEqualTo(McpApiTokenService.MaxTokensPerUser);
173+
}
74174
}

0 commit comments

Comments
 (0)