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
16 changes: 16 additions & 0 deletions EssentialCSharp.Chat.Shared/Models/AIOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,20 @@ public class AIOptions
/// </summary>
public string Endpoint { get; set; } = string.Empty;

/// <summary>
/// Static allowlist of MCP tool names the AI agent is permitted to invoke.
/// Tools not on this list are neither advertised to the model nor executed.
/// </summary>
/// <remarks>
/// When empty and <see cref="AllowAllMcpTools"/> is <c>false</c> (the default), all MCP tool
/// calls are denied — fail-secure. Set <see cref="AllowAllMcpTools"/> to <c>true</c> to allow
/// all tools without an explicit list (useful in development environments only).
/// </remarks>
public List<string> AllowedMcpTools { get; set; } = [];

/// <summary>
/// When <c>true</c>, bypasses the <see cref="AllowedMcpTools"/> allowlist and permits all
/// MCP tools. Should only be set in non-production environments.
/// </summary>
public bool AllowAllMcpTools { get; set; }
}
170 changes: 146 additions & 24 deletions EssentialCSharp.Chat.Shared/Services/AIChatService.cs

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions EssentialCSharp.Chat/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ static int Main(string[] args)
var fullResponse = new System.Text.StringBuilder();

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

Console.WriteLine(response);
conversationHistory.Add(("Assistant", response));
Expand Down
138 changes: 119 additions & 19 deletions EssentialCSharp.Web.Tests/McpApiTokenServiceTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using EssentialCSharp.Web.Models;
Comment thread
BenjaminMichaelis marked this conversation as resolved.
using EssentialCSharp.Web.Services;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
Expand All @@ -8,13 +9,41 @@ namespace EssentialCSharp.Web.Tests;
[ClassDataSource<WebApplicationFactory>(Shared = SharedType.PerClass)]
public class McpApiTokenServiceTests(WebApplicationFactory factory)
{
[Test]
public async Task CreateTokenAsync_WithoutExpiry_UsesSixMonthDefault()
private readonly List<IServiceScope> _scopes = [];

[After(Test)]
public void DisposeScopes()
{
string userId = await McpTestHelper.CreateUserAsync(factory, "mcp-default-expiry");
foreach (var scope in _scopes)
scope.Dispose();
_scopes.Clear();
}

private async Task<(string UserId, McpApiTokenService TokenService)> ArrangeAsync(string prefix)
{
string userId = await McpTestHelper.CreateUserAsync(factory, prefix);
var scope = factory.Services.CreateScope();
_scopes.Add(scope);
var tokenService = scope.ServiceProvider.GetRequiredService<McpApiTokenService>();
return (userId, tokenService);
}

using var scope = factory.Services.CreateScope();
private async Task<McpApiTokenService> FillToLimitAsync(string userId)
{
var scope = factory.Services.CreateScope();
_scopes.Add(scope);
var tokenService = scope.ServiceProvider.GetRequiredService<McpApiTokenService>();
for (int i = 0; i < McpApiTokenService.MaxTokensPerUser; i++)
{
await tokenService.CreateTokenAsync(userId, $"token-{i}");
}
return tokenService;
}

[Test]
public async Task CreateTokenAsync_WithoutExpiry_UsesSixMonthDefault()
{
var (userId, tokenService) = await ArrangeAsync("mcp-default-expiry");

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

Expand All @@ -26,10 +55,7 @@ await Assert.That(entity.ExpiresAt!.Value)
[Test]
public async Task CreateTokenAsync_WithExpiryWithinSixMonths_UsesRequestedExpiry()
{
string userId = await McpTestHelper.CreateUserAsync(factory, "mcp-custom-expiry");

using var scope = factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<McpApiTokenService>();
var (userId, tokenService) = await ArrangeAsync("mcp-custom-expiry");
DateTime requestedExpiry = DateTime.UtcNow.AddMonths(3);

(_, var entity) = await tokenService.CreateTokenAsync(userId, "custom-expiry", requestedExpiry);
Expand All @@ -41,10 +67,7 @@ public async Task CreateTokenAsync_WithExpiryWithinSixMonths_UsesRequestedExpiry
[Test]
public async Task CreateTokenAsync_WithExpiryBeyondSixMonths_Throws()
{
string userId = await McpTestHelper.CreateUserAsync(factory, "mcp-max-expiry");

using var scope = factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<McpApiTokenService>();
var (userId, tokenService) = await ArrangeAsync("mcp-max-expiry");
DateTime requestedExpiry = McpApiTokenService.GetDefaultExpirationUtc(DateTime.UtcNow).AddDays(2);

await Assert.That(() => tokenService.CreateTokenAsync(userId, "too-long", requestedExpiry))
Expand All @@ -55,20 +78,97 @@ await Assert.That(() => tokenService.CreateTokenAsync(userId, "too-long", reques
[Test]
public async Task CreateTokenAsync_WithExplicitCreatedAt_UsesReferenceTimeForDefaultExpiry()
{
string userId = await McpTestHelper.CreateUserAsync(factory, "mcp-explicit-created-at");

using var scope = factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<McpApiTokenService>();
var (userId, tokenService) = await ArrangeAsync("mcp-explicit-created-at");
DateTime createdAtUtc = new(2026, 4, 30, 23, 59, 59, DateTimeKind.Utc);

(_, var entity) = await tokenService.CreateTokenAsync(
userId,
"explicit-created-at",
createdAtUtc: createdAtUtc);
userId, "explicit-created-at", createdAtUtc: createdAtUtc);

await Assert.That(entity.CreatedAt).IsEqualTo(createdAtUtc);
await Assert.That(entity.ExpiresAt).IsNotNull();
await Assert.That(entity.ExpiresAt!.Value)
.IsEqualTo(McpApiTokenService.GetDefaultExpirationUtc(createdAtUtc));
}

[Test]
public async Task GetActiveTokenCountAsync_NoTokens_ReturnsZero()
{
var (userId, tokenService) = await ArrangeAsync("mcp-count-zero");

int count = await tokenService.GetActiveTokenCountAsync(userId);

await Assert.That(count).IsEqualTo(0);
}

[Test]
public async Task GetActiveTokenCountAsync_ActiveTokens_CountsAll()
{
var (userId, tokenService) = await ArrangeAsync("mcp-count-active");

await tokenService.CreateTokenAsync(userId, "token-1");
await tokenService.CreateTokenAsync(userId, "token-2");
await tokenService.CreateTokenAsync(userId, "token-3");

int count = await tokenService.GetActiveTokenCountAsync(userId);

await Assert.That(count).IsEqualTo(3);
}

[Test]
public async Task GetActiveTokenCountAsync_RevokedToken_ExcludedFromCount()
{
var (userId, tokenService) = await ArrangeAsync("mcp-count-revoked");

await tokenService.CreateTokenAsync(userId, "active-token");
(_, var revokedEntity) = await tokenService.CreateTokenAsync(userId, "revoked-token");
await tokenService.RevokeTokenAsync(revokedEntity.Id, userId);

int count = await tokenService.GetActiveTokenCountAsync(userId);

await Assert.That(count).IsEqualTo(1);
}

[Test]
public async Task GetActiveTokenCountAsync_ExpiredToken_ExcludedFromCount()
{
var (userId, tokenService) = await ArrangeAsync("mcp-count-expired");

// createdAt 7 months ago → max expiry = 1 month ago; use 2 months ago as expiresAt
DateTime createdAt = DateTime.UtcNow.AddMonths(-7);
DateTime pastExpiry = DateTime.UtcNow.AddMonths(-2);
await tokenService.CreateTokenAsync(userId, "expired-token",
expiresAt: pastExpiry, createdAtUtc: createdAt);
await tokenService.CreateTokenAsync(userId, "valid-token");

int count = await tokenService.GetActiveTokenCountAsync(userId);

await Assert.That(count).IsEqualTo(1);
}

[Test]
public async Task CreateTokenAsync_AtMaxLimit_ThrowsTokenLimitExceededException()
{
var (userId, _) = await ArrangeAsync("mcp-at-limit");
var tokenService = await FillToLimitAsync(userId);

await Assert.That(() => tokenService.CreateTokenAsync(userId, "one-too-many"))
.Throws<TokenLimitExceededException>();
}

[Test]
public async Task CreateTokenAsync_AfterRevokingAtLimit_AllowsNewToken()
{
var (userId, _) = await ArrangeAsync("mcp-revoke-then-create");
var tokenService = await FillToLimitAsync(userId);

// Revoke the last token to free a slot
var tokens = await tokenService.GetUserTokensAsync(userId);
await tokenService.RevokeTokenAsync(tokens[0].Id, userId);

// Should now succeed — active count dropped below max
(_, var newEntity) = await tokenService.CreateTokenAsync(userId, "replacement");
await Assert.That(newEntity).IsNotNull();
int activeCount = await tokenService.GetActiveTokenCountAsync(userId);
await Assert.That(activeCount).IsEqualTo(McpApiTokenService.MaxTokensPerUser);
}
}
130 changes: 130 additions & 0 deletions EssentialCSharp.Web.Tests/ResponseIdValidationServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
using EssentialCSharp.Web.Services;
using Microsoft.Extensions.Caching.Memory;

namespace EssentialCSharp.Web.Tests;

public class ResponseIdValidationServiceTests
{
// Match production SizeLimit so SetSize(1) is exercised in tests, not silently ignored.
private static MemoryCache CreateCache() => new(new MemoryCacheOptions { SizeLimit = 10_000 });

private static ResponseIdValidationService CreateService(MemoryCache cache) => new(cache);

[Test]
[Arguments(null)]
[Arguments("")]
public async Task ValidateResponseId_BlankResponseId_AllowsNewConversation(string? responseId)
{
using var cache = CreateCache();
var service = CreateService(cache);

bool result = service.ValidateResponseId("user1", responseId);

await Assert.That(result).IsTrue();
}

[Test]
[Arguments(null)]
[Arguments("")]
public async Task ValidateResponseId_BlankUserId_Rejects(string? userId)
{
using var cache = CreateCache();
var service = CreateService(cache);

bool result = service.ValidateResponseId(userId, "resp_123");

await Assert.That(result).IsFalse();
}

[Test]
public async Task ValidateResponseId_CacheMiss_AllowsGracefulDegradation()
{
using var cache = CreateCache();
var service = CreateService(cache);
// No RecordResponseId call — simulate server restart / different instance

bool result = service.ValidateResponseId("user1", "resp_unknown");

await Assert.That(result).IsTrue();
}

[Test]
public async Task ValidateResponseId_RecordedByOwner_Validates()
{
using var cache = CreateCache();
var service = CreateService(cache);
service.RecordResponseId("user1", "resp_abc");

bool result = service.ValidateResponseId("user1", "resp_abc");

await Assert.That(result).IsTrue();
}

[Test]
public async Task ValidateResponseId_RecordedByDifferentUser_Rejects()
{
using var cache = CreateCache();
var service = CreateService(cache);
service.RecordResponseId("user1", "resp_abc");

bool result = service.ValidateResponseId("user2", "resp_abc");

await Assert.That(result).IsFalse();
}

[Test]
public async Task RecordResponseId_NullInputs_DoesNotThrow()
{
using var cache = CreateCache();
var service = CreateService(cache);

service.RecordResponseId(null, "resp_abc");
service.RecordResponseId("user1", null);
service.RecordResponseId(null, null);

// Verify the service is still functional after no-op calls
service.RecordResponseId("user1", "resp_abc");
await Assert.That(service.ValidateResponseId("user1", "resp_abc")).IsTrue();
}

[Test]
public async Task ValidateResponseId_MultipleResponseIds_EachValidatedIndependently()
{
using var cache = CreateCache();
var service = CreateService(cache);
service.RecordResponseId("user1", "resp_001");
service.RecordResponseId("user1", "resp_002");

await Assert.That(service.ValidateResponseId("user1", "resp_001")).IsTrue();
await Assert.That(service.ValidateResponseId("user1", "resp_002")).IsTrue();
// Unrecorded ID for same user → cache miss → allow
await Assert.That(service.ValidateResponseId("user1", "resp_003")).IsTrue();
}

[Test]
public async Task ValidateResponseId_TwoUsers_IsolatedFromEachOther()
{
using var cache = CreateCache();
var service = CreateService(cache);
service.RecordResponseId("user1", "resp_A");
service.RecordResponseId("user2", "resp_B");

await Assert.That(service.ValidateResponseId("user1", "resp_A")).IsTrue();
await Assert.That(service.ValidateResponseId("user2", "resp_B")).IsTrue();
await Assert.That(service.ValidateResponseId("user2", "resp_A")).IsFalse();
await Assert.That(service.ValidateResponseId("user1", "resp_B")).IsFalse();
}

[Test]
public async Task RecordResponseId_SizeLimitEnforced_EntryCountedInCache()
{
using var cache = CreateCache();
var service = CreateService(cache);

// Record an entry — with SizeLimit set, SetSize(1) should count toward the cache size.
service.RecordResponseId("user1", "resp_size_test");

// Verify it was recorded (i.e., not silently evicted due to misconfiguration).
await Assert.That(service.ValidateResponseId("user1", "resp_size_test")).IsTrue();
}
}
Loading
Loading