From 16de7b1fff7f4732b8bce77629fd4142408de0fb Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 29 Jun 2026 16:52:40 -0500 Subject: [PATCH 1/3] Add MCP project context switching --- .../Mcp/ExceptionlessMcpTools.cs | 216 +++++++-- .../Mcp/McpContextService.cs | 459 ++++++++++++++++++ src/Exceptionless.Web/Mcp/McpErrors.cs | 32 ++ src/Exceptionless.Web/Mcp/McpModels.cs | 19 + src/Exceptionless.Web/Startup.cs | 9 +- .../Controllers/ExceptionlessMcpToolsTests.cs | 216 ++++++++- 6 files changed, 904 insertions(+), 47 deletions(-) create mode 100644 src/Exceptionless.Web/Mcp/McpContextService.cs diff --git a/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs index d78593093..94719e4ff 100644 --- a/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs +++ b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs @@ -47,6 +47,7 @@ public sealed class ExceptionlessMcpTools private readonly IHttpContextAccessor _httpContextAccessor; private readonly IOrganizationRepository _organizationRepository; private readonly IProjectRepository _projectRepository; + private readonly McpContextService _mcpContextService; private readonly IStackRepository _stackRepository; private readonly IEventRepository _eventRepository; private readonly ITokenRepository _tokenRepository; @@ -66,6 +67,7 @@ public ExceptionlessMcpTools( ITokenRepository tokenRepository, StackQueryValidator stackQueryValidator, PersistentEventQueryValidator eventQueryValidator, + McpContextService mcpContextService, SemanticVersionParser semanticVersionParser, ITextSerializer serializer, ILogger logger, @@ -83,8 +85,122 @@ public ExceptionlessMcpTools( _serializer = serializer; _logger = logger; _timeProvider = timeProvider; + _mcpContextService = mcpContextService; } + [McpServerTool(Name = "get_context", ReadOnly = true, UseStructuredContent = true)] + [Description("Gets the active MCP organization and project context for this session.")] + public async Task> GetContextAsync() + { + try + { + EnsureScope(AuthorizationRoles.McpRead); + var context = await _mcpContextService.GetContextAsync(requireProject: false); + if (!context.Succeeded) + return McpResponse.Failed(context.Error!); + + return McpResponse.Success(context.Context); + } + catch (Exception ex) when (IsLookupError(ex)) + { + return McpResponse.Failed(ToLookupError("MCP context", "current session", ex)); + } + } + + [McpServerTool(Name = "list_organizations", ReadOnly = true, UseStructuredContent = true)] + [Description("Lists organizations available to the current MCP OAuth grant.")] + public async Task>> ListOrganizationsAsync() + { + try + { + EnsureScope(AuthorizationRoles.McpRead); + var context = await _mcpContextService.ListOrganizationsAsync(); + return McpResponse>.Success(new McpListData(context.Context.Organizations)); + } + catch (Exception ex) when (IsLookupError(ex)) + { + return McpResponse>.Failed(ToLookupError("Organization", "current user", ex)); + } + } + + [McpServerTool(Name = "switch_organization", ReadOnly = false, UseStructuredContent = true)] + [Description("Sets the active MCP organization for this session and clears any active project unless the organization has exactly one project.")] + public async Task> SwitchOrganizationAsync( + [Description("The Exceptionless organization id to make active.")] + string organizationId) + { + try + { + EnsureScope(AuthorizationRoles.McpRead); + if (!TryValidateId(organizationId, "organizationId", out var idError)) + return McpResponse.Failed(idError); + + var context = await _mcpContextService.SwitchOrganizationAsync(organizationId); + if (!context.Succeeded) + return McpResponse.Failed(context.Error!); + + return McpResponse.Success(context.Context); + } + catch (Exception ex) when (IsLookupError(ex)) + { + return McpResponse.Failed(ToLookupError("Organization", organizationId, ex)); + } + } + + [McpServerTool(Name = "switch_project", ReadOnly = false, UseStructuredContent = true)] + [Description("Sets the active MCP project for this session and switches the active organization to the project's organization.")] + public async Task> SwitchProjectAsync( + [Description("The Exceptionless project id to make active.")] + string projectId) + { + try + { + EnsureScope(AuthorizationRoles.McpRead); + if (!TryValidateId(projectId, "projectId", out var idError)) + return McpResponse.Failed(idError); + + var context = await _mcpContextService.SwitchProjectAsync(projectId); + if (!context.Succeeded) + return McpResponse.Failed(context.Error!); + + return McpResponse.Success(context.Context); + } + catch (Exception ex) when (IsLookupError(ex)) + { + return McpResponse.Failed(ToLookupError("Project", projectId, ex)); + } + } + + [McpServerTool(Name = "resolve_project_context", ReadOnly = false, UseStructuredContent = true)] + [Description("Resolves and sets the active MCP project context by project id or exact project name.")] + public async Task> ResolveProjectContextAsync( + [Description("Optional Exceptionless project id to make active.")] + string? projectId = null, + [Description("Optional exact project name to make active within the active organization.")] + string? projectName = null, + [Description("Optional organization id to use when resolving a project name.")] + string? organizationId = null) + { + try + { + EnsureScope(AuthorizationRoles.McpRead); + if (!String.IsNullOrWhiteSpace(projectId) && !TryValidateId(projectId, "projectId", out var projectIdError)) + return McpResponse.Failed(projectIdError); + + if (!String.IsNullOrWhiteSpace(organizationId) && !TryValidateId(organizationId, "organizationId", out var organizationIdError)) + return McpResponse.Failed(organizationIdError); + + var context = await _mcpContextService.ResolveProjectContextAsync(projectId, projectName, organizationId); + if (!context.Succeeded) + return McpResponse.Failed(context.Error!); + + return McpResponse.Success(context.Context); + } + catch (Exception ex) when (IsLookupError(ex)) + { + return McpResponse.Failed(ToLookupError("Project", projectId ?? projectName ?? "current session", ex)); + } + } [McpServerTool(Name = "list_projects", ReadOnly = true, UseStructuredContent = true)] [Description("Lists projects the authenticated Exceptionless user can access. When pagination.hasMore is true, pass pagination.after to fetch the next page or pagination.before to fetch the previous page.")] public async Task>> ListProjectsAsync( @@ -111,11 +227,12 @@ public async Task>> ListProjectsAsync( int resolvedLimit = validation.Limit; - var organizations = await GetAccessibleOrganizationsAsync(); - var systemFilter = new AppFilter(organizations) - { - IsUserOrganizationsFilter = true - }; + var context = await _mcpContextService.GetContextAsync(requireProject: false); + if (!context.Succeeded) + return McpResponse>.Failed(context.Error!); + + var organization = context.ActiveOrganization ?? throw new UnauthorizedAccessException("No active organization is available."); + var systemFilter = new AppFilter(organization); var results = await _projectRepository.GetByFilterAsync(systemFilter, filter, sort, o => o .SearchBeforeToken(before, _serializer) @@ -141,43 +258,50 @@ public async Task>> ListProjectsAsync( [McpServerTool(Name = "get_project", ReadOnly = true, UseStructuredContent = true)] [Description("Gets summary details for a specific Exceptionless project.")] public async Task> GetProjectAsync( - [Description("The Exceptionless project id.")] - string projectId) + [Description("Optional Exceptionless project id. Defaults to the active MCP project context.")] + string? projectId = null) { try { EnsureScope(AuthorizationRoles.ProjectsRead); - if (!TryValidateId(projectId, "projectId", out var idError)) + if (!String.IsNullOrWhiteSpace(projectId) && !TryValidateId(projectId, "projectId", out var idError)) return McpResponse.Failed(idError); - var project = await GetAccessibleProjectAsync(projectId); - return McpResponse.Success(ToProjectResult(project)); + var projectContext = await _mcpContextService.ResolveProjectAsync(projectId); + if (!projectContext.Succeeded) + return McpResponse.Failed(projectContext.Error!); + + return McpResponse.Success(ToProjectResult(projectContext.Project!)); } catch (Exception ex) when (IsLookupError(ex)) { - return McpResponse.Failed(ToLookupError("Project", projectId, ex)); + return McpResponse.Failed(ToLookupError("Project", projectId ?? "active project", ex)); } } [McpServerTool(Name = "get_client_setup_instructions", ReadOnly = true, UseStructuredContent = true)] [Description("Gets project-specific Exceptionless client setup instructions for sending events from an app. Use this for setup questions such as Expo or React Native apps.")] public async Task> GetClientSetupInstructionsAsync( - [Description("The Exceptionless project id to configure.")] - string projectId, + [Description("Optional Exceptionless project id to configure. Defaults to the active MCP project context.")] + string? projectId = null, [Description("Client platform to configure. Supported values: expo, react-native. Use expo for Expo apps.")] string platform = "expo") { try { EnsureScope(AuthorizationRoles.ProjectsRead); - if (!TryValidateId(projectId, "projectId", out var idError)) + if (!String.IsNullOrWhiteSpace(projectId) && !TryValidateId(projectId, "projectId", out var idError)) return McpResponse.Failed(idError); string normalizedPlatform = platform.Trim().ToLowerInvariant(); if (!ClientSetupPlatforms.Contains(normalizedPlatform)) return McpResponse.Failed(McpErrors.InvalidClientPlatform($"Unsupported client platform '{platform}'.", platform, ClientSetupPlatforms)); - var project = await GetAccessibleProjectAsync(projectId); + var projectContext = await _mcpContextService.ResolveProjectAsync(projectId); + if (!projectContext.Succeeded) + return McpResponse.Failed(projectContext.Error!); + + var project = projectContext.Project!; var tokenResults = await _tokenRepository.GetByTypeAndProjectIdAsync(TokenType.Access, project.Id, o => o.PageLimit(10)); var token = tokenResults.Documents.FirstOrDefault(t => !t.IsDisabled && !t.IsSuspended); string apiKey = token?.Id ?? "YOUR_API_KEY"; @@ -228,7 +352,7 @@ public async Task> GetClientSetupI } catch (Exception ex) when (IsLookupError(ex)) { - return McpResponse.Failed(ToLookupError("Project", projectId, ex)); + return McpResponse.Failed(ToLookupError("Project", projectId ?? "active project", ex)); } catch (Exception ex) when (IsExpectedToolError(ex)) { @@ -239,8 +363,8 @@ public async Task> GetClientSetupI [McpServerTool(Name = "search_stacks", ReadOnly = true, UseStructuredContent = true)] [Description("Searches stacks in an Exceptionless project, useful for top issues, top 404s, or recent problem groups. When pagination.hasMore is true, pass pagination.after to fetch the next page or pagination.before to fetch the previous page.")] public async Task>> SearchStacksAsync( - [Description("The Exceptionless project id to search within.")] - string projectId, + [Description("Optional Exceptionless project id to search within. Defaults to the active MCP project context.")] + string? projectId = null, [Description(StackFilterDescription)] string? filter = null, [Description("Optional sort expression. Defaults to -last_occurrence.")] @@ -261,7 +385,7 @@ public async Task>> SearchStacksAsync( try { EnsureScope(AuthorizationRoles.StacksRead); - if (!TryValidateId(projectId, "projectId", out var idError)) + if (!String.IsNullOrWhiteSpace(projectId) && !TryValidateId(projectId, "projectId", out var idError)) return McpResponse>.Failed(idError); var validation = await ValidateSearchAsync(filter, sort, limit, StackFilterFields, StackSortFields, _stackQueryValidator); @@ -274,7 +398,12 @@ public async Task>> SearchStacksAsync( if (!TryResolveTimeRange(last, startUtc, endUtc, out var timeRange, out var timeError)) return McpResponse>.Failed(timeError); - var (project, organization) = await GetProjectAndOrganizationAsync(projectId); + var projectContext = await _mcpContextService.ResolveProjectAsync(projectId); + if (!projectContext.Succeeded) + return McpResponse>.Failed(projectContext.Error!); + + var project = projectContext.Project!; + var organization = projectContext.Organization!; var systemFilter = new AppFilter(project, organization); var results = await _stackRepository.FindAsync( @@ -288,7 +417,7 @@ public async Task>> SearchStacksAsync( } catch (Exception ex) when (IsLookupError(ex)) { - return McpResponse>.Failed(ToLookupError("Project", projectId, ex)); + return McpResponse>.Failed(ToLookupError("Project", projectId ?? "active project", ex)); } catch (Exception ex) when (IsExpectedToolError(ex)) { @@ -380,8 +509,8 @@ public async Task>> GetStackEventsAsync( [McpServerTool(Name = "search_events", ReadOnly = true, UseStructuredContent = true)] [Description("Searches event summary rows in an Exceptionless project. Use this for event-first triage across correlation ids, order ids, users, sessions, recent windows, or data.* fields. When pagination.hasMore is true, pass pagination.after or pagination.before to page.")] public async Task>> SearchEventsAsync( - [Description("The Exceptionless project id to search within.")] - string projectId, + [Description("Optional Exceptionless project id to search within. Defaults to the active MCP project context.")] + string? projectId = null, [Description(EventFilterDescription)] string? filter = null, [Description("Optional sort expression. Defaults to -date.")] @@ -402,7 +531,7 @@ public async Task>> SearchEventsAsync( try { EnsureScope(AuthorizationRoles.EventsRead); - if (!TryValidateId(projectId, "projectId", out var idError)) + if (!String.IsNullOrWhiteSpace(projectId) && !TryValidateId(projectId, "projectId", out var idError)) return McpResponse>.Failed(idError); var validation = await ValidateSearchAsync(filter, sort, limit, EventFilterFields, EventSortFields, _eventQueryValidator); @@ -415,7 +544,12 @@ public async Task>> SearchEventsAsync( if (!TryResolveTimeRange(last, startUtc, endUtc, out var timeRange, out var timeError)) return McpResponse>.Failed(timeError); - var (project, organization) = await GetProjectAndOrganizationAsync(projectId); + var projectContext = await _mcpContextService.ResolveProjectAsync(projectId); + if (!projectContext.Succeeded) + return McpResponse>.Failed(projectContext.Error!); + + var project = projectContext.Project!; + var organization = projectContext.Organization!; var systemFilter = new AppFilter(project, organization); var results = await _eventRepository.FindAsync( @@ -429,7 +563,7 @@ public async Task>> SearchEventsAsync( } catch (Exception ex) when (IsLookupError(ex)) { - return McpResponse>.Failed(ToLookupError("Project", projectId, ex)); + return McpResponse>.Failed(ToLookupError("Project", projectId ?? "active project", ex)); } catch (Exception ex) when (IsExpectedToolError(ex)) { @@ -461,6 +595,10 @@ public async Task> GetEventAsync( return McpResponse.Failed(McpErrors.NotFound($"Event {eventId} was not found or is not accessible.", "eventId", eventId)); EnsureOrganizationAccess(ev.OrganizationId); + var contextError = await _mcpContextService.ValidateProjectScopeAsync(ev.OrganizationId, ev.ProjectId); + if (contextError is not null) + return McpResponse.Failed(contextError); + return McpResponse.Success(ToEventResult(ev, includeDetails, maxDetailSize)); } catch (Exception ex) when (IsLookupError(ex)) @@ -472,8 +610,8 @@ public async Task> GetEventAsync( [McpServerTool(Name = "count_events", ReadOnly = true, UseStructuredContent = true)] [Description("Counts Exceptionless events and occurrences in a project, with optional time buckets and groupBy dimensions for questions like occurrences by version, tag, user, or error type.")] public async Task> CountEventsAsync( - [Description("The Exceptionless project id to count within.")] - string projectId, + [Description("Optional Exceptionless project id to count within. Defaults to the active MCP project context.")] + string? projectId = null, [Description(EventFilterDescription)] string? filter = null, [Description(LastDescription)] @@ -492,7 +630,7 @@ public async Task> CountEventsAsync( try { EnsureScope(AuthorizationRoles.EventsRead); - if (!TryValidateId(projectId, "projectId", out var idError)) + if (!String.IsNullOrWhiteSpace(projectId) && !TryValidateId(projectId, "projectId", out var idError)) return McpResponse.Failed(idError); var validation = await ValidateSearchAsync(filter, sort: null, DefaultLimit, EventFilterFields, EventSortFields, _eventQueryValidator); @@ -511,7 +649,12 @@ public async Task> CountEventsAsync( if (!TryValidateGroupLimit(groupLimit, out int resolvedGroupLimit, out var groupLimitError, out string? groupLimitWarning)) return McpResponse.Failed(groupLimitError); - var (project, organization) = await GetProjectAndOrganizationAsync(projectId); + var projectContext = await _mcpContextService.ResolveProjectAsync(projectId); + if (!projectContext.Succeeded) + return McpResponse.Failed(projectContext.Error!); + + var project = projectContext.Project!; + var organization = projectContext.Organization!; var systemFilter = new AppFilter(project, organization); string aggregations = BuildCountEventsAggregations(interval, resolvedGroupBy, resolvedGroupLimit); @@ -572,7 +715,7 @@ public async Task> CountEventsAsync( } catch (Exception ex) when (IsLookupError(ex)) { - return McpResponse.Failed(ToLookupError("Project", projectId, ex)); + return McpResponse.Failed(ToLookupError("Project", projectId ?? "active project", ex)); } catch (Exception ex) when (IsExpectedToolError(ex)) { @@ -874,6 +1017,10 @@ private async Task GetAccessibleStackAsync(string stackId) throw new KeyNotFoundException($"Stack {stackId} was not found."); EnsureOrganizationAccess(stack.OrganizationId); + var contextError = await _mcpContextService.ValidateProjectScopeAsync(stack.OrganizationId, stack.ProjectId); + if (contextError is not null) + throw new McpContextException(contextError); + return stack; } @@ -887,6 +1034,10 @@ private async Task GetAccessibleStackForWriteAsync(string stackId) throw new KeyNotFoundException($"Stack {stackId} was not found."); EnsureOrganizationAccess(stack.OrganizationId); + var contextError = await _mcpContextService.ValidateProjectScopeAsync(stack.OrganizationId, stack.ProjectId); + if (contextError is not null) + throw new McpContextException(contextError); + return stack; } @@ -1306,7 +1457,7 @@ private static bool TryValidateDetailSize(int maxDetailSize, out McpErrorInfo er private static bool IsLookupError(Exception ex) { - return ex is ArgumentException or KeyNotFoundException or UnauthorizedAccessException; + return ex is ArgumentException or KeyNotFoundException or UnauthorizedAccessException or McpContextException; } private static bool IsExpectedToolError(Exception ex) @@ -1321,6 +1472,7 @@ private static McpErrorInfo ToLookupError(string resourceName, string resourceId return ex switch { ArgumentException => McpErrors.InvalidId($"{resourceName} id is invalid.", $"{resourceName.ToLowerInvariant()}Id", resourceId), + McpContextException context => context.Error, McpForbiddenException forbidden => McpErrors.Forbidden(forbidden.Message, forbidden.RequiredScope), UnauthorizedAccessException => McpErrors.NotAccessible(message, resourceName, resourceId), KeyNotFoundException => McpErrors.NotFound(message, $"{resourceName.ToLowerInvariant()}Id", resourceId), diff --git a/src/Exceptionless.Web/Mcp/McpContextService.cs b/src/Exceptionless.Web/Mcp/McpContextService.cs new file mode 100644 index 000000000..8f3f90550 --- /dev/null +++ b/src/Exceptionless.Web/Mcp/McpContextService.cs @@ -0,0 +1,459 @@ +using System.Security.Claims; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.Web.Extensions; +using Foundatio.Caching; +using Foundatio.Repositories; +using Foundatio.Repositories.Options; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol; + +namespace Exceptionless.Web.Mcp; + +public sealed class McpContextService( + IHttpContextAccessor httpContextAccessor, + ICacheClient cacheClient, + IOrganizationRepository organizationRepository, + IProjectRepository projectRepository, + IServiceProvider serviceProvider, + TimeProvider timeProvider) +{ + private const int CandidateLimit = 100; + private const string CacheKeyPrefix = "mcp:context:"; + private const string SessionHeaderName = "MCP-Session-Id"; + private static readonly TimeSpan ContextLifetime = TimeSpan.FromHours(12); + + private HttpRequest Request => httpContextAccessor.HttpContext?.Request + ?? throw new UnauthorizedAccessException("No active request is available."); + + private ClaimsPrincipal User => httpContextAccessor.HttpContext?.User + ?? throw new UnauthorizedAccessException("No authenticated user is available."); + + public async Task GetContextAsync(bool requireProject = false) + { + string? cacheKey = GetCacheKey(); + if (String.IsNullOrEmpty(cacheKey)) + { + return McpContextResolution.Failed(McpErrors.ContextRequired( + "A stable MCP session is required before project context can be stored.", + "session", + [], + [])); + } + + var accessibleOrganizations = await GetAccessibleOrganizationsAsync(); + if (accessibleOrganizations.Count == 0) + { + return McpContextResolution.Failed(McpErrors.NotAccessible("No accessible organizations were found.", "organization")); + } + + var storedContext = await cacheClient.GetAsync(cacheKey, null); + if (storedContext is not null) + await cacheClient.SetExpirationAsync(cacheKey, ContextLifetime); + + bool changed = false; + string? activeOrganizationId = storedContext?.ActiveOrganizationId; + string? activeProjectId = storedContext?.ActiveProjectId; + + var accessibleOrganization = accessibleOrganizations.FirstOrDefault(o => String.Equals(o.Id, activeOrganizationId, StringComparison.Ordinal)); + if (!String.IsNullOrEmpty(activeOrganizationId) && accessibleOrganization is null) + { + activeOrganizationId = null; + activeProjectId = null; + changed = true; + } + + if (String.IsNullOrEmpty(activeOrganizationId)) + { + if (accessibleOrganizations.Count != 1) + { + var context = ToContextResult(null, null, accessibleOrganizations, []); + return McpContextResolution.Failed(McpErrors.ContextRequired( + "Select an active organization before using project-scoped MCP tools.", + "organization", + context.Organizations, + context.Projects), context); + } + + accessibleOrganization = accessibleOrganizations[0]; + activeOrganizationId = accessibleOrganization.Id; + changed = true; + } + + accessibleOrganization ??= accessibleOrganizations.First(o => String.Equals(o.Id, activeOrganizationId, StringComparison.Ordinal)); + var accessibleProjects = await GetOrganizationProjectsAsync(accessibleOrganization.Id); + var activeProject = await GetValidatedProjectAsync(activeProjectId, accessibleOrganization.Id); + if (!String.IsNullOrEmpty(activeProjectId) && activeProject is null) + { + activeProjectId = null; + changed = true; + } + + if (String.IsNullOrEmpty(activeProjectId) && requireProject) + { + if (accessibleProjects.Total == 1) + { + activeProject = accessibleProjects.Documents.FirstOrDefault(); + activeProjectId = activeProject?.Id; + changed = activeProject is not null; + } + else + { + var context = ToContextResult(accessibleOrganization, null, accessibleOrganizations, accessibleProjects.Documents); + return McpContextResolution.Failed(McpErrors.ContextRequired( + "Select an active project before using this MCP tool.", + "project", + context.Organizations, + context.Projects), context, accessibleOrganization); + } + } + + if (changed) + await SaveContextAsync(cacheKey, activeOrganizationId, activeProjectId); + + var result = ToContextResult(accessibleOrganization, activeProject, accessibleOrganizations, accessibleProjects.Documents, storedContext?.UpdatedUtc); + return McpContextResolution.Success(result, accessibleOrganization, activeProject); + } + + public async Task ListOrganizationsAsync() + { + var accessibleOrganizations = await GetAccessibleOrganizationsAsync(); + var context = await GetContextAsync(requireProject: false); + var activeOrganization = context.ActiveOrganization; + var activeProject = context.ActiveProject; + var projects = activeOrganization is null + ? Array.Empty() + : (await GetOrganizationProjectsAsync(activeOrganization.Id)).Documents; + + return McpContextResolution.Success(ToContextResult(activeOrganization, activeProject, accessibleOrganizations, projects), activeOrganization, activeProject); + } + + public async Task SwitchOrganizationAsync(string organizationId) + { + string? cacheKey = GetCacheKey(); + if (String.IsNullOrEmpty(cacheKey)) + { + return McpContextResolution.Failed(McpErrors.ContextRequired( + "A stable MCP session is required before organization context can be stored.", + "session", + [], + [])); + } + + var accessibleOrganizations = await GetAccessibleOrganizationsAsync(); + var activeOrganization = accessibleOrganizations.FirstOrDefault(o => String.Equals(o.Id, organizationId, StringComparison.Ordinal)); + if (activeOrganization is null) + return McpContextResolution.Failed(McpErrors.NotAccessible($"Organization {organizationId} was not found or is not accessible.", "organizationId", organizationId)); + + var projects = await GetOrganizationProjectsAsync(activeOrganization.Id); + var activeProject = projects.Total == 1 ? projects.Documents.FirstOrDefault() : null; + + await SaveContextAsync(cacheKey, activeOrganization.Id, activeProject?.Id); + return McpContextResolution.Success(ToContextResult(activeOrganization, activeProject, accessibleOrganizations, projects.Documents), activeOrganization, activeProject); + } + + public async Task SwitchProjectAsync(string projectId) + { + string? cacheKey = GetCacheKey(); + if (String.IsNullOrEmpty(cacheKey)) + { + return McpContextResolution.Failed(McpErrors.ContextRequired( + "A stable MCP session is required before project context can be stored.", + "session", + [], + [])); + } + + var project = await GetAccessibleProjectAsync(projectId); + if (project is null) + return McpContextResolution.Failed(McpErrors.NotAccessible($"Project {projectId} was not found or is not accessible.", "projectId", projectId)); + + var accessibleOrganizations = await GetAccessibleOrganizationsAsync(); + var activeOrganization = accessibleOrganizations.FirstOrDefault(o => String.Equals(o.Id, project.OrganizationId, StringComparison.Ordinal)); + if (activeOrganization is null) + return McpContextResolution.Failed(McpErrors.NotAccessible($"Organization {project.OrganizationId} was not found or is not accessible.", "organizationId", project.OrganizationId)); + + var projects = await GetOrganizationProjectsAsync(activeOrganization.Id); + await SaveContextAsync(cacheKey, activeOrganization.Id, project.Id); + return McpContextResolution.Success(ToContextResult(activeOrganization, project, accessibleOrganizations, projects.Documents), activeOrganization, project); + } + + public async Task ResolveProjectContextAsync(string? projectId = null, string? projectName = null, string? organizationId = null) + { + if (!String.IsNullOrWhiteSpace(projectId)) + return await SwitchProjectAsync(projectId.Trim()); + + if (String.IsNullOrWhiteSpace(projectName)) + return await GetContextAsync(requireProject: true); + + McpContextResolution context; + if (!String.IsNullOrWhiteSpace(organizationId)) + { + context = await SwitchOrganizationAsync(organizationId.Trim()); + if (!context.Succeeded) + return context; + } + else + { + context = await GetContextAsync(requireProject: false); + if (!context.Succeeded) + return context; + } + + if (context.ActiveOrganization is null) + return context; + + var projects = await GetOrganizationProjectsAsync(context.ActiveOrganization.Id); + var matches = projects.Documents + .Where(p => String.Equals(p.Name, projectName.Trim(), StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + if (matches.Length == 0) + return McpContextResolution.Failed(McpErrors.NotFound($"Project '{projectName}' was not found in the active organization.", "projectName", projectName)); + + if (matches.Length > 1) + { + var result = ToContextResult(context.ActiveOrganization, context.ActiveProject, context.Context.Organizations.Select(ToOrganization).ToArray(), matches); + return McpContextResolution.Failed(McpErrors.ContextRequired( + $"Multiple projects named '{projectName}' were found. Select a project explicitly.", + "project", + result.Organizations, + result.Projects), result, context.ActiveOrganization, context.ActiveProject); + } + + return await SwitchProjectAsync(matches[0].Id); + } + + public async Task ResolveProjectAsync(string? projectId) + { + if (String.IsNullOrWhiteSpace(projectId)) + { + var resolvedContext = await GetContextAsync(requireProject: true); + return resolvedContext.Succeeded && resolvedContext.ActiveProject is not null && resolvedContext.ActiveOrganization is not null + ? McpProjectContextResolution.Success(resolvedContext.ActiveProject, resolvedContext.ActiveOrganization, resolvedContext.Context) + : McpProjectContextResolution.Failed(resolvedContext.Error!, resolvedContext.Context); + } + + var project = await GetAccessibleProjectAsync(projectId.Trim()); + if (project is null) + { + return McpProjectContextResolution.Failed(McpErrors.NotAccessible($"Project {projectId} was not found or is not accessible.", "projectId", projectId)); + } + + var context = await GetContextAsync(requireProject: false); + if (!context.Succeeded) + return McpProjectContextResolution.Failed(context.Error!, context.Context); + + if (context.ActiveOrganization is null) + { + return McpProjectContextResolution.Failed(McpErrors.ContextRequired( + "Select an active organization before using project-scoped MCP tools.", + "organization", + context.Context.Organizations, + context.Context.Projects), context.Context); + } + + if (!String.Equals(project.OrganizationId, context.ActiveOrganization.Id, StringComparison.Ordinal)) + { + return McpProjectContextResolution.Failed(McpErrors.ContextMismatch( + "The requested project is not in the active organization.", + context.ActiveOrganization.Id, + project.OrganizationId, + context.ActiveProject?.Id, + project.Id), context.Context); + } + + if (context.ActiveProject is not null && !String.Equals(project.Id, context.ActiveProject.Id, StringComparison.Ordinal)) + { + return McpProjectContextResolution.Failed(McpErrors.ContextMismatch( + "The requested project does not match the active project.", + context.ActiveOrganization.Id, + project.OrganizationId, + context.ActiveProject.Id, + project.Id), context.Context); + } + + if (context.ActiveProject is null) + context = await SwitchProjectAsync(project.Id); + + return context.Succeeded && context.ActiveOrganization is not null + ? McpProjectContextResolution.Success(project, context.ActiveOrganization, context.Context) + : McpProjectContextResolution.Failed(context.Error!, context.Context); + } + + public async Task ValidateProjectScopeAsync(string organizationId, string projectId) + { + var context = await GetContextAsync(requireProject: true); + if (!context.Succeeded) + return context.Error; + + if (context.ActiveOrganization is null || context.ActiveProject is null) + return McpErrors.ContextRequired("Select an active project before using this MCP tool.", "project", context.Context.Organizations, context.Context.Projects); + + if (!String.Equals(context.ActiveOrganization.Id, organizationId, StringComparison.Ordinal) || !String.Equals(context.ActiveProject.Id, projectId, StringComparison.Ordinal)) + { + return McpErrors.ContextMismatch( + "The requested resource does not match the active MCP context.", + context.ActiveOrganization.Id, + organizationId, + context.ActiveProject.Id, + projectId); + } + + return null; + } + + private async Task> GetAccessibleOrganizationsAsync() + { + var organizationIds = Request.GetAssociatedOrganizationIds(); + if (organizationIds.Count == 0) + return []; + + var organizations = await organizationRepository.GetByIdsAsync(organizationIds.Distinct(StringComparer.Ordinal).ToArray(), o => o.Cache()); + return organizations + .OrderBy(o => o.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(o => o.Id, StringComparer.Ordinal) + .ToArray(); + } + + private async Task GetAccessibleProjectAsync(string projectId) + { + var project = await projectRepository.GetByIdAsync(projectId, o => o.Cache()); + if (project is null) + return null; + + return Request.GetAssociatedOrganizationIds().Contains(project.OrganizationId) + ? project + : null; + } + + private async Task GetValidatedProjectAsync(string? projectId, string organizationId) + { + if (String.IsNullOrWhiteSpace(projectId)) + return null; + + var project = await projectRepository.GetByIdAsync(projectId, o => o.Cache()); + if (project is null || !String.Equals(project.OrganizationId, organizationId, StringComparison.Ordinal)) + return null; + + return project; + } + + private Task> GetOrganizationProjectsAsync(string organizationId) + { + return projectRepository.GetByOrganizationIdAsync(organizationId, o => o.PageLimit(CandidateLimit)); + } + + private Task SaveContextAsync(string cacheKey, string? activeOrganizationId, string? activeProjectId) + { + return cacheClient.SetAsync(cacheKey, new McpStoredContext(activeOrganizationId, activeProjectId, timeProvider.GetUtcNow().UtcDateTime), ContextLifetime); + } + + private string? GetCacheKey() + { + string? sessionId = null; + if (Request.Headers.TryGetValue(SessionHeaderName, out var sessionHeader)) + sessionId = sessionHeader.FirstOrDefault(); + + if (String.IsNullOrWhiteSpace(sessionId)) + sessionId = serviceProvider.GetService()?.SessionId; + + string? userId = User.GetClaimValue(ClaimTypes.NameIdentifier); + if (String.IsNullOrWhiteSpace(sessionId) || String.IsNullOrWhiteSpace(userId)) + return null; + + string clientId = User.GetClaimValue(IdentityUtils.OAuthClientIdClaim) ?? "user"; + string resource = User.GetClaimValue(IdentityUtils.OAuthResourceClaim) ?? Request.Path.ToString(); + return String.Concat( + CacheKeyPrefix, + sessionId.ToSHA1(), + ":", + userId.ToSHA1(), + ":", + clientId.ToSHA1(), + ":", + resource.ToSHA1()); + } + + private static McpContextResult ToContextResult( + Organization? activeOrganization, + Project? activeProject, + IReadOnlyCollection organizations, + IReadOnlyCollection projects, + DateTime? updatedUtc = null) + { + return new McpContextResult( + activeOrganization?.Id, + activeOrganization?.Name, + activeProject?.Id, + activeProject?.Name, + organizations.Select(ToOrganizationResult).ToArray(), + projects.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase).ThenBy(p => p.Id, StringComparer.Ordinal).Select(ToProjectResult).ToArray(), + activeOrganization is null, + activeOrganization is not null && activeProject is null && projects.Count > 1, + updatedUtc); + } + + private static Organization ToOrganization(McpOrganizationResult organization) + { + return new Organization + { + Id = organization.Id, + Name = organization.Name + }; + } + + private static McpOrganizationResult ToOrganizationResult(Organization organization) + { + return new McpOrganizationResult( + organization.Id, + organization.Name, + $"/api/v2/organizations/{organization.Id}"); + } + + private static McpProjectResult ToProjectResult(Project project) + { + return new McpProjectResult( + project.Id, + project.OrganizationId, + project.Name, + project.CreatedUtc, + project.UpdatedUtc, + $"/api/v2/projects/{project.Id}", + project.IsConfigured, + project.LastEventDateUtc); + } +} + +public sealed record McpStoredContext(string? ActiveOrganizationId, string? ActiveProjectId, DateTime UpdatedUtc); + +public sealed record McpContextResolution(McpContextResult Context, Organization? ActiveOrganization, Project? ActiveProject, McpErrorInfo? Error) +{ + public bool Succeeded => Error is null; + + public static McpContextResolution Success(McpContextResult context, Organization? activeOrganization, Project? activeProject) + { + return new McpContextResolution(context, activeOrganization, activeProject, null); + } + + public static McpContextResolution Failed(McpErrorInfo error, McpContextResult? context = null, Organization? activeOrganization = null, Project? activeProject = null) + { + return new McpContextResolution(context ?? McpContextResult.Empty, activeOrganization, activeProject, error); + } +} + +public sealed record McpProjectContextResolution(Project? Project, Organization? Organization, McpContextResult Context, McpErrorInfo? Error) +{ + public bool Succeeded => Error is null; + + public static McpProjectContextResolution Success(Project project, Organization organization, McpContextResult context) + { + return new McpProjectContextResolution(project, organization, context, null); + } + + public static McpProjectContextResolution Failed(McpErrorInfo error, McpContextResult? context = null) + { + return new McpProjectContextResolution(null, null, context ?? McpContextResult.Empty, error); + } +} diff --git a/src/Exceptionless.Web/Mcp/McpErrors.cs b/src/Exceptionless.Web/Mcp/McpErrors.cs index 0da326814..04c9e4f98 100644 --- a/src/Exceptionless.Web/Mcp/McpErrors.cs +++ b/src/Exceptionless.Web/Mcp/McpErrors.cs @@ -6,6 +6,8 @@ namespace Exceptionless.Web.Mcp; public static class McpErrorCodes { + public const string ContextMismatch = "context_mismatch"; + public const string ContextRequired = "context_required"; public const string Forbidden = "forbidden"; public const string InvalidClientPlatform = "invalid_client_platform"; public const string InvalidCursor = "invalid_cursor"; @@ -29,6 +31,31 @@ public static class McpErrorCodes public static class McpErrors { + public static McpErrorInfo ContextMismatch(string message, string? activeOrganizationId, string? requestedOrganizationId, string? activeProjectId, string? requestedProjectId) + { + return new McpErrorInfo(McpErrorCodes.ContextMismatch, message, new Dictionary + { + ["activeOrganizationId"] = activeOrganizationId, + ["requestedOrganizationId"] = requestedOrganizationId, + ["activeProjectId"] = activeProjectId, + ["requestedProjectId"] = requestedProjectId + }); + } + + public static McpErrorInfo ContextRequired( + string message, + string selection, + IReadOnlyCollection organizations, + IReadOnlyCollection projects) + { + return new McpErrorInfo(McpErrorCodes.ContextRequired, message, new Dictionary + { + ["selection"] = selection, + ["organizations"] = organizations, + ["projects"] = projects + }); + } + public static McpErrorInfo Forbidden(string message, string requiredScope) { return new McpErrorInfo(McpErrorCodes.Forbidden, message, new Dictionary @@ -202,3 +229,8 @@ public sealed class McpForbiddenException(string message, string requiredScope) { public string RequiredScope { get; } = requiredScope; } + +public sealed class McpContextException(McpErrorInfo error) : InvalidOperationException(error.Message) +{ + public McpErrorInfo Error { get; } = error; +} diff --git a/src/Exceptionless.Web/Mcp/McpModels.cs b/src/Exceptionless.Web/Mcp/McpModels.cs index 89e15a1f5..c628869b2 100644 --- a/src/Exceptionless.Web/Mcp/McpModels.cs +++ b/src/Exceptionless.Web/Mcp/McpModels.cs @@ -22,6 +22,25 @@ public sealed record McpListData(IReadOnlyCollection Items); public sealed record McpPagination(bool HasMore, int Limit, string? Before = null, string? After = null); +public sealed record McpContextResult( + string? ActiveOrganizationId, + string? ActiveOrganizationName, + string? ActiveProjectId, + string? ActiveProjectName, + IReadOnlyCollection Organizations, + IReadOnlyCollection Projects, + bool RequiresOrganizationSelection, + bool RequiresProjectSelection, + DateTime? UpdatedUtc = null) +{ + public static McpContextResult Empty { get; } = new(null, null, null, null, [], [], false, false); +} + +public sealed record McpOrganizationResult( + string Id, + string Name, + string Url); + public sealed record McpTimeRange(DateTime? StartUtc, DateTime? EndUtc) { public bool HasRange => StartUtc.HasValue || EndUtc.HasValue; diff --git a/src/Exceptionless.Web/Startup.cs b/src/Exceptionless.Web/Startup.cs index 30163e942..8a1511db4 100644 --- a/src/Exceptionless.Web/Startup.cs +++ b/src/Exceptionless.Web/Startup.cs @@ -133,8 +133,9 @@ public void ConfigureServices(IServiceCollection services) var appOptions = AppOptions.ReadFromConfiguration(Configuration); Bootstrapper.RegisterServices(services, appOptions, Log.Logger.ToLoggerFactory()); + services.AddScoped(); services.AddMcpServer() - .WithHttpTransport(o => o.Stateless = true) + .WithHttpTransport(o => o.Stateless = false) .WithTools(); services.AddSingleton(s => @@ -333,12 +334,6 @@ ApplicationException applicationException when applicationException.Message.Cont endpoints.MapControllers(); endpoints.MapMcp("/mcp").RequireAuthorization(AuthorizationRoles.McpPolicy); - endpoints.MapMethods("/mcp", [HttpMethods.Get, HttpMethods.Delete], context => - { - context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; - context.Response.Headers[HeaderNames.Allow] = HttpMethods.Post; - return Task.CompletedTask; - }).RequireAuthorization(AuthorizationRoles.McpPolicy); endpoints.MapFallback("{**slug:nonfile}", CreateRequestDelegate(endpoints, "/index.html")); }); } diff --git a/tests/Exceptionless.Tests/Controllers/ExceptionlessMcpToolsTests.cs b/tests/Exceptionless.Tests/Controllers/ExceptionlessMcpToolsTests.cs index 3e8f2bec5..ef9652d09 100644 --- a/tests/Exceptionless.Tests/Controllers/ExceptionlessMcpToolsTests.cs +++ b/tests/Exceptionless.Tests/Controllers/ExceptionlessMcpToolsTests.cs @@ -10,6 +10,7 @@ using Exceptionless.Core.Utility; using Exceptionless.Tests.Utility; using Exceptionless.Web.Mcp; +using Foundatio.Caching; using Foundatio.Repositories; using Foundatio.Repositories.Extensions; using Foundatio.Serializer; @@ -43,6 +44,135 @@ protected override async Task ResetDataAsync() await GetService().CreateDataAsync(); } + [Fact] + public async Task GetContextAsync_MultipleOrganizations_ReturnsContextRequired() + { + var tools = await CreateToolsForOrganizationsAsync( + Guid.NewGuid().ToString("N"), + [TestConstants.OrganizationId, SampleDataService.FREE_ORG_ID], + AuthorizationRoles.McpRead, + AuthorizationRoles.ProjectsRead); + + var result = await tools.GetContextAsync(); + + Assert.False(result.Ok); + Assert.Equal(McpErrorCodes.ContextRequired, result.Error?.Code); + Assert.Equal("organization", result.Error?.Details?["selection"]); + var organizations = Assert.IsAssignableFrom>(result.Error?.Details?["organizations"]); + Assert.Contains(organizations, organization => organization.Id == TestConstants.OrganizationId); + Assert.Contains(organizations, organization => organization.Id == SampleDataService.FREE_ORG_ID); + } + + [Fact] + public async Task SwitchOrganizationAsync_FiltersProjectsToActiveOrganization() + { + var tools = await CreateToolsForOrganizationsAsync( + Guid.NewGuid().ToString("N"), + [TestConstants.OrganizationId, SampleDataService.FREE_ORG_ID], + AuthorizationRoles.McpRead, + AuthorizationRoles.ProjectsRead); + + var context = await tools.SwitchOrganizationAsync(SampleDataService.FREE_ORG_ID); + var projects = await tools.ListProjectsAsync(limit: 50); + + Assert.True(context.Ok); + Assert.Equal(SampleDataService.FREE_ORG_ID, Data(context).ActiveOrganizationId); + Assert.Equal(SampleDataService.FREE_PROJECT_ID, Data(context).ActiveProjectId); + Assert.True(projects.Ok); + Assert.All(Items(projects), project => Assert.Equal(SampleDataService.FREE_ORG_ID, project.OrganizationId)); + Assert.Contains(Items(projects), project => project.Id == SampleDataService.FREE_PROJECT_ID); + } + + [Fact] + public async Task GetContextAsync_IsIsolatedPerMcpSession() + { + var organizationIds = new[] { TestConstants.OrganizationId, SampleDataService.FREE_ORG_ID }; + var toolsA = await CreateToolsForOrganizationsAsync(Guid.NewGuid().ToString("N"), organizationIds, AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead); + var toolsB = await CreateToolsForOrganizationsAsync(Guid.NewGuid().ToString("N"), organizationIds, AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead); + + var switchResult = await toolsA.SwitchOrganizationAsync(SampleDataService.FREE_ORG_ID); + var contextB = await toolsB.GetContextAsync(); + + Assert.True(switchResult.Ok); + Assert.False(contextB.Ok); + Assert.Equal(McpErrorCodes.ContextRequired, contextB.Error?.Code); + Assert.Equal("organization", contextB.Error?.Details?["selection"]); + } + + [Fact] + public async Task GetContextAsync_StaleOrganizationContext_ReResolvesAccessibleOrganization() + { + string sessionId = Guid.NewGuid().ToString("N"); + var toolsWithBothOrganizations = await CreateToolsForOrganizationsAsync( + sessionId, + [TestConstants.OrganizationId, SampleDataService.FREE_ORG_ID], + AuthorizationRoles.McpRead, + AuthorizationRoles.ProjectsRead); + await toolsWithBothOrganizations.SwitchOrganizationAsync(SampleDataService.FREE_ORG_ID); + + var toolsWithSingleOrganization = await CreateToolsForOrganizationsAsync( + sessionId, + [TestConstants.OrganizationId], + AuthorizationRoles.McpRead, + AuthorizationRoles.ProjectsRead); + + var context = await toolsWithSingleOrganization.GetContextAsync(); + + Assert.True(context.Ok); + Assert.Equal(TestConstants.OrganizationId, Data(context).ActiveOrganizationId); + Assert.Null(Data(context).ActiveProjectId); + } + + [Fact] + public async Task SearchStacksAsync_WithoutProjectId_UsesActiveProject() + { + var (stacks, _) = await CreateDataAsync(d => d.Event().TestProject().Message("MCP active project stack")); + var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead, AuthorizationRoles.StacksRead); + await tools.SwitchProjectAsync(TestConstants.ProjectId); + + var result = await tools.SearchStacksAsync(limit: 50); + + Assert.True(result.Ok); + Assert.Contains(Items(result), stack => stack.Id == stacks[0].Id); + } + + [Fact] + public async Task SearchStacksAsync_WithoutProjectContext_ReturnsContextRequired() + { + var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.StacksRead); + + var result = await tools.SearchStacksAsync(); + + Assert.False(result.Ok); + Assert.Equal(McpErrorCodes.ContextRequired, result.Error?.Code); + Assert.Equal("project", result.Error?.Details?["selection"]); + } + + [Fact] + public async Task SearchStacksAsync_ConflictingProjectId_ReturnsContextMismatch() + { + var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead, AuthorizationRoles.StacksRead); + await tools.SwitchProjectAsync(TestConstants.ProjectId); + + var result = await tools.SearchStacksAsync(SampleDataService.TEST_ROCKET_SHIP_PROJECT_ID); + + Assert.False(result.Ok); + Assert.Equal(McpErrorCodes.ContextMismatch, result.Error?.Code); + } + + [Fact] + public async Task GetEventAsync_EventOutsideActiveProject_ReturnsContextMismatch() + { + var (_, events) = await CreateDataAsync(d => d.Event().TestProject().Message("MCP mismatch event")); + await RefreshDataAsync(); + var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead, AuthorizationRoles.EventsRead); + await tools.SwitchProjectAsync(SampleDataService.TEST_ROCKET_SHIP_PROJECT_ID); + + var result = await tools.GetEventAsync(events[0].Id); + + Assert.False(result.Ok); + Assert.Equal(McpErrorCodes.ContextMismatch, result.Error?.Code); + } [Fact] public async Task ListProjectsAsync_ProjectsScope_ReturnsAccessibleProjects() { @@ -122,6 +252,8 @@ public async Task GetEventAsync_EventsScope_ReturnsEvent() await RefreshDataAsync(); var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.EventsRead); + await SelectTestProjectAsync(tools); + var result = await tools.GetEventAsync(events[0].Id); Assert.True(result.Ok); @@ -150,6 +282,8 @@ public async Task GetEventAsync_EventsScope_ReturnsEventDetails() await RefreshDataAsync(); var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.EventsRead); + await SelectTestProjectAsync(tools); + var result = await tools.GetEventAsync(ev.Id); var item = Data(result); @@ -383,7 +517,6 @@ public async Task ListProjectsAsync_MalformedFilter_ReturnsSpecificError() [Theory] [InlineData("bad-project-id")] - [InlineData("")] public async Task GetProjectAsync_InvalidId_ReturnsInvalidId(string projectId) { var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead); @@ -435,6 +568,8 @@ public async Task GetEventAsync_DetailPayloadAboveMaximum_OmitsLargeDetails() await RefreshDataAsync(); var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.EventsRead); + await SelectTestProjectAsync(tools); + var result = await tools.GetEventAsync(ev.Id, maxDetailSize: 1024); var item = Data(result); @@ -479,6 +614,8 @@ public async Task UpdateStackStatusAsync_StacksWriteScope_MarksFixedWithVersion( var (stacks, _) = await CreateDataAsync(d => d.Event().TestProject().Message("MCP write fixed")); var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.StacksWrite); + await SelectTestProjectAsync(tools); + var result = await tools.UpdateStackStatusAsync(stacks[0].Id, "fixed", "1.0.2"); var data = Data(result); @@ -546,6 +683,8 @@ public async Task SnoozeStackAsync_Duration_SnoozesStack() var (stacks, _) = await CreateDataAsync(d => d.Event().TestProject().Message("MCP write snooze")); var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.StacksWrite); + await SelectTestProjectAsync(tools); + var result = await tools.SnoozeStackAsync(stacks[0].Id, duration: "2h"); var data = Data(result); @@ -585,6 +724,8 @@ public async Task SetStackCriticalAsync_StacksWriteScope_TogglesCritical() var (stacks, _) = await CreateDataAsync(d => d.Event().TestProject().Message("MCP write critical")); var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.StacksWrite); + await SelectTestProjectAsync(tools); + var result = await tools.SetStackCriticalAsync(stacks[0].Id, critical: true); var data = Data(result); @@ -604,6 +745,8 @@ public async Task AddStackReferenceLinkAsync_StacksWriteScope_AddsReference() var (stacks, _) = await CreateDataAsync(d => d.Event().TestProject().Message("MCP write reference")); var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.StacksWrite); + await SelectTestProjectAsync(tools); + var result = await tools.AddStackReferenceLinkAsync(stacks[0].Id, " https://github.com/exceptionless/Exceptionless/issues/123 "); var data = Data(result); @@ -624,6 +767,8 @@ public async Task AddStackReferenceLinkAsync_DuplicateReference_ReturnsUnchanged var (stacks, _) = await CreateDataAsync(d => d.Event().TestProject().StackReference(url).Message("MCP write duplicate reference")); var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.StacksWrite); + await SelectTestProjectAsync(tools); + var result = await tools.AddStackReferenceLinkAsync(stacks[0].Id, url); var data = Data(result); @@ -654,6 +799,8 @@ public async Task RemoveStackReferenceLinkAsync_StacksWriteScope_RemovesReferenc var (stacks, _) = await CreateDataAsync(d => d.Event().TestProject().StackReference(url).Message("MCP remove reference")); var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.StacksWrite); + await SelectTestProjectAsync(tools); + var result = await tools.RemoveStackReferenceLinkAsync(stacks[0].Id, url); var data = Data(result); @@ -674,6 +821,8 @@ public async Task RemoveStackReferenceLinkAsync_MissingReference_ReturnsUnchange var (stacks, _) = await CreateDataAsync(d => d.Event().TestProject().Message("MCP remove missing reference")); var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.StacksWrite); + await SelectTestProjectAsync(tools); + var result = await tools.RemoveStackReferenceLinkAsync(stacks[0].Id, url); var data = Data(result); @@ -703,6 +852,8 @@ public async Task GetStackEventsAsync_WithAfterCursor_ReturnsNextPage() await RefreshDataAsync(); var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.EventsRead); + await SelectTestProjectAsync(tools); + var firstPage = await tools.GetStackEventsAsync(stacks[0].Id, limit: 1); Assert.True(firstPage.Ok); @@ -859,6 +1010,22 @@ public async Task GetClientSetupInstructionsAsync_SchemaAdvertisesExpoPlatform() Assert.Contains("expo", platform.GetProperty("description").GetString(), StringComparison.OrdinalIgnoreCase); } + [Theory] + [InlineData(nameof(ExceptionlessMcpTools.GetProjectAsync))] + [InlineData(nameof(ExceptionlessMcpTools.GetClientSetupInstructionsAsync))] + [InlineData(nameof(ExceptionlessMcpTools.SearchStacksAsync))] + [InlineData(nameof(ExceptionlessMcpTools.SearchEventsAsync))] + [InlineData(nameof(ExceptionlessMcpTools.CountEventsAsync))] + public async Task ProjectScopedTools_ProjectIdIsOptionalInSchema(string methodName) + { + var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead, AuthorizationRoles.StacksRead, AuthorizationRoles.EventsRead); + var method = typeof(ExceptionlessMcpTools).GetMethod(methodName) ?? throw new InvalidOperationException($"Could not find {methodName}."); + var tool = McpServerTool.Create(method, tools, new McpServerToolCreateOptions()); + var properties = tool.ProtocolTool.InputSchema.GetProperty("properties"); + + Assert.True(properties.TryGetProperty("projectId", out _), "The projectId input must be advertised in the MCP tool schema."); + Assert.DoesNotContain("projectId", RequiredProperties(tool.ProtocolTool.InputSchema)); + } [Theory] [InlineData(nameof(ExceptionlessMcpTools.UpdateStackStatusAsync))] [InlineData(nameof(ExceptionlessMcpTools.SnoozeStackAsync))] @@ -895,25 +1062,42 @@ public async Task StackWriteTools_SchemaAdvertisesWriteInputs(string methodName) } } + + private static async Task SelectTestProjectAsync(ExceptionlessMcpTools tools) + { + var context = await tools.SwitchProjectAsync(TestConstants.ProjectId); + Assert.True(context.Ok); + Assert.Equal(TestConstants.ProjectId, Data(context).ActiveProjectId); + } + private Task CreateToolsAsync(params string[] scopes) { - return CreateToolsAsync(includeUserRole: false, scopes); + return CreateToolsAsync(includeUserRole: false, sessionId: Guid.NewGuid().ToString("N"), organizationIds: null, scopes); } private Task CreateToolsWithUserRoleAsync(params string[] scopes) { - return CreateToolsAsync(includeUserRole: true, scopes); + return CreateToolsAsync(includeUserRole: true, sessionId: Guid.NewGuid().ToString("N"), organizationIds: null, scopes); } - private Task CreateToolsAsync(bool includeUserRole, params string[] scopes) + private Task CreateToolsForOrganizationsAsync(string sessionId, IReadOnlyCollection organizationIds, params string[] scopes) { + return CreateToolsAsync(includeUserRole: false, sessionId, organizationIds, scopes); + } + + private Task CreateToolsAsync(bool includeUserRole, string sessionId, IReadOnlyCollection? organizationIds, params string[] scopes) + { + organizationIds ??= [TestConstants.OrganizationId]; + var user = new User { Id = TestConstants.UserId, FullName = "MCP Test User", EmailAddress = SampleDataService.TEST_USER_EMAIL }; - user.OrganizationIds.Add(TestConstants.OrganizationId); + + foreach (string organizationId in organizationIds) + user.OrganizationIds.Add(organizationId); var utcNow = TimeProvider.GetUtcNow().UtcDateTime; var token = new OAuthToken @@ -925,13 +1109,13 @@ private Task CreateToolsAsync(bool includeUserRole, param Resource = "http://localhost/mcp", AccessTokenHash = OAuthService.CreateTokenHash("mcp-tools-access-token"), Scopes = scopes.ToHashSet(StringComparer.Ordinal), - OrganizationIds = [TestConstants.OrganizationId], + OrganizationIds = organizationIds.ToHashSet(StringComparer.Ordinal), CreatedUtc = utcNow, UpdatedUtc = utcNow, CreatedBy = user.Id }; - var identity = user.ToIdentity(token); + var identity = user.ToIdentity(token, organizationIds); if (includeUserRole) identity.AddClaim(new Claim(ClaimTypes.Role, AuthorizationRoles.User)); @@ -941,9 +1125,19 @@ private Task CreateToolsAsync(bool includeUserRole, param }; context.Request.Scheme = "http"; context.Request.Host = new HostString("localhost"); + context.Request.Headers["MCP-Session-Id"] = sessionId; + + var accessor = new TestHttpContextAccessor { HttpContext = context }; + var contextService = new McpContextService( + accessor, + GetService(), + _organizationRepository, + _projectRepository, + GetService(), + TimeProvider); return Task.FromResult(new ExceptionlessMcpTools( - new HttpContextAccessor { HttpContext = context }, + accessor, _organizationRepository, _projectRepository, _stackRepository, @@ -951,12 +1145,18 @@ private Task CreateToolsAsync(bool includeUserRole, param _tokenRepository, GetService(), GetService(), + contextService, GetService(), GetService(), GetService>(), TimeProvider)); } + + private sealed class TestHttpContextAccessor : IHttpContextAccessor + { + public HttpContext? HttpContext { get; set; } + } private static IReadOnlyCollection Items(McpResponse> result) { Assert.NotNull(result.Data); From 398343d073dba385002477721e944b19b0a9981a Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 29 Jun 2026 22:13:09 -0500 Subject: [PATCH 2/3] Address MCP feedback --- .../Mcp/ExceptionlessMcpTools.cs | 92 +++++++++++++++---- .../Mcp/McpContextService.cs | 40 +++++--- src/Exceptionless.Web/Mcp/McpErrors.cs | 12 ++- src/Exceptionless.Web/Mcp/McpModels.cs | 3 +- .../Controllers/ExceptionlessMcpToolsTests.cs | 89 ++++++++++++++++++ 5 files changed, 204 insertions(+), 32 deletions(-) diff --git a/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs index 94719e4ff..d44d0824b 100644 --- a/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs +++ b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs @@ -38,11 +38,13 @@ public sealed class ExceptionlessMcpTools private const string LastDescription = "Optional relative time range such as 24h, 7d, or 30m. Do not combine with startUtc or endUtc."; private const string StartUtcDescription = "Optional inclusive UTC start time, for example 2026-06-25T00:00:00Z. Do not combine with last."; private const string EndUtcDescription = "Optional exclusive UTC end time, for example 2026-06-25T01:00:00Z. Do not combine with last."; - private const string EventGroupByDescription = "Optional dimension to group counts by. Supported values: version, type, source, status, tag, stack, user, level, error.type, error.code, os, os.version, browser."; + private const string EventGroupByDescription = "Optional dimension to group counts by. Supported values: version, type, source, status, tag, stack, user, level, error.type, error.code, os, os.version, browser. Multi-value fields such as tag, error.type, and error.code can place one event into multiple groups, so group totals may sum higher than the overall event total."; private const string SnoozeDurationDescription = "Optional relative snooze duration such as 2h, 3d, or 1w. Do not combine with snoozeUntilUtc."; private const string ProjectFilterDescription = "Optional Exceptionless filter expression applied to projects. Supported fields: id, name, organization_id, created_utc, updated_utc, last_event_date_utc."; - private const string StackFilterDescription = "Optional Exceptionless filter expression. Supported fields include: stack, project, project_id, organization, organization_id, type, status, title, description, tag, tags, references, fixed, hidden, regressed, error, first, first_occurrence, last, last_occurrence, occurrences, total_occurrences, data.*, idx.*."; - private const string EventFilterDescription = "Optional Exceptionless filter expression applied to events. Supported fields include: id, project, project_id, stack, stack_id, organization, organization_id, type, source, message, date, tag, tags, user, user.name, user.email, path, error, error.type, error.message, error.code, status, data.*, idx.*."; + private const string StackFilterDescription = "Optional Exceptionless filter expression. Supported fields include: stack, project, project_id, organization, organization_id, type, status, title, description, tag, tags, references, fixed, hidden, regressed, error, first, first_occurrence, last, last_occurrence, occurrences, total_occurrences, data.*, idx.*. data.* only works for fields mapped in the search index; use idx.* for custom indexed data."; + private const string EventFilterDescription = "Optional Exceptionless filter expression applied to events. Supported fields include: id, project, project_id, stack, stack_id, organization, organization_id, type, source, message, date, tag, tags, user, user.name, user.email, path, error, error.type, error.message, error.code, status, data.*, idx.*. data.* only works for fields mapped in the search index; use idx.* for custom indexed data."; + + private const string IndexedDataFilterNote = "data.* filters only work for fields mapped in the search index. Use idx.* for custom indexed data; arbitrary event detail data is returned by get_event but is not searchable unless it is indexed."; private readonly IHttpContextAccessor _httpContextAccessor; private readonly IOrganizationRepository _organizationRepository; @@ -660,7 +662,7 @@ public async Task> CountEventsAsync( var aggregationValidation = await _eventQueryValidator.ValidateAggregationsAsync(aggregations); if (!aggregationValidation.IsValid) - return McpResponse.Failed(McpErrors.InvalidGroupBy($"Invalid aggregation: {aggregationValidation.Message ?? "Unable to validate aggregation."}", groupBy, EventGroupByFields.Keys)); + return McpResponse.Failed(McpErrors.InvalidGroupBy($"Invalid aggregation: {aggregationValidation.Message ?? "Unable to validate aggregation."}", groupBy, EventGroupByAllowedFields)); var countQuery = ApplyEventTimeRange(new RepositoryQuery().AppFilter(systemFilter), timeRange); var result = await _eventRepository.CountAsync(_ => countQuery @@ -700,6 +702,8 @@ public async Task> CountEventsAsync( .ToArray() ?? []; } + string? warning = CombineWarnings(groupLimitWarning, GetGroupByOverlapWarning(resolvedGroupBy)); + return McpResponse.Success(new McpEventCountResult( result.Total, GetNumericAggregationValue(result.Aggregations.Sum("sum_count")?.Value, result.Total), @@ -711,7 +715,7 @@ public async Task> CountEventsAsync( EndUtc: timeRange.EndUtc, GroupBy: resolvedGroupBy?.Name, Groups: groups), - groupLimitWarning); + warning); } catch (Exception ex) when (IsLookupError(ex)) { @@ -872,9 +876,8 @@ public async Task> AddStackReferenceLinkAsync( if (!TryValidateId(stackId, "stackId", out var idError)) return McpResponse.Failed(idError); - string? referenceLink = NormalizeReferenceLink(url); - if (referenceLink is null) - return McpResponse.Failed(McpErrors.InvalidReferenceLink("url is required.", url)); + if (!TryNormalizeReferenceUrl(url, out string referenceLink, out var referenceError)) + return McpResponse.Failed(referenceError); var stack = await GetAccessibleStackForWriteAsync(stackId); bool changed = !stack.References.Contains(referenceLink); @@ -942,7 +945,7 @@ public async Task> RemoveStackReferenceLinkAsy } [McpServerTool(Name = "get_filter_fields", ReadOnly = true, UseStructuredContent = true)] - [Description("Lists supported Exceptionless MCP filter and sort fields for projects, stacks, and events. Dynamic data.* and idx.* filter fields are allowed for stacks and events.")] + [Description("Lists supported Exceptionless MCP filter and sort fields for projects, stacks, and events. Dynamic data.* and idx.* filter prefixes are allowed for stacks and events, but data.* only works for fields mapped in the search index; use idx.* for custom indexed data.")] public McpResponse GetFilterFields() { try @@ -1302,7 +1305,7 @@ private static bool TryResolveEventGroupBy(string? groupBy, out McpEventGroupBy? return true; } - error = McpErrors.InvalidGroupBy($"Unsupported groupBy field '{groupBy}'.", groupBy, EventGroupByFields.Keys); + error = McpErrors.InvalidGroupBy($"Unsupported groupBy field '{groupBy}'.", groupBy, EventGroupByAllowedFields); return false; } @@ -1408,6 +1411,29 @@ private static bool TryParseWritableStackStatus(string status, out StackStatus s return String.IsNullOrWhiteSpace(fixedInVersion) ? null : fixedInVersion.Trim(); } + private static bool TryNormalizeReferenceUrl(string? url, out string referenceUrl, out McpErrorInfo error) + { + referenceUrl = null!; + if (String.IsNullOrWhiteSpace(url)) + { + error = McpErrors.InvalidReferenceUrl("url is required.", url); + return false; + } + + referenceUrl = url.Trim(); + if (Uri.TryCreate(referenceUrl, UriKind.Absolute, out var uri) + && uri.IsWellFormedOriginalString() + && !String.IsNullOrEmpty(uri.Host) + && (String.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) || String.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))) + { + error = null!; + return true; + } + + error = McpErrors.InvalidReferenceUrl("url must be an absolute http or https URL.", url); + return false; + } + private static string? NormalizeReferenceLink(string? url) { return String.IsNullOrWhiteSpace(url) ? null : url.Trim(); @@ -1618,7 +1644,7 @@ private static List BuildReactNativeClientSetupSteps(string Code: String.Join('\n', new[] { "try {", - " throw new Error(\"Hello from Expo\");", + " throw new Error(\"Handled React Native exception\");", "} catch (error) {", " await Exceptionless.submitException(error);", "}" @@ -1644,10 +1670,12 @@ private static string[] ToTags(IEnumerable? tags) private static McpFilterFieldSet ToFilterFieldSet(IReadOnlySet filterFields, IReadOnlySet sortFields, params string[] dynamicFilterPrefixes) { + string? notes = dynamicFilterPrefixes.Contains("data.", StringComparer.OrdinalIgnoreCase) ? IndexedDataFilterNote : null; return new McpFilterFieldSet( filterFields.Order(StringComparer.OrdinalIgnoreCase).ToArray(), sortFields.Order(StringComparer.OrdinalIgnoreCase).ToArray(), - dynamicFilterPrefixes); + dynamicFilterPrefixes, + notes); } private McpResponse> ToListResponse( @@ -1686,6 +1714,19 @@ private static double GetNumericAggregationValue(object? value, double defaultVa return value is null ? defaultValue : Convert.ToDouble(value, CultureInfo.InvariantCulture); } + private static string? CombineWarnings(params string?[] warnings) + { + var values = warnings.Where(w => !String.IsNullOrWhiteSpace(w)).ToArray(); + return values.Length == 0 ? null : String.Join(" ", values); + } + + private static string? GetGroupByOverlapWarning(McpEventGroupBy? groupBy) + { + return groupBy?.CanOverlap == true + ? $"groupBy={groupBy.Name} is multi-value; one event can appear in multiple groups, so group event totals may sum higher than the overall event total." + : null; + } + private static readonly HashSet ClientSetupPlatforms = new(StringComparer.OrdinalIgnoreCase) { "expo", "react-native" }; private static readonly Regex FilterFieldRegex = new(@"(?:^|[\s(])(?@?[A-Za-z_][A-Za-z0-9_@.-]*):", RegexOptions.Compiled | RegexOptions.CultureInvariant); @@ -1693,20 +1734,37 @@ private static double GetNumericAggregationValue(object? value, double defaultVa private static readonly Regex RelativeTimeRegex = new(@"^(?\d+)(?[mhdw])$", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); private static readonly Regex IntervalRegex = new(@"^\d+[mhdwM]$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly string[] EventGroupByAllowedFields = + [ + "version", + "type", + "source", + "status", + "tag", + "stack", + "user", + "level", + "error.type", + "error.code", + "os", + "os.version", + "browser" + ]; + private static readonly IReadOnlyDictionary EventGroupByFields = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["version"] = new("version", EventIndex.Alias.Version), ["type"] = new("type", EventIndex.Alias.Type), ["source"] = new("source", EventIndex.Alias.Source), ["status"] = new("status", "status"), - ["tag"] = new("tag", "tags"), - ["tags"] = new("tag", "tags"), + ["tag"] = new("tag", "tags", CanOverlap: true), + ["tags"] = new("tag", "tags", CanOverlap: true), ["stack"] = new("stack", EventIndex.Alias.StackId), ["stack_id"] = new("stack", "stack_id"), ["user"] = new("user", EventIndex.Alias.User), ["level"] = new("level", EventIndex.Alias.Level), - ["error.type"] = new("error.type", EventIndex.Alias.ErrorType), - ["error.code"] = new("error.code", EventIndex.Alias.ErrorCode), + ["error.type"] = new("error.type", EventIndex.Alias.ErrorType, CanOverlap: true), + ["error.code"] = new("error.code", EventIndex.Alias.ErrorCode, CanOverlap: true), ["os"] = new("os", "os"), ["os.version"] = new("os.version", "os.version"), ["browser"] = new("browser", EventIndex.Alias.Browser) @@ -1838,5 +1896,5 @@ public static SearchValidationResult Failed(McpErrorInfo error) } } - private sealed record McpEventGroupBy(string Name, string AggregationField); + private sealed record McpEventGroupBy(string Name, string AggregationField, bool CanOverlap = false); } diff --git a/src/Exceptionless.Web/Mcp/McpContextService.cs b/src/Exceptionless.Web/Mcp/McpContextService.cs index 8f3f90550..6064dbcd6 100644 --- a/src/Exceptionless.Web/Mcp/McpContextService.cs +++ b/src/Exceptionless.Web/Mcp/McpContextService.cs @@ -165,10 +165,11 @@ public async Task SwitchProjectAsync(string projectId) [])); } - var project = await GetAccessibleProjectAsync(projectId); - if (project is null) - return McpContextResolution.Failed(McpErrors.NotAccessible($"Project {projectId} was not found or is not accessible.", "projectId", projectId)); + var projectAccess = await GetAccessibleProjectAsync(projectId); + if (projectAccess.Error is not null) + return McpContextResolution.Failed(projectAccess.Error); + var project = projectAccess.Project!; var accessibleOrganizations = await GetAccessibleOrganizationsAsync(); var activeOrganization = accessibleOrganizations.FirstOrDefault(o => String.Equals(o.Id, project.OrganizationId, StringComparison.Ordinal)); if (activeOrganization is null) @@ -235,12 +236,13 @@ public async Task ResolveProjectAsync(string? proje : McpProjectContextResolution.Failed(resolvedContext.Error!, resolvedContext.Context); } - var project = await GetAccessibleProjectAsync(projectId.Trim()); - if (project is null) + var projectAccess = await GetAccessibleProjectAsync(projectId.Trim()); + if (projectAccess.Error is not null) { - return McpProjectContextResolution.Failed(McpErrors.NotAccessible($"Project {projectId} was not found or is not accessible.", "projectId", projectId)); + return McpProjectContextResolution.Failed(projectAccess.Error); } + var project = projectAccess.Project!; var context = await GetContextAsync(requireProject: false); if (!context.Succeeded) return McpProjectContextResolution.Failed(context.Error!, context.Context); @@ -317,15 +319,16 @@ private async Task> GetAccessibleOrganizationsAsync( .ToArray(); } - private async Task GetAccessibleProjectAsync(string projectId) + private async Task GetAccessibleProjectAsync(string projectId) { var project = await projectRepository.GetByIdAsync(projectId, o => o.Cache()); if (project is null) - return null; + return McpProjectAccess.Failed(McpErrors.NotFound($"Project {projectId} was not found.", "projectId", projectId)); + + if (!Request.GetAssociatedOrganizationIds().Contains(project.OrganizationId)) + return McpProjectAccess.Failed(McpErrors.NotAccessible($"Project {projectId} is not accessible.", "projectId", projectId)); - return Request.GetAssociatedOrganizationIds().Contains(project.OrganizationId) - ? project - : null; + return McpProjectAccess.Success(project); } private async Task GetValidatedProjectAsync(string? projectId, string organizationId) @@ -443,6 +446,21 @@ public static McpContextResolution Failed(McpErrorInfo error, McpContextResult? } } +public sealed record McpProjectAccess(Project? Project, McpErrorInfo? Error) +{ + public bool Succeeded => Error is null; + + public static McpProjectAccess Success(Project project) + { + return new McpProjectAccess(project, null); + } + + public static McpProjectAccess Failed(McpErrorInfo error) + { + return new McpProjectAccess(null, error); + } +} + public sealed record McpProjectContextResolution(Project? Project, Organization? Organization, McpContextResult Context, McpErrorInfo? Error) { public bool Succeeded => Error is null; diff --git a/src/Exceptionless.Web/Mcp/McpErrors.cs b/src/Exceptionless.Web/Mcp/McpErrors.cs index 04c9e4f98..e78641a0c 100644 --- a/src/Exceptionless.Web/Mcp/McpErrors.cs +++ b/src/Exceptionless.Web/Mcp/McpErrors.cs @@ -17,7 +17,8 @@ public static class McpErrorCodes public const string InvalidId = "invalid_id"; public const string InvalidInterval = "invalid_interval"; public const string InvalidLimit = "invalid_limit"; - public const string InvalidReferenceLink = "invalid_reference_link"; + public const string InvalidReferenceUrl = "invalid_reference_url"; + public const string InvalidReferenceLink = InvalidReferenceUrl; public const string InvalidSnooze = "invalid_snooze"; public const string InvalidSort = "invalid_sort"; public const string InvalidStatus = "invalid_status"; @@ -132,15 +133,20 @@ public static McpErrorInfo InvalidLimit(string message, int value, int max) }); } - public static McpErrorInfo InvalidReferenceLink(string message, string? url) + public static McpErrorInfo InvalidReferenceUrl(string message, string? url) { - return new McpErrorInfo(McpErrorCodes.InvalidReferenceLink, message, new Dictionary + return new McpErrorInfo(McpErrorCodes.InvalidReferenceUrl, message, new Dictionary { ["field"] = "url", ["value"] = url }); } + public static McpErrorInfo InvalidReferenceLink(string message, string? url) + { + return InvalidReferenceUrl(message, url); + } + public static McpErrorInfo InvalidSnooze(string message, string? duration, string? snoozeUntilUtc) { return new McpErrorInfo(McpErrorCodes.InvalidSnooze, message, new Dictionary diff --git a/src/Exceptionless.Web/Mcp/McpModels.cs b/src/Exceptionless.Web/Mcp/McpModels.cs index c628869b2..20eeb5e45 100644 --- a/src/Exceptionless.Web/Mcp/McpModels.cs +++ b/src/Exceptionless.Web/Mcp/McpModels.cs @@ -54,7 +54,8 @@ public sealed record McpFilterFieldsResult( public sealed record McpFilterFieldSet( IReadOnlyCollection FilterFields, IReadOnlyCollection SortFields, - IReadOnlyCollection DynamicFilterPrefixes); + IReadOnlyCollection DynamicFilterPrefixes, + string? Notes = null); public sealed record McpEventCountResult( long Events, diff --git a/tests/Exceptionless.Tests/Controllers/ExceptionlessMcpToolsTests.cs b/tests/Exceptionless.Tests/Controllers/ExceptionlessMcpToolsTests.cs index ef9652d09..af5e11bae 100644 --- a/tests/Exceptionless.Tests/Controllers/ExceptionlessMcpToolsTests.cs +++ b/tests/Exceptionless.Tests/Controllers/ExceptionlessMcpToolsTests.cs @@ -203,6 +203,7 @@ public async Task GetClientSetupInstructionsAsync_Expo_ReturnsProjectSpecificIns Assert.Contains(setup.Steps, step => step.Command?.Contains("npx expo install", StringComparison.OrdinalIgnoreCase) == true); Assert.Contains(setup.Steps, step => step.Code?.Contains("@exceptionless/react-native/expo-plugin", StringComparison.OrdinalIgnoreCase) == true); Assert.Contains(setup.Steps, step => step.Code?.Contains(SampleDataService.TEST_API_KEY, StringComparison.OrdinalIgnoreCase) == true); + Assert.DoesNotContain(setup.Steps, step => step.Code?.Contains("Hello from Expo", StringComparison.OrdinalIgnoreCase) == true); Assert.Contains(setup.Notes, note => note.Contains("Expo", StringComparison.OrdinalIgnoreCase)); } @@ -475,6 +476,50 @@ public async Task CountEventsAsync_InvalidGroupBy_ReturnsError() Assert.Null(result.Data); } + [Fact] + public async Task CountEventsAsync_InvalidGroupBy_ReturnsCanonicalAllowedFields() + { + var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.EventsRead); + + var result = await tools.CountEventsAsync(TestConstants.ProjectId, groupBy: "client.version"); + + Assert.False(result.Ok); + Assert.Equal(McpErrorCodes.InvalidGroupBy, result.Error?.Code); + Assert.NotNull(result.Error?.Details); + var allowedFields = Assert.IsType(result.Error.Details["allowedFields"]); + Assert.Contains("error.type", allowedFields); + Assert.DoesNotContain("stack_id", allowedFields); + Assert.DoesNotContain("tags", allowedFields); + } + + [Fact] + public async Task CountEventsAsync_MultiValueGroupBy_ReturnsOverlapWarning() + { + const string referenceId = "mcp-count-events-error-type-warning"; + var (_, events) = await CreateDataAsync(d => d.Event().TestProject().Type(Event.KnownTypes.Error).ReferenceId(referenceId).Message("MCP count error type warning")); + var ev = events[0]; + ev.SetSimpleError(new SimpleError + { + Message = "Outer", + Type = "System.InvalidOperationException", + Inner = new SimpleError + { + Message = "Inner", + Type = "System.Exception" + } + }); + await _eventRepository.SaveAsync(ev, o => o.ImmediateConsistency()); + await RefreshDataAsync(); + var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.EventsRead); + + var result = await tools.CountEventsAsync(TestConstants.ProjectId, filter: $"reference:{referenceId}", groupBy: "error.type"); + + Assert.True(result.Ok); + Assert.Equal("error.type", Data(result).GroupBy); + Assert.Contains("multi-value", result.Warning, StringComparison.OrdinalIgnoreCase); + } + + [Fact] public async Task CountEventsAsync_GroupLimitAboveMaximum_IsCappedWithWarning() { @@ -528,6 +573,19 @@ public async Task GetProjectAsync_InvalidId_ReturnsInvalidId(string projectId) Assert.Equal(McpErrorCodes.InvalidId, result.Error?.Code); } + [Fact] + public async Task SearchEventsAsync_MissingProjectId_ReturnsNotFound() + { + var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.EventsRead); + + var result = await tools.SearchEventsAsync("000000000000000000000000"); + + Assert.False(result.Ok); + Assert.Equal(McpErrorCodes.NotFound, result.Error?.Code); + Assert.Null(result.Data); + } + + [Fact] public async Task SearchStacksAsync_InvalidProjectId_ReturnsInvalidId() { @@ -552,6 +610,19 @@ public async Task GetEventAsync_InvalidEventId_ReturnsInvalidId() Assert.Equal(McpErrorCodes.InvalidId, result.Error?.Code); } + [Fact] + public async Task GetEventAsync_MissingEventId_ReturnsNotFound() + { + var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.EventsRead); + + var result = await tools.GetEventAsync("000000000000000000000000"); + + Assert.False(result.Ok); + Assert.Equal(McpErrorCodes.NotFound, result.Error?.Code); + Assert.Null(result.Data); + } + + [Fact] public async Task GetEventAsync_DetailPayloadAboveMaximum_OmitsLargeDetails() { @@ -606,6 +677,7 @@ public async Task GetFilterFields_McpScope_ReturnsSupportedFields() Assert.Contains("status", item.Stacks.FilterFields); Assert.Contains("path", item.Events.FilterFields); Assert.Contains("data.", item.Stacks.DynamicFilterPrefixes); + Assert.Contains("mapped in the search index", item.Events.Notes, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -792,6 +864,23 @@ public async Task AddStackReferenceLinkAsync_EmptyUrl_ReturnsError() Assert.Null(result.Data); } + [Theory] + [InlineData("not-a-url")] + [InlineData("/relative")] + [InlineData("mailto:test@example.com")] + public async Task AddStackReferenceLinkAsync_InvalidUrl_ReturnsInvalidReferenceUrl(string url) + { + var (stacks, _) = await CreateDataAsync(d => d.Event().TestProject().Message("MCP write invalid reference")); + var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.StacksWrite); + + var result = await tools.AddStackReferenceLinkAsync(stacks[0].Id, url); + + Assert.False(result.Ok); + Assert.Equal(McpErrorCodes.InvalidReferenceUrl, result.Error?.Code); + Assert.Null(result.Data); + } + + [Fact] public async Task RemoveStackReferenceLinkAsync_StacksWriteScope_RemovesReference() { From 25b8f2863cf2f2bb445f24788e210d173a7b667f Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 29 Jun 2026 23:17:11 -0500 Subject: [PATCH 3/3] Hide internal indexed data fields from MCP --- .../Mcp/ExceptionlessMcpTools.cs | 15 +++++++------- .../Controllers/ExceptionlessMcpToolsTests.cs | 20 +++++++++++++++++-- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs index d44d0824b..66facf201 100644 --- a/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs +++ b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs @@ -41,10 +41,10 @@ public sealed class ExceptionlessMcpTools private const string EventGroupByDescription = "Optional dimension to group counts by. Supported values: version, type, source, status, tag, stack, user, level, error.type, error.code, os, os.version, browser. Multi-value fields such as tag, error.type, and error.code can place one event into multiple groups, so group totals may sum higher than the overall event total."; private const string SnoozeDurationDescription = "Optional relative snooze duration such as 2h, 3d, or 1w. Do not combine with snoozeUntilUtc."; private const string ProjectFilterDescription = "Optional Exceptionless filter expression applied to projects. Supported fields: id, name, organization_id, created_utc, updated_utc, last_event_date_utc."; - private const string StackFilterDescription = "Optional Exceptionless filter expression. Supported fields include: stack, project, project_id, organization, organization_id, type, status, title, description, tag, tags, references, fixed, hidden, regressed, error, first, first_occurrence, last, last_occurrence, occurrences, total_occurrences, data.*, idx.*. data.* only works for fields mapped in the search index; use idx.* for custom indexed data."; - private const string EventFilterDescription = "Optional Exceptionless filter expression applied to events. Supported fields include: id, project, project_id, stack, stack_id, organization, organization_id, type, source, message, date, tag, tags, user, user.name, user.email, path, error, error.type, error.message, error.code, status, data.*, idx.*. data.* only works for fields mapped in the search index; use idx.* for custom indexed data."; + private const string StackFilterDescription = "Optional Exceptionless filter expression. Supported fields include: stack, project, project_id, organization, organization_id, type, status, title, description, tag, tags, references, fixed, hidden, regressed, error, first, first_occurrence, last, last_occurrence, occurrences, total_occurrences."; + private const string EventFilterDescription = "Optional Exceptionless filter expression applied to events. Supported fields include: id, project, project_id, stack, stack_id, organization, organization_id, type, source, message, date, tag, tags, user, user.name, user.email, path, error, error.type, error.message, error.code, status, data.*. data.* works for custom data values that were indexed for search; arbitrary event detail data is returned by get_event but is not searchable unless indexed."; - private const string IndexedDataFilterNote = "data.* filters only work for fields mapped in the search index. Use idx.* for custom indexed data; arbitrary event detail data is returned by get_event but is not searchable unless it is indexed."; + private const string IndexedDataFilterNote = "data.* filters work for custom data values that were indexed for search. Arbitrary event detail data is returned by get_event but is not searchable unless indexed."; private readonly IHttpContextAccessor _httpContextAccessor; private readonly IOrganizationRepository _organizationRepository; @@ -945,7 +945,7 @@ public async Task> RemoveStackReferenceLinkAsy } [McpServerTool(Name = "get_filter_fields", ReadOnly = true, UseStructuredContent = true)] - [Description("Lists supported Exceptionless MCP filter and sort fields for projects, stacks, and events. Dynamic data.* and idx.* filter prefixes are allowed for stacks and events, but data.* only works for fields mapped in the search index; use idx.* for custom indexed data.")] + [Description("Lists supported Exceptionless MCP filter and sort fields for projects, stacks, and events. Dynamic data.* filter prefixes are allowed for indexed custom event data.")] public McpResponse GetFilterFields() { try @@ -953,8 +953,8 @@ public McpResponse GetFilterFields() EnsureScope(AuthorizationRoles.McpRead); return McpResponse.Success(new McpFilterFieldsResult( ToFilterFieldSet(ProjectFilterFields, ProjectSortFields), - ToFilterFieldSet(StackFilterFields, StackSortFields, "data.", "idx."), - ToFilterFieldSet(EventFilterFields, EventSortFields, "data.", "idx."))); + ToFilterFieldSet(StackFilterFields, StackSortFields), + ToFilterFieldSet(EventFilterFields, EventSortFields, "data."))); } catch (Exception ex) when (IsLookupError(ex)) { @@ -1471,7 +1471,7 @@ private static bool TryValidateDetailSize(int maxDetailSize, out McpErrorInfo er foreach (Match match in FilterFieldRegex.Matches(filter)) { string field = match.Groups["field"].Value; - if (field.StartsWith("data.", StringComparison.OrdinalIgnoreCase) || field.StartsWith("idx.", StringComparison.OrdinalIgnoreCase)) + if (field.StartsWith("data.", StringComparison.OrdinalIgnoreCase)) continue; if (!allowedFilterFields.Contains(field)) @@ -1851,7 +1851,6 @@ private static double GetNumericAggregationValue(object? value, double defaultVa EventIndex.Alias.Tags, "tags", EventIndex.Alias.Geo, - EventIndex.Alias.IDX, EventIndex.Alias.Version, EventIndex.Alias.Level, EventIndex.Alias.SubmissionMethod, diff --git a/tests/Exceptionless.Tests/Controllers/ExceptionlessMcpToolsTests.cs b/tests/Exceptionless.Tests/Controllers/ExceptionlessMcpToolsTests.cs index af5e11bae..319500f3f 100644 --- a/tests/Exceptionless.Tests/Controllers/ExceptionlessMcpToolsTests.cs +++ b/tests/Exceptionless.Tests/Controllers/ExceptionlessMcpToolsTests.cs @@ -339,6 +339,19 @@ public async Task SearchStacksAsync_UnknownFilterField_ReturnsError() Assert.Null(result.Data); } + [Fact] + public async Task SearchEventsAsync_InternalIndexedDataFilter_ReturnsUnknownFilterField() + { + var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.EventsRead); + + var result = await tools.SearchEventsAsync(TestConstants.ProjectId, filter: "idx.customer.email-s:user42@example.com"); + + Assert.False(result.Ok); + Assert.Equal(McpErrorCodes.UnknownFilterField, result.Error?.Code); + Assert.Equal("Unknown filter field 'idx.customer.email-s'.", result.Error?.Message); + Assert.Null(result.Data); + } + [Fact] public async Task SearchStacksAsync_MalformedFilter_ReturnsSpecificError() { @@ -676,8 +689,11 @@ public async Task GetFilterFields_McpScope_ReturnsSupportedFields() Assert.Contains("name", item.Projects.FilterFields); Assert.Contains("status", item.Stacks.FilterFields); Assert.Contains("path", item.Events.FilterFields); - Assert.Contains("data.", item.Stacks.DynamicFilterPrefixes); - Assert.Contains("mapped in the search index", item.Events.Notes, StringComparison.OrdinalIgnoreCase); + Assert.Empty(item.Stacks.DynamicFilterPrefixes); + Assert.Contains("data.", item.Events.DynamicFilterPrefixes); + Assert.DoesNotContain("idx.", item.Events.DynamicFilterPrefixes); + Assert.DoesNotContain("idx", item.Events.FilterFields); + Assert.Contains("indexed for search", item.Events.Notes, StringComparison.OrdinalIgnoreCase); } [Fact]