diff --git a/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs index d78593093..66facf201 100644 --- a/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs +++ b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs @@ -38,15 +38,18 @@ 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."; + 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 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; private readonly IProjectRepository _projectRepository; + private readonly McpContextService _mcpContextService; private readonly IStackRepository _stackRepository; private readonly IEventRepository _eventRepository; private readonly ITokenRepository _tokenRepository; @@ -66,6 +69,7 @@ public ExceptionlessMcpTools( ITokenRepository tokenRepository, StackQueryValidator stackQueryValidator, PersistentEventQueryValidator eventQueryValidator, + McpContextService mcpContextService, SemanticVersionParser semanticVersionParser, ITextSerializer serializer, ILogger logger, @@ -83,8 +87,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 +229,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 +260,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 +354,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 +365,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 +387,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 +400,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 +419,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 +511,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 +533,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 +546,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 +565,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 +597,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 +612,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 +632,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,13 +651,18 @@ 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); 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 @@ -557,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), @@ -568,11 +715,11 @@ public async Task> CountEventsAsync( EndUtc: timeRange.EndUtc, GroupBy: resolvedGroupBy?.Name, Groups: groups), - groupLimitWarning); + warning); } 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)) { @@ -729,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); @@ -799,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.* filter prefixes are allowed for indexed custom event data.")] public McpResponse GetFilterFields() { try @@ -807,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)) { @@ -874,6 +1020,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 +1037,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; } @@ -1151,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; } @@ -1257,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(); @@ -1294,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)) @@ -1306,7 +1483,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 +1498,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), @@ -1466,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);", "}" @@ -1492,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( @@ -1534,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); @@ -1541,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) @@ -1641,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, @@ -1686,5 +1895,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 new file mode 100644 index 000000000..6064dbcd6 --- /dev/null +++ b/src/Exceptionless.Web/Mcp/McpContextService.cs @@ -0,0 +1,477 @@ +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 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) + 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 projectAccess = await GetAccessibleProjectAsync(projectId.Trim()); + if (projectAccess.Error is not null) + { + 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); + + 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 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 McpProjectAccess.Success(project); + } + + 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 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; + + 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..e78641a0c 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"; @@ -15,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"; @@ -29,6 +32,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 @@ -105,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 @@ -202,3 +235,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..20eeb5e45 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; @@ -35,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/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..319500f3f 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() { @@ -73,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)); } @@ -122,6 +253,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 +283,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); @@ -204,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() { @@ -341,6 +489,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() { @@ -383,7 +575,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); @@ -395,6 +586,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() { @@ -419,6 +623,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() { @@ -435,6 +652,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); @@ -470,7 +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.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] @@ -479,6 +702,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 +771,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 +812,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 +833,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 +855,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); @@ -647,6 +880,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() { @@ -654,6 +904,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 +926,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 +957,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 +1115,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 +1167,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 +1214,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 +1230,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 +1250,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);