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
20 changes: 12 additions & 8 deletions backend/src/Taskdeck.Api/Mcp/BoardResources.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using ModelContextProtocol.Server;
using Taskdeck.Application.Interfaces;
using Taskdeck.Application.Services;
using Taskdeck.Domain.Common;

namespace Taskdeck.Api.Mcp;

Expand Down Expand Up @@ -59,7 +60,7 @@ public async Task<string> ListBoards()
// List board summaries scoped to this user.
var listResult = await _boardService.ListBoardsAsync(userId, searchText: null, includeArchived: false);
if (!listResult.IsSuccess)
throw new InvalidOperationException($"MCP: failed to list boards: {listResult.ErrorMessage}");
throw new InvalidOperationException($"MCP: failed to list boards: {PublicFailureMessage(listResult)}");

var boardSummaries = new List<object>();
foreach (var boardDto in listResult.Value)
Expand Down Expand Up @@ -108,7 +109,7 @@ public async Task<string> GetBoardDetail(string boardId)

var detailResult = await _boardService.GetBoardDetailAsync(boardGuid, userId);
if (!detailResult.IsSuccess)
throw new InvalidOperationException($"MCP: failed to get board detail: {detailResult.ErrorMessage}");
throw new InvalidOperationException($"MCP: failed to get board detail: {PublicFailureMessage(detailResult)}");

var detail = detailResult.Value;

Expand Down Expand Up @@ -159,15 +160,15 @@ public async Task<string> GetColumnCards(string boardId, string columnId)
// Verify user can access this board
var boardResult = await _boardService.GetBoardDetailAsync(boardGuid, userId);
if (!boardResult.IsSuccess)
throw new InvalidOperationException($"MCP: failed to access board: {boardResult.ErrorMessage}");
throw new InvalidOperationException($"MCP: failed to access board: {PublicFailureMessage(boardResult)}");

var column = boardResult.Value.Columns.FirstOrDefault(c => c.Id == columnGuid);
if (column == null)
throw new InvalidOperationException($"MCP: column {columnId} not found in board {boardId}");

var cardsResult = await _cardService.SearchCardsAsync(boardGuid, columnId: columnGuid);
if (!cardsResult.IsSuccess)
throw new InvalidOperationException($"MCP: failed to get cards: {cardsResult.ErrorMessage}");
throw new InvalidOperationException($"MCP: failed to get cards: {PublicFailureMessage(cardsResult)}");

var cards = cardsResult.Value.OrderBy(c => c.Position).Select(c => new
{
Expand Down Expand Up @@ -208,11 +209,11 @@ public async Task<string> GetCardDetail(string boardId, string cardId)
// Verify user can access this board
var boardResult = await _boardService.GetBoardDetailAsync(boardGuid, userId);
if (!boardResult.IsSuccess)
throw new InvalidOperationException($"MCP: failed to access board: {boardResult.ErrorMessage}");
throw new InvalidOperationException($"MCP: failed to access board: {PublicFailureMessage(boardResult)}");

var cardsResult = await _cardService.SearchCardsAsync(boardGuid);
if (!cardsResult.IsSuccess)
throw new InvalidOperationException($"MCP: failed to search cards: {cardsResult.ErrorMessage}");
throw new InvalidOperationException($"MCP: failed to search cards: {PublicFailureMessage(cardsResult)}");

var card = cardsResult.Value.FirstOrDefault(c => c.Id == cardGuid);
if (card == null)
Expand Down Expand Up @@ -258,11 +259,11 @@ public async Task<string> GetBoardLabels(string boardId)
// Verify user can access this board
var boardResult = await _boardService.GetBoardDetailAsync(boardGuid, userId);
if (!boardResult.IsSuccess)
throw new InvalidOperationException($"MCP: failed to access board: {boardResult.ErrorMessage}");
throw new InvalidOperationException($"MCP: failed to access board: {PublicFailureMessage(boardResult)}");

var labelsResult = await _labelService.GetLabelsByBoardIdAsync(boardGuid);
if (!labelsResult.IsSuccess)
throw new InvalidOperationException($"MCP: failed to get labels: {labelsResult.ErrorMessage}");
throw new InvalidOperationException($"MCP: failed to get labels: {PublicFailureMessage(labelsResult)}");

var labels = labelsResult.Value.Select(l => new
{
Expand All @@ -279,4 +280,7 @@ public async Task<string> GetBoardLabels(string boardId)
totalCount = labelsResult.Value.Count()
}, SerializerOptions);
}

private static string PublicFailureMessage(Result result) =>
SensitiveDataRedactor.SanitizeLlmFailureMessage(result.ErrorCode, result.ErrorMessage);
}
8 changes: 6 additions & 2 deletions backend/src/Taskdeck.Api/Mcp/CaptureResources.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Taskdeck.Application.DTOs;
using Taskdeck.Application.Interfaces;
using Taskdeck.Application.Services;
using Taskdeck.Domain.Common;

namespace Taskdeck.Api.Mcp;

Expand Down Expand Up @@ -38,7 +39,7 @@ public async Task<string> ListCaptures()

var result = await _captureService.ListAsync(userId, new CaptureListFilterDto());
if (!result.IsSuccess)
throw new InvalidOperationException($"MCP: failed to list captures: {result.ErrorMessage}");
throw new InvalidOperationException($"MCP: failed to list captures: {PublicFailureMessage(result)}");

var captures = result.Value.Select(c => new
{
Expand Down Expand Up @@ -75,7 +76,7 @@ public async Task<string> GetCaptureDetail(string captureId)

var result = await _captureService.GetByIdAsync(userId, captureGuid);
if (!result.IsSuccess)
throw new InvalidOperationException($"MCP: failed to get capture: {result.ErrorMessage}");
throw new InvalidOperationException($"MCP: failed to get capture: {PublicFailureMessage(result)}");

var c = result.Value;

Expand All @@ -94,4 +95,7 @@ public async Task<string> GetCaptureDetail(string captureId)
errorMessage = c.ErrorMessage
}, BoardResources.SerializerOptions);
}

private static string PublicFailureMessage(Result result) =>
SensitiveDataRedactor.SanitizeLlmFailureMessage(result.ErrorCode, result.ErrorMessage);
}
132 changes: 132 additions & 0 deletions backend/tests/Taskdeck.Api.Tests/BoardResourcesErrorSafetyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
using FluentAssertions;
using Moq;
using Taskdeck.Api.Mcp;
using Taskdeck.Application.Interfaces;
using Taskdeck.Application.Services;
using Taskdeck.Domain.Common;
using Taskdeck.Domain.Enums;
using Taskdeck.Domain.Exceptions;
using Xunit;

namespace Taskdeck.Api.Tests;

public class BoardResourcesErrorSafetyTests
{
private const string HostileError =
"Bearer tdsk_test_secret C:\\Users\\alice\\taskdeck.db " +
"SQLite UNIQUE constraint failed: Boards.Name https://provider.example/v1/internal";

[Fact]
public async Task ListBoards_UnexpectedAuthorizationFailure_UsesGenericMessage()
{
var userId = Guid.NewGuid();
var boardId = Guid.NewGuid();
var (resources, unitOfWork, authorization) = CreateResources(userId);
var boardRepository = new Mock<IBoardRepository>(MockBehavior.Strict);
unitOfWork
.SetupGet(value => value.Boards)
.Returns(boardRepository.Object);
boardRepository
.Setup(repository => repository.SearchIdsAsync(null, false, It.IsAny<CancellationToken>()))
.ReturnsAsync(new[] { boardId });
authorization
.Setup(service => service.GetReadableBoardIdsAsync(
userId,
It.IsAny<IEnumerable<Guid>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Failure<IReadOnlySet<Guid>>(ErrorCodes.UnexpectedError, HostileError));

var action = () => resources.ListBoards();

var exception = (await action.Should().ThrowAsync<InvalidOperationException>()).Which;
exception.Message.Should().Be(
$"MCP: failed to list boards: {SensitiveDataRedactor.GenericUnexpectedFailureMessage}");
exception.Message.Should().NotContain(HostileError);
authorization.VerifyAll();
boardRepository.VerifyAll();
}

[Theory]
[InlineData("detail")]
[InlineData("column")]
[InlineData("card")]
[InlineData("labels")]
public async Task BoardAccess_UnexpectedFailure_UsesGenericMessage(string resource)
{
var userId = Guid.NewGuid();
var boardId = Guid.NewGuid();
var (resources, _, authorization) = CreateResources(userId);
authorization
.Setup(service => service.CanReadBoardAsync(userId, boardId))
.ReturnsAsync(Result.Failure<bool>(ErrorCodes.UnexpectedError, HostileError));

Func<Task<string>> action = resource switch
{
"detail" => () => resources.GetBoardDetail(boardId.ToString()),
"column" => () => resources.GetColumnCards(boardId.ToString(), Guid.NewGuid().ToString()),
"card" => () => resources.GetCardDetail(boardId.ToString(), Guid.NewGuid().ToString()),
"labels" => () => resources.GetBoardLabels(boardId.ToString()),
_ => throw new ArgumentOutOfRangeException(nameof(resource), resource, null)
};

var exception = (await action.Should().ThrowAsync<InvalidOperationException>()).Which;
var prefix = resource switch
{
"detail" => "MCP: failed to get board detail: ",
"column" => "MCP: failed to access board: ",
"card" => "MCP: failed to access board: ",
"labels" => "MCP: failed to access board: ",
_ => throw new ArgumentOutOfRangeException(nameof(resource), resource, null)
};
exception.Message.Should().Be(prefix + SensitiveDataRedactor.GenericUnexpectedFailureMessage);
authorization.VerifyAll();
}

[Fact]
public async Task GetBoardDetail_KnownDomainFailure_PreservesStableMessage()
{
const string stableMessage = "You do not have access to this board.";
var userId = Guid.NewGuid();
var boardId = Guid.NewGuid();
var (resources, _, authorization) = CreateResources(userId);
authorization
.Setup(service => service.CanReadBoardAsync(userId, boardId))
.ReturnsAsync(Result.Failure<bool>(ErrorCodes.Forbidden, stableMessage));

var action = () => resources.GetBoardDetail(boardId.ToString());

var exception = (await action.Should().ThrowAsync<InvalidOperationException>()).Which;
exception.Message.Should().Be($"MCP: failed to get board detail: {stableMessage}");
authorization.VerifyAll();
}

private static (
BoardResources Resources,
Mock<IUnitOfWork> UnitOfWork,
Mock<IAuthorizationService> Authorization) CreateResources(Guid userId)
{
var unitOfWork = new Mock<IUnitOfWork>(MockBehavior.Strict);
var authorization = new Mock<IAuthorizationService>(MockBehavior.Strict);
var boardService = new BoardService(unitOfWork.Object, authorization.Object);
var resources = new BoardResources(
boardService,
new ColumnService(unitOfWork.Object),
new CardService(unitOfWork.Object),
new LabelService(unitOfWork.Object),
new FixedUserContextProvider(userId));

return (resources, unitOfWork, authorization);
}

private sealed class FixedUserContextProvider(Guid userId) : IUserContextProvider
{
public Task<McpUserContext> GetCurrentContextAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(new McpUserContext(userId, ApiKeyScope.Full));

public Task<Guid> GetCurrentUserIdAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(userId);

public Task<Guid?> GetUserIdAsync(CancellationToken cancellationToken = default) =>
Task.FromResult<Guid?>(userId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
using FluentAssertions;
using Moq;
using Taskdeck.Api.Mcp;
using Taskdeck.Application.DTOs;
using Taskdeck.Application.Interfaces;
using Taskdeck.Application.Services;
using Taskdeck.Domain.Common;
using Taskdeck.Domain.Enums;
using Taskdeck.Domain.Exceptions;
using Xunit;

namespace Taskdeck.Api.Tests;

public class CaptureResourcesErrorSafetyTests
{
private const string HostileError =
"Bearer tdsk_test_secret C:\\Users\\alice\\taskdeck.db " +
"SQLite UNIQUE constraint failed: Users.Email https://provider.example/v1/internal";

[Fact]
public async Task ListCaptures_UnexpectedFailure_UsesGenericMessage()
{
var userId = Guid.NewGuid();
var captureService = new Mock<ICaptureService>(MockBehavior.Strict);
captureService
.Setup(service => service.ListAsync(
userId,
It.IsAny<CaptureListFilterDto>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Failure<IReadOnlyList<CaptureItemSummaryDto>>(
ErrorCodes.UnexpectedError,
HostileError));

var action = () => CreateResources(captureService.Object, userId).ListCaptures();

var exception = (await action.Should().ThrowAsync<InvalidOperationException>()).Which;
exception.Message.Should().Be(
$"MCP: failed to list captures: {SensitiveDataRedactor.GenericUnexpectedFailureMessage}");
exception.Message.Should().NotContain(HostileError);
captureService.VerifyAll();
}

[Fact]
public async Task GetCaptureDetail_UnexpectedFailure_UsesGenericMessage()
{
var userId = Guid.NewGuid();
var captureId = Guid.NewGuid();
var captureService = new Mock<ICaptureService>(MockBehavior.Strict);
captureService
.Setup(service => service.GetByIdAsync(userId, captureId, It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Failure<CaptureItemDto>(ErrorCodes.UnexpectedError, HostileError));

var action = () => CreateResources(captureService.Object, userId)
.GetCaptureDetail(captureId.ToString());

var exception = (await action.Should().ThrowAsync<InvalidOperationException>()).Which;
exception.Message.Should().Be(
$"MCP: failed to get capture: {SensitiveDataRedactor.GenericUnexpectedFailureMessage}");
exception.Message.Should().NotContain(HostileError);
captureService.VerifyAll();
}

[Fact]
public async Task GetCaptureDetail_KnownDomainFailure_PreservesStableMessage()
{
const string stableMessage = "Capture item not found.";
var userId = Guid.NewGuid();
var captureId = Guid.NewGuid();
var captureService = new Mock<ICaptureService>(MockBehavior.Strict);
captureService
.Setup(service => service.GetByIdAsync(userId, captureId, It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Failure<CaptureItemDto>(ErrorCodes.NotFound, stableMessage));

var action = () => CreateResources(captureService.Object, userId)
.GetCaptureDetail(captureId.ToString());

var exception = (await action.Should().ThrowAsync<InvalidOperationException>()).Which;
exception.Message.Should().Be($"MCP: failed to get capture: {stableMessage}");
captureService.VerifyAll();
}

private static CaptureResources CreateResources(
ICaptureService captureService,
Guid userId) =>
new(captureService, new FixedUserContextProvider(userId));

private sealed class FixedUserContextProvider(Guid userId) : IUserContextProvider
{
public Task<McpUserContext> GetCurrentContextAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(new McpUserContext(userId, ApiKeyScope.Full));

public Task<Guid> GetCurrentUserIdAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(userId);

public Task<Guid?> GetUserIdAsync(CancellationToken cancellationToken = default) =>
Task.FromResult<Guid?>(userId);
}
}
4 changes: 4 additions & 0 deletions docs/security/SECURITY_LOGGING_REDACTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ It applies to API middleware, SignalR transport request logging, queue/worker lo
`UnexpectedError` with the same stable generic failure message before returning tool output or
throwing a resource error. Known domain result messages stay specific, and diagnostic logging
remains owned by the service and MCP operation boundaries.
- MCP capture and board resources apply the same boundary rule to every interpolated failed
`Result`: `UnexpectedError` becomes the stable generic failure message, while known domain
messages remain specific. Persisted `CaptureItemDto.ErrorMessage` values, invalid caller IDs, and
arbitrary thrown exceptions are separate contracts and are not rewritten by this rule.
Comment thread
Chris0Jeky marked this conversation as resolved.
- Capture-source validation errors use generic wording (`Invalid capture source value`) instead of reflecting the untrusted source string.
- Opt-in Sentry keeps server-side exception tracking and the existing event/breadcrumb scrubbing, but does not decorate the registered OpenAI, OpenAICompatible, Ollama, or outbound-webhook clients.
- The web host enforces `Warning` as the minimum for
Expand Down
Loading