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
54 changes: 54 additions & 0 deletions EssentialCSharp.Web.Tests/McpApiTokenServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using EssentialCSharp.Web.Services;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;

namespace EssentialCSharp.Web.Tests;

[NotInParallel("McpTests")]
[ClassDataSource<WebApplicationFactory>(Shared = SharedType.PerClass)]
public class McpApiTokenServiceTests(WebApplicationFactory factory)
{
[Test]
public async Task CreateTokenAsync_WithoutExpiry_UsesSixMonthDefault()
{
string userId = await McpTestHelper.CreateUserAsync(factory, "mcp-default-expiry");

using var scope = factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<McpApiTokenService>();

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

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

[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>();
DateTime requestedExpiry = DateTime.UtcNow.AddMonths(3);

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

await Assert.That(entity.ExpiresAt).IsNotNull();
await Assert.That(entity.ExpiresAt!.Value).IsEqualTo(requestedExpiry);
}

[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>();
DateTime requestedExpiry = McpApiTokenService.GetDefaultExpirationUtc(DateTime.UtcNow).AddDays(1);

await Assert.That(() => tokenService.CreateTokenAsync(userId, "too-long", requestedExpiry))
.Throws<ArgumentOutOfRangeException>()
.WithMessageContaining("6 months");
Comment on lines +48 to +52
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test can be flaky around a UTC midnight boundary: requestedExpiry is computed from DateTime.UtcNow, but CreateTokenAsync computes createdAt from a separate UtcNow. If the date rolls over between those calls, the service's computed max can advance by a day and the requestedExpiry may no longer be beyond the max, causing intermittent failures. Consider making requestedExpiry unambiguously beyond any possible max (e.g., more than 1 day past), or refactoring token creation to accept/inject a reference time so tests can be deterministic.

Copilot uses AI. Check for mistakes.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,10 @@
<span asp-validation-for="TokenName" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="ExpiresOn" class="form-label">Expiry Date <span class="text-muted">(optional)</span></label>
<input asp-for="ExpiresOn" type="date" class="form-control" />
<label asp-for="ExpiresOn" class="form-label">Expiry Date</label>
<input asp-for="ExpiresOn" type="date" class="form-control" max="@Model.MaxExpiresOn.ToString("yyyy-MM-dd")" />
<span asp-validation-for="ExpiresOn" class="text-danger"></span>
<div class="form-text">Leave blank for a non-expiring token. The token expires at end of day (23:59:59) UTC on the selected date.</div>
<div class="form-text">MCP tokens default to 6 months and cannot exceed 6 months from today. The token expires at end of day (23:59:59) UTC on the selected date.</div>
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The help text says the token expires at end of day "(23:59:59) UTC", but the code persists TimeOnly.MaxValue (23:59:59.9999999). To avoid inaccurate UX/docs, consider changing the text to just "end of day (UTC)" or align it with the actual persisted precision.

Suggested change
<div class="form-text">MCP tokens default to 6 months and cannot exceed 6 months from today. The token expires at end of day (23:59:59) UTC on the selected date.</div>
<div class="form-text">MCP tokens default to 6 months and cannot exceed 6 months from today. The token expires at end of day (UTC) on the selected date.</div>

Copilot uses AI. Check for mistakes.
</div>
<button type="submit" class="btn btn-primary">Create Token</button>
</form>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ public class McpAccessModel(

public List<McpApiToken> UserTokens { get; private set; } = [];

public DateOnly MaxExpiresOn { get; private set; }

[BindProperty]
[StringLength(256, ErrorMessage = "Token name must be 256 characters or fewer.")]
public string TokenName { get; set; } = "My Token";
Expand All @@ -31,6 +33,8 @@ public class McpAccessModel(
public async Task<IActionResult> OnGetAsync()
{
DisableCaching();
InitializeExpiryBounds();
ApplyDefaultExpiryIfMissing();
string? userId = userManager.GetUserId(User);
if (userId is null) return Challenge();
UserTokens = await tokenService.GetUserTokensAsync(userId);
Expand All @@ -40,14 +44,21 @@ public async Task<IActionResult> OnGetAsync()
public async Task<IActionResult> OnPostCreateAsync()
{
DisableCaching();
InitializeExpiryBounds();
ApplyDefaultExpiryIfMissing();
string? userId = userManager.GetUserId(User);
if (userId is null) return Challenge();

if (string.IsNullOrWhiteSpace(TokenName))
ModelState.AddModelError(nameof(TokenName), "Token name is required.");

if (ExpiresOn.HasValue && ExpiresOn.Value < DateOnly.FromDateTime(DateTime.UtcNow))
ModelState.AddModelError(nameof(ExpiresOn), "Expiry date must be today or in the future.");
if (ExpiresOn.HasValue)
{
if (ExpiresOn.Value < DateOnly.FromDateTime(DateTime.UtcNow))
ModelState.AddModelError(nameof(ExpiresOn), "Expiry date must be today or in the future.");
if (ExpiresOn.Value > MaxExpiresOn)
ModelState.AddModelError(nameof(ExpiresOn), McpApiTokenService.MaxExpiryValidationMessage);
Comment on lines 46 to +60
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

InitializeExpiryBounds() (via GetDefaultExpiryDate()) and the "today" check each call DateTime.UtcNow separately. Around a UTC date rollover, this can yield inconsistent validation (e.g., MaxExpiresOn computed from one day but the comparison uses the next day). Capture a single nowUtc per request and use it for both MaxExpiresOn and the minimum-date validation (e.g., DateOnly.FromDateTime(nowUtc)).

Copilot uses AI. Check for mistakes.
}

if (!ModelState.IsValid)
{
Expand Down Expand Up @@ -85,4 +96,14 @@ private void DisableCaching()
Response.Headers.Pragma = "no-cache";
Response.Headers.Expires = "0";
}

private void InitializeExpiryBounds()
{
MaxExpiresOn = McpApiTokenService.GetDefaultExpiryDate();
}

private void ApplyDefaultExpiryIfMissing()
{
ExpiresOn ??= MaxExpiresOn;
}
}
3 changes: 3 additions & 0 deletions EssentialCSharp.Web/Controllers/McpTokenController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,13 @@ public async Task<IActionResult> CreateToken(
return BadRequest(new { Error = "Token name must be 256 characters or fewer." });

DateTime? expiresAt = null;
DateOnly maxExpiresOn = McpApiTokenService.GetDefaultExpiryDate();
if (request?.ExpiresOn is DateOnly expiresOn)
{
if (expiresOn < DateOnly.FromDateTime(DateTime.UtcNow))
Comment on lines 29 to 33
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DateTime.UtcNow is evaluated multiple times during validation (GetDefaultExpiryDate() and the expiresOn < DateOnly.FromDateTime(DateTime.UtcNow) check). If the request crosses a UTC midnight boundary between calls, the computed maxExpiresOn and "today" could disagree, causing inconsistent accept/reject behavior. Capture a single nowUtc at the top of the action and derive both bounds from it (you can pass it into GetDefaultExpiryDate(nowUtc)).

Suggested change
DateTime? expiresAt = null;
DateOnly maxExpiresOn = McpApiTokenService.GetDefaultExpiryDate();
if (request?.ExpiresOn is DateOnly expiresOn)
{
if (expiresOn < DateOnly.FromDateTime(DateTime.UtcNow))
DateTime nowUtc = DateTime.UtcNow;
DateOnly todayUtc = DateOnly.FromDateTime(nowUtc);
DateTime? expiresAt = null;
DateOnly maxExpiresOn = McpApiTokenService.GetDefaultExpiryDate(nowUtc);
if (request?.ExpiresOn is DateOnly expiresOn)
{
if (expiresOn < todayUtc)

Copilot uses AI. Check for mistakes.
return BadRequest(new { Error = "ExpiresOn must be today or in the future." });
if (expiresOn > maxExpiresOn)
return BadRequest(new { Error = McpApiTokenService.MaxExpiryValidationMessage });
// Convert date-only boundary to end-of-day UTC instant before persisting
expiresAt = expiresOn.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc);
}
Expand Down
28 changes: 26 additions & 2 deletions EssentialCSharp.Web/Services/McpApiTokenService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,17 @@ namespace EssentialCSharp.Web.Services;

public class McpApiTokenService(EssentialCSharpWebContext db)
{
public const int DefaultLifetimeMonths = 6;
public const string MaxExpiryValidationMessage = "MCP tokens can expire at most 6 months from today.";
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MaxExpiryValidationMessage hard-codes "6 months" while DefaultLifetimeMonths is the actual source of truth. If the lifetime ever changes, the validation message (and any consumers that display it) will become inaccurate; consider formatting the message from DefaultLifetimeMonths (and optionally include the computed max date for clarity).

Suggested change
public const string MaxExpiryValidationMessage = "MCP tokens can expire at most 6 months from today.";
public static string MaxExpiryValidationMessage => $"MCP tokens can expire at most {DefaultLifetimeMonths} months from today.";

Copilot uses AI. Check for mistakes.

public sealed record ResolvedMcpApiToken(Guid TokenId, string UserId);

public static DateOnly GetDefaultExpiryDate(DateTime? referenceTimeUtc = null)
=> DateOnly.FromDateTime(referenceTimeUtc ?? DateTime.UtcNow).AddMonths(DefaultLifetimeMonths);

public static DateTime GetDefaultExpirationUtc(DateTime? referenceTimeUtc = null)
=> GetDefaultExpiryDate(referenceTimeUtc).ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc);

/// <summary>Returns SHA-256 hash of the raw token as a byte array (varbinary(32)).</summary>
public static byte[] HashToken(string rawToken)
=> SHA256.HashData(Encoding.UTF8.GetBytes(rawToken));
Expand All @@ -30,19 +39,34 @@ public static string GenerateRawToken()
CancellationToken cancellationToken = default)
{
string raw = GenerateRawToken();
DateTime createdAt = DateTime.UtcNow;
DateTime effectiveExpiration = ResolveExpiration(expiresAt, createdAt);

var entity = new McpApiToken
{
UserId = userId,
Name = name,
TokenHash = HashToken(raw),
CreatedAt = DateTime.UtcNow,
ExpiresAt = expiresAt,
CreatedAt = createdAt,
ExpiresAt = effectiveExpiration,
};
db.McpApiTokens.Add(entity);
await db.SaveChangesAsync(cancellationToken);
return (raw, entity);
}

private static DateTime ResolveExpiration(DateTime? requestedExpirationUtc, DateTime createdAtUtc)
{
DateTime maxExpiration = GetDefaultExpirationUtc(createdAtUtc);
if (requestedExpirationUtc is null)
return maxExpiration;

if (requestedExpirationUtc > maxExpiration)
throw new ArgumentOutOfRangeException(nameof(requestedExpirationUtc), MaxExpiryValidationMessage);

return requestedExpirationUtc.Value;
}

/// <summary>
/// Revokes a token by ID. Validates ownership to prevent cross-user revocation.
/// Returns false if token not found or user doesn't own it.
Expand Down
3 changes: 2 additions & 1 deletion EssentialCSharp.Web/Views/McpSetup/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@
<h5>Step 2 — Generate a token</h5>
<p>
Go to <a href="/Identity/Account/Manage/McpAccess">MCP Access</a> under your account settings.
Enter a name for the token (e.g. "VS Code"), choose an optional expiry date, and click
Enter a name for the token (e.g. "VS Code"), keep the default 6-month expiry or choose an earlier date,
and click
<strong>Create Token</strong>. Copy the token — it won't be shown again.
</p>

Expand Down
Loading