diff --git a/src/Exceptionless.Web/Exceptionless.Web.csproj b/src/Exceptionless.Web/Exceptionless.Web.csproj index c3f9bd4ba5..9ff362a4c5 100644 --- a/src/Exceptionless.Web/Exceptionless.Web.csproj +++ b/src/Exceptionless.Web/Exceptionless.Web.csproj @@ -21,7 +21,7 @@ - + diff --git a/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs index 66facf201e..c9df660288 100644 --- a/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs +++ b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs @@ -90,25 +90,6 @@ public ExceptionlessMcpTools( _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() @@ -125,74 +106,26 @@ public async Task>> ListOrganizat } } - [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.")] + [McpServerTool(Name = "resolve_project", ReadOnly = true, UseStructuredContent = true)] + [Description("Resolves a project by id or exact name. All inputs may be omitted when only one project is accessible. Pass the returned projectId to subsequent project-scoped tools when more than one project is accessible.")] + public async Task> ResolveProjectAsync( + [Description("Optional Exceptionless project id to resolve.")] string? projectId = null, - [Description("Optional exact project name to make active within the active organization.")] + [Description("Optional exact project name to resolve within the specified organization.")] string? projectName = null, - [Description("Optional organization id to use when resolving a project name.")] + [Description("Optional organization id to use when resolving a project name. May be omitted when only one organization is accessible.")] string? organizationId = null) { try { EnsureScope(AuthorizationRoles.McpRead); - if (!String.IsNullOrWhiteSpace(projectId) && !TryValidateId(projectId, "projectId", out var projectIdError)) + if (projectId is not null && !TryValidateId(projectId, "projectId", out var projectIdError)) return McpResponse.Failed(projectIdError); - if (!String.IsNullOrWhiteSpace(organizationId) && !TryValidateId(organizationId, "organizationId", out var organizationIdError)) + if (organizationId is not null && !TryValidateId(organizationId, "organizationId", out var organizationIdError)) return McpResponse.Failed(organizationIdError); - var context = await _mcpContextService.ResolveProjectContextAsync(projectId, projectName, organizationId); + var context = await _mcpContextService.ResolveProjectByIdOrNameAsync(projectId, projectName, organizationId); if (!context.Succeeded) return McpResponse.Failed(context.Error!); @@ -200,12 +133,15 @@ public async Task> ResolveProjectContextAsync( } catch (Exception ex) when (IsLookupError(ex)) { - return McpResponse.Failed(ToLookupError("Project", projectId ?? projectName ?? "current session", ex)); + return McpResponse.Failed(ToLookupError("Project", projectId ?? projectName ?? organizationId ?? "current authorization", 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.")] + [Description("Lists projects the authenticated Exceptionless user can access. Omit organizationId when only one organization is accessible. 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( + [Description("Optional Exceptionless organization id. May be omitted when only one organization is accessible.")] + string? organizationId = null, [Description(ProjectFilterDescription)] string? filter = null, [Description("Optional sort expression. Defaults to project name.")] @@ -220,6 +156,9 @@ public async Task>> ListProjectsAsync( try { EnsureScope(AuthorizationRoles.ProjectsRead); + if (organizationId is not null && !TryValidateId(organizationId, "organizationId", out var idError)) + return McpResponse>.Failed(idError); + var validation = ValidateProjectSearch(filter, sort, limit); if (validation.Error is not null) return McpResponse>.Failed(validation.Error); @@ -229,11 +168,11 @@ public async Task>> ListProjectsAsync( int resolvedLimit = validation.Limit; - var context = await _mcpContextService.GetContextAsync(requireProject: false); + var context = await _mcpContextService.GetContextAsync(organizationId: organizationId, requireProject: false); if (!context.Succeeded) return McpResponse>.Failed(context.Error!); - var organization = context.ActiveOrganization ?? throw new UnauthorizedAccessException("No active organization is available."); + var organization = context.ActiveOrganization ?? throw new UnauthorizedAccessException("The requested organization is not available."); var systemFilter = new AppFilter(organization); var results = await _projectRepository.GetByFilterAsync(systemFilter, filter, sort, o => o @@ -258,15 +197,15 @@ public async Task>> ListProjectsAsync( } [McpServerTool(Name = "get_project", ReadOnly = true, UseStructuredContent = true)] - [Description("Gets summary details for a specific Exceptionless project.")] + [Description("Gets summary details for an Exceptionless project. Omit projectId when only one project is accessible.")] public async Task> GetProjectAsync( - [Description("Optional Exceptionless project id. Defaults to the active MCP project context.")] + [Description("Optional Exceptionless project id. May be omitted when only one project is accessible.")] string? projectId = null) { try { EnsureScope(AuthorizationRoles.ProjectsRead); - if (!String.IsNullOrWhiteSpace(projectId) && !TryValidateId(projectId, "projectId", out var idError)) + if (projectId is not null && !TryValidateId(projectId, "projectId", out var idError)) return McpResponse.Failed(idError); var projectContext = await _mcpContextService.ResolveProjectAsync(projectId); @@ -277,14 +216,14 @@ public async Task> GetProjectAsync( } catch (Exception ex) when (IsLookupError(ex)) { - return McpResponse.Failed(ToLookupError("Project", projectId ?? "active project", ex)); + return McpResponse.Failed(ToLookupError("Project", projectId ?? "current authorization", 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("Optional Exceptionless project id to configure. Defaults to the active MCP project context.")] + [Description("Optional Exceptionless project id to configure. May be omitted when only one project is accessible.")] string? projectId = null, [Description("Client platform to configure. Supported values: expo, react-native. Use expo for Expo apps.")] string platform = "expo") @@ -292,7 +231,7 @@ public async Task> GetClientSetupI try { EnsureScope(AuthorizationRoles.ProjectsRead); - if (!String.IsNullOrWhiteSpace(projectId) && !TryValidateId(projectId, "projectId", out var idError)) + if (projectId is not null && !TryValidateId(projectId, "projectId", out var idError)) return McpResponse.Failed(idError); string normalizedPlatform = platform.Trim().ToLowerInvariant(); @@ -315,7 +254,7 @@ public async Task> GetClientSetupI }; if (token is null) - notes.Add("No active project API key was found. Create or enable a project API key in Exceptionless, then replace YOUR_API_KEY."); + notes.Add("No enabled project API key was found. Create or enable a project API key in Exceptionless, then replace YOUR_API_KEY."); string packageName; string documentationUrl; @@ -354,7 +293,7 @@ public async Task> GetClientSetupI } catch (Exception ex) when (IsLookupError(ex)) { - return McpResponse.Failed(ToLookupError("Project", projectId ?? "active project", ex)); + return McpResponse.Failed(ToLookupError("Project", projectId ?? "current authorization", ex)); } catch (Exception ex) when (IsExpectedToolError(ex)) { @@ -365,7 +304,7 @@ 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("Optional Exceptionless project id to search within. Defaults to the active MCP project context.")] + [Description("Optional Exceptionless project id to search within. May be omitted when only one project is accessible.")] string? projectId = null, [Description(StackFilterDescription)] string? filter = null, @@ -387,7 +326,7 @@ public async Task>> SearchStacksAsync( try { EnsureScope(AuthorizationRoles.StacksRead); - if (!String.IsNullOrWhiteSpace(projectId) && !TryValidateId(projectId, "projectId", out var idError)) + if (projectId is not null && !TryValidateId(projectId, "projectId", out var idError)) return McpResponse>.Failed(idError); var validation = await ValidateSearchAsync(filter, sort, limit, StackFilterFields, StackSortFields, _stackQueryValidator); @@ -419,7 +358,7 @@ public async Task>> SearchStacksAsync( } catch (Exception ex) when (IsLookupError(ex)) { - return McpResponse>.Failed(ToLookupError("Project", projectId ?? "active project", ex)); + return McpResponse>.Failed(ToLookupError("Project", projectId ?? "current authorization", ex)); } catch (Exception ex) when (IsExpectedToolError(ex)) { @@ -428,10 +367,12 @@ public async Task>> SearchStacksAsync( } [McpServerTool(Name = "get_stack", ReadOnly = true, UseStructuredContent = true)] - [Description("Gets summary details for a specific Exceptionless stack.")] + [Description("Gets summary details for a specific Exceptionless stack. Omit projectId when only one project is accessible.")] public async Task> GetStackAsync( [Description("The Exceptionless stack id.")] - string stackId) + string stackId, + [Description("Optional Exceptionless project id that owns the stack. May be omitted when only one project is accessible.")] + string? projectId = null) { try { @@ -439,7 +380,10 @@ public async Task> GetStackAsync( if (!TryValidateId(stackId, "stackId", out var idError)) return McpResponse.Failed(idError); - var stack = await GetAccessibleStackAsync(stackId); + if (projectId is not null && !TryValidateId(projectId, "projectId", out var projectIdError)) + return McpResponse.Failed(projectIdError); + + var stack = await GetAccessibleStackAsync(stackId, projectId); return McpResponse.Success(ToStackResult(stack)); } catch (Exception ex) when (IsLookupError(ex)) @@ -453,6 +397,8 @@ public async Task> GetStackAsync( public async Task>> GetStackEventsAsync( [Description("The Exceptionless stack id.")] string stackId, + [Description("Optional Exceptionless project id that owns the stack. May be omitted when only one project is accessible.")] + string? projectId = null, [Description(EventFilterDescription)] string? filter = null, [Description("Optional sort expression. Defaults to -date.")] @@ -476,6 +422,9 @@ public async Task>> GetStackEventsAsync( if (!TryValidateId(stackId, "stackId", out var idError)) return McpResponse>.Failed(idError); + if (projectId is not null && !TryValidateId(projectId, "projectId", out var projectIdError)) + return McpResponse>.Failed(projectIdError); + var validation = await ValidateSearchAsync(filter, sort, limit, EventFilterFields, EventSortFields, _eventQueryValidator); if (validation.Error is not null) return McpResponse>.Failed(validation.Error); @@ -486,7 +435,7 @@ public async Task>> GetStackEventsAsync( if (!TryResolveTimeRange(last, startUtc, endUtc, out var timeRange, out var timeError)) return McpResponse>.Failed(timeError); - var (stack, organization) = await GetStackAndOrganizationAsync(stackId); + var (stack, organization) = await GetStackAndOrganizationAsync(stackId, projectId); var systemFilter = new AppFilter(stack, organization); var results = await _eventRepository.FindAsync( @@ -511,7 +460,7 @@ 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("Optional Exceptionless project id to search within. Defaults to the active MCP project context.")] + [Description("Optional Exceptionless project id to search within. May be omitted when only one project is accessible.")] string? projectId = null, [Description(EventFilterDescription)] string? filter = null, @@ -533,7 +482,7 @@ public async Task>> SearchEventsAsync( try { EnsureScope(AuthorizationRoles.EventsRead); - if (!String.IsNullOrWhiteSpace(projectId) && !TryValidateId(projectId, "projectId", out var idError)) + if (projectId is not null && !TryValidateId(projectId, "projectId", out var idError)) return McpResponse>.Failed(idError); var validation = await ValidateSearchAsync(filter, sort, limit, EventFilterFields, EventSortFields, _eventQueryValidator); @@ -565,7 +514,7 @@ public async Task>> SearchEventsAsync( } catch (Exception ex) when (IsLookupError(ex)) { - return McpResponse>.Failed(ToLookupError("Project", projectId ?? "active project", ex)); + return McpResponse>.Failed(ToLookupError("Project", projectId ?? "current authorization", ex)); } catch (Exception ex) when (IsExpectedToolError(ex)) { @@ -578,6 +527,8 @@ public async Task>> SearchEventsAsync( public async Task> GetEventAsync( [Description("The Exceptionless event id.")] string eventId, + [Description("Optional Exceptionless project id that owns the event. May be omitted when only one project is accessible.")] + string? projectId = null, [Description("Whether to include error, request, environment, and extended data. Defaults to true.")] bool includeDetails = true, [Description("Maximum serialized detail payload size in bytes when includeDetails is true. Defaults to 16384, minimum 1024, maximum 65536. Large detail sections are omitted with truncation metadata.")] @@ -589,6 +540,9 @@ public async Task> GetEventAsync( if (!TryValidateId(eventId, "eventId", out var idError)) return McpResponse.Failed(idError); + if (projectId is not null && !TryValidateId(projectId, "projectId", out var projectIdError)) + return McpResponse.Failed(projectIdError); + if (includeDetails && !TryValidateDetailSize(maxDetailSize, out var detailSizeError)) return McpResponse.Failed(detailSizeError); @@ -597,7 +551,7 @@ 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); + var contextError = await _mcpContextService.ValidateProjectScopeAsync(ev.OrganizationId, ev.ProjectId, projectId); if (contextError is not null) return McpResponse.Failed(contextError); @@ -612,7 +566,7 @@ 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("Optional Exceptionless project id to count within. Defaults to the active MCP project context.")] + [Description("Optional Exceptionless project id to count within. May be omitted when only one project is accessible.")] string? projectId = null, [Description(EventFilterDescription)] string? filter = null, @@ -632,7 +586,7 @@ public async Task> CountEventsAsync( try { EnsureScope(AuthorizationRoles.EventsRead); - if (!String.IsNullOrWhiteSpace(projectId) && !TryValidateId(projectId, "projectId", out var idError)) + if (projectId is not null && !TryValidateId(projectId, "projectId", out var idError)) return McpResponse.Failed(idError); var validation = await ValidateSearchAsync(filter, sort: null, DefaultLimit, EventFilterFields, EventSortFields, _eventQueryValidator); @@ -719,7 +673,7 @@ public async Task> CountEventsAsync( } catch (Exception ex) when (IsLookupError(ex)) { - return McpResponse.Failed(ToLookupError("Project", projectId ?? "active project", ex)); + return McpResponse.Failed(ToLookupError("Project", projectId ?? "current authorization", ex)); } catch (Exception ex) when (IsExpectedToolError(ex)) { @@ -734,6 +688,8 @@ public async Task> UpdateStackStatusAsync( string stackId, [Description("Target status: open, fixed, ignored, or discarded. Regressed and snoozed cannot be set directly.")] string status, + [Description("Optional Exceptionless project id that owns the stack. May be omitted when only one project is accessible.")] + string? projectId = null, [Description("Optional semantic version for fixed status, such as 1.0.2. Only allowed when status is fixed.")] string? fixedInVersion = null) { @@ -743,13 +699,16 @@ public async Task> UpdateStackStatusAsync( if (!TryValidateId(stackId, "stackId", out var idError)) return McpResponse.Failed(idError); + if (projectId is not null && !TryValidateId(projectId, "projectId", out var projectIdError)) + return McpResponse.Failed(projectIdError); + if (!TryParseWritableStackStatus(status, out var stackStatus, out var statusError)) return McpResponse.Failed(statusError); if (!String.IsNullOrWhiteSpace(fixedInVersion) && stackStatus != StackStatus.Fixed) return McpResponse.Failed(McpErrors.InvalidVersion("fixedInVersion can only be used when status is fixed.", fixedInVersion)); - var stack = await GetAccessibleStackForWriteAsync(stackId); + var stack = await GetAccessibleStackForWriteAsync(stackId, projectId); bool changed = stack.Status != stackStatus || (stackStatus == StackStatus.Fixed && !String.Equals(stack.FixedInVersion, NormalizeFixedVersion(fixedInVersion), StringComparison.Ordinal)); if (stackStatus == StackStatus.Fixed) @@ -789,6 +748,8 @@ public async Task> UpdateStackStatusAsync( public async Task> SnoozeStackAsync( [Description("The Exceptionless stack id.")] string stackId, + [Description("Optional Exceptionless project id that owns the stack. May be omitted when only one project is accessible.")] + string? projectId = null, [Description(SnoozeDurationDescription)] string? duration = null, [Description("Optional UTC time to snooze until, for example 2026-06-26T12:00:00Z. Do not combine with duration.")] @@ -800,10 +761,13 @@ public async Task> SnoozeStackAsync( if (!TryValidateId(stackId, "stackId", out var idError)) return McpResponse.Failed(idError); + if (projectId is not null && !TryValidateId(projectId, "projectId", out var projectIdError)) + return McpResponse.Failed(projectIdError); + if (!TryResolveSnoozeUntil(duration, snoozeUntilUtc, out var untilUtc, out var snoozeError)) return McpResponse.Failed(snoozeError); - var stack = await GetAccessibleStackForWriteAsync(stackId); + var stack = await GetAccessibleStackForWriteAsync(stackId, projectId); bool changed = stack.Status != StackStatus.Snoozed || stack.SnoozeUntilUtc != untilUtc; stack.Status = StackStatus.Snoozed; stack.SnoozeUntilUtc = untilUtc; @@ -832,7 +796,9 @@ public async Task> SetStackCriticalAsync( [Description("The Exceptionless stack id.")] string stackId, [Description("True marks future events for this stack as critical; false clears that behavior.")] - bool critical) + bool critical, + [Description("Optional Exceptionless project id that owns the stack. May be omitted when only one project is accessible.")] + string? projectId = null) { try { @@ -840,7 +806,10 @@ public async Task> SetStackCriticalAsync( if (!TryValidateId(stackId, "stackId", out var idError)) return McpResponse.Failed(idError); - var stack = await GetAccessibleStackForWriteAsync(stackId); + if (projectId is not null && !TryValidateId(projectId, "projectId", out var projectIdError)) + return McpResponse.Failed(projectIdError); + + var stack = await GetAccessibleStackForWriteAsync(stackId, projectId); bool changed = stack.OccurrencesAreCritical != critical; stack.OccurrencesAreCritical = critical; @@ -868,7 +837,9 @@ public async Task> AddStackReferenceLinkAsync( [Description("The Exceptionless stack id.")] string stackId, [Description("The reference link to add to the stack, such as an issue, pull request, deployment, or incident URL.")] - string url) + string url, + [Description("Optional Exceptionless project id that owns the stack. May be omitted when only one project is accessible.")] + string? projectId = null) { try { @@ -876,10 +847,13 @@ public async Task> AddStackReferenceLinkAsync( if (!TryValidateId(stackId, "stackId", out var idError)) return McpResponse.Failed(idError); + if (projectId is not null && !TryValidateId(projectId, "projectId", out var projectIdError)) + return McpResponse.Failed(projectIdError); + if (!TryNormalizeReferenceUrl(url, out string referenceLink, out var referenceError)) return McpResponse.Failed(referenceError); - var stack = await GetAccessibleStackForWriteAsync(stackId); + var stack = await GetAccessibleStackForWriteAsync(stackId, projectId); bool changed = !stack.References.Contains(referenceLink); if (changed) { @@ -910,7 +884,9 @@ public async Task> RemoveStackReferenceLinkAsy [Description("The Exceptionless stack id.")] string stackId, [Description("The reference link to remove from the stack.")] - string url) + string url, + [Description("Optional Exceptionless project id that owns the stack. May be omitted when only one project is accessible.")] + string? projectId = null) { try { @@ -918,11 +894,14 @@ public async Task> RemoveStackReferenceLinkAsy if (!TryValidateId(stackId, "stackId", out var idError)) return McpResponse.Failed(idError); + if (projectId is not null && !TryValidateId(projectId, "projectId", out var projectIdError)) + return McpResponse.Failed(projectIdError); + string? referenceLink = NormalizeReferenceLink(url); if (referenceLink is null) return McpResponse.Failed(McpErrors.InvalidReferenceLink("url is required.", url)); - var stack = await GetAccessibleStackForWriteAsync(stackId); + var stack = await GetAccessibleStackForWriteAsync(stackId, projectId); bool changed = stack.References.Remove(referenceLink); if (changed) await _stackRepository.SaveAsync(stack, o => o.ImmediateConsistency()); @@ -1010,7 +989,7 @@ private async Task GetAccessibleProjectAsync(string projectId) return (project, organization); } - private async Task GetAccessibleStackAsync(string stackId) + private async Task GetAccessibleStackAsync(string stackId, string? projectId) { if (String.IsNullOrWhiteSpace(stackId)) throw new ArgumentException("Stack id is required.", nameof(stackId)); @@ -1020,14 +999,14 @@ 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); + var contextError = await _mcpContextService.ValidateProjectScopeAsync(stack.OrganizationId, stack.ProjectId, projectId); if (contextError is not null) throw new McpContextException(contextError); return stack; } - private async Task GetAccessibleStackForWriteAsync(string stackId) + private async Task GetAccessibleStackForWriteAsync(string stackId, string? projectId) { if (String.IsNullOrWhiteSpace(stackId)) throw new ArgumentException("Stack id is required.", nameof(stackId)); @@ -1037,16 +1016,16 @@ 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); + var contextError = await _mcpContextService.ValidateProjectScopeAsync(stack.OrganizationId, stack.ProjectId, projectId); if (contextError is not null) throw new McpContextException(contextError); return stack; } - private async Task<(Stack Stack, Organization Organization)> GetStackAndOrganizationAsync(string stackId) + private async Task<(Stack Stack, Organization Organization)> GetStackAndOrganizationAsync(string stackId, string? projectId) { - var stack = await GetAccessibleStackAsync(stackId); + var stack = await GetAccessibleStackAsync(stackId, projectId); var organization = await _organizationRepository.GetByIdAsync(stack.OrganizationId, o => o.Cache()); if (organization is null) throw new KeyNotFoundException($"Organization {stack.OrganizationId} was not found."); diff --git a/src/Exceptionless.Web/Mcp/McpContextService.cs b/src/Exceptionless.Web/Mcp/McpContextService.cs index 6064dbcd67..9ecf80a1a3 100644 --- a/src/Exceptionless.Web/Mcp/McpContextService.cs +++ b/src/Exceptionless.Web/Mcp/McpContextService.cs @@ -1,208 +1,139 @@ -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) + IProjectRepository projectRepository) { 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) + public async Task GetContextAsync( + string? organizationId = null, + string? projectId = null, + 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; + Organization? activeOrganization = null; + Project? activeProject = null; - var accessibleOrganization = accessibleOrganizations.FirstOrDefault(o => String.Equals(o.Id, activeOrganizationId, StringComparison.Ordinal)); - if (!String.IsNullOrEmpty(activeOrganizationId) && accessibleOrganization is null) + if (projectId is not null) { - activeOrganizationId = null; - activeProjectId = null; - changed = true; - } + var projectAccess = await GetAccessibleProjectAsync(projectId.Trim()); + if (projectAccess.Error is not null) + return McpContextResolution.Failed(projectAccess.Error); - if (String.IsNullOrEmpty(activeOrganizationId)) - { - if (accessibleOrganizations.Count != 1) + activeProject = projectAccess.Project!; + activeOrganization = accessibleOrganizations.FirstOrDefault(o => String.Equals(o.Id, activeProject.OrganizationId, StringComparison.Ordinal)); + if (activeOrganization is null) { - 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); + return McpContextResolution.Failed(McpErrors.NotAccessible( + $"Organization {activeProject.OrganizationId} was not found or is not accessible.", + "organizationId", + activeProject.OrganizationId)); } - accessibleOrganization = accessibleOrganizations[0]; - activeOrganizationId = accessibleOrganization.Id; - changed = true; + if (organizationId is not null && !String.Equals(organizationId.Trim(), activeOrganization.Id, StringComparison.Ordinal)) + { + return McpContextResolution.Failed(McpErrors.ContextMismatch( + "The requested project is not in the requested organization.", + organizationId.Trim(), + activeProject.OrganizationId, + activeProject.Id, + activeProject.Id)); + } + } + else if (organizationId is not null) + { + activeOrganization = accessibleOrganizations.FirstOrDefault(o => String.Equals(o.Id, organizationId.Trim(), StringComparison.Ordinal)); + if (activeOrganization is null) + { + return McpContextResolution.Failed(McpErrors.NotAccessible( + $"Organization {organizationId.Trim()} was not found or is not accessible.", + "organizationId", + organizationId.Trim())); + } + } + else if (accessibleOrganizations.Count == 1) + { + activeOrganization = accessibleOrganizations[0]; } - 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) + if (activeOrganization is null) { - activeProjectId = null; - changed = true; + var context = ToContextResult(null, null, accessibleOrganizations, []); + return McpContextResolution.Failed(McpErrors.ContextRequired( + "Specify an organization id before using organization-scoped MCP tools.", + "organization", + context.Organizations, + context.Projects), context); } - if (String.IsNullOrEmpty(activeProjectId) && requireProject) + var accessibleProjects = await GetOrganizationProjectsAsync(activeOrganization.Id); + if (activeProject is null && 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); + var context = ToContextResult(activeOrganization, null, accessibleOrganizations, accessibleProjects.Documents); return McpContextResolution.Failed(McpErrors.ContextRequired( - "Select an active project before using this MCP tool.", + "Specify a project id before using this MCP tool.", "project", context.Organizations, - context.Projects), context, accessibleOrganization); + context.Projects), context, activeOrganization); } } - if (changed) - await SaveContextAsync(cacheKey, activeOrganizationId, activeProjectId); - - var result = ToContextResult(accessibleOrganization, activeProject, accessibleOrganizations, accessibleProjects.Documents, storedContext?.UpdatedUtc); - return McpContextResolution.Success(result, accessibleOrganization, activeProject); + var result = ToContextResult(activeOrganization, activeProject, accessibleOrganizations, accessibleProjects.Documents); + return McpContextResolution.Success(result, activeOrganization, 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); + return McpContextResolution.Success( + ToContextResult(null, null, accessibleOrganizations, []), + null, + null); } - public async Task ResolveProjectContextAsync(string? projectId = null, string? projectName = null, string? organizationId = null) + public async Task ResolveProjectByIdOrNameAsync( + string? projectId = null, + string? projectName = null, + string? organizationId = null) { - if (!String.IsNullOrWhiteSpace(projectId)) - return await SwitchProjectAsync(projectId.Trim()); + if (projectId is not null) + return await GetContextAsync(organizationId, projectId.Trim(), requireProject: true); 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 (organizationId is not null) + return await GetContextAsync(organizationId: organizationId, requireProject: true); + + var projectContext = await ResolveProjectAsync(); + return projectContext.Succeeded + ? McpContextResolution.Success(projectContext.Context, projectContext.Organization, projectContext.Project) + : McpContextResolution.Failed(projectContext.Error!, projectContext.Context); } - if (context.ActiveOrganization is null) + var context = await GetContextAsync(organizationId: organizationId, requireProject: false); + if (!context.Succeeded || context.ActiveOrganization is null) return context; var projects = await GetOrganizationProjectsAsync(context.ActiveOrganization.Id); @@ -211,101 +142,96 @@ public async Task ResolveProjectContextAsync(string? proje .ToArray(); if (matches.Length == 0) - return McpContextResolution.Failed(McpErrors.NotFound($"Project '{projectName}' was not found in the active organization.", "projectName", projectName)); + return McpContextResolution.Failed(McpErrors.NotFound($"Project '{projectName}' was not found in the selected organization.", "projectName", projectName)); if (matches.Length > 1) { - var result = ToContextResult(context.ActiveOrganization, context.ActiveProject, context.Context.Organizations.Select(ToOrganization).ToArray(), matches); + var result = ToContextResult(context.ActiveOrganization, null, context.Context.Organizations.Select(ToOrganization).ToArray(), matches); return McpContextResolution.Failed(McpErrors.ContextRequired( - $"Multiple projects named '{projectName}' were found. Select a project explicitly.", + $"Multiple projects named '{projectName}' were found. Specify a project id.", "project", result.Organizations, - result.Projects), result, context.ActiveOrganization, context.ActiveProject); + result.Projects), result, context.ActiveOrganization); } - return await SwitchProjectAsync(matches[0].Id); + return await GetContextAsync(projectId: matches[0].Id, requireProject: true); } - public async Task ResolveProjectAsync(string? projectId) + public async Task ResolveProjectAsync(string? projectId = null) { - 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); - } + if (projectId is null) + return await ResolveOnlyProjectAsync(); var projectAccess = await GetAccessibleProjectAsync(projectId.Trim()); - if (projectAccess.Error is not null) - { - return McpProjectContextResolution.Failed(projectAccess.Error); - } + if (!projectAccess.Succeeded) + 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)) + var organization = await organizationRepository.GetByIdAsync(project.OrganizationId, o => o.Cache()); + if (organization is null) { - 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); + return McpProjectContextResolution.Failed(McpErrors.NotAccessible( + $"Organization {project.OrganizationId} was not found or is not accessible.", + "organizationId", + project.OrganizationId)); } - 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); + var context = ToContextResult(organization, project, [organization], [project]); + return McpProjectContextResolution.Success(project, organization, context); } - public async Task ValidateProjectScopeAsync(string organizationId, string projectId) + public async Task ValidateProjectScopeAsync(string organizationId, string projectId, string? requestedProjectId) { - var context = await GetContextAsync(requireProject: true); - if (!context.Succeeded) - return context.Error; + if (requestedProjectId is not null && String.Equals(projectId, requestedProjectId, StringComparison.Ordinal)) + return null; - 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); + var projectContext = await ResolveProjectAsync(requestedProjectId); + if (!projectContext.Succeeded) + return projectContext.Error; - if (!String.Equals(context.ActiveOrganization.Id, organizationId, StringComparison.Ordinal) || !String.Equals(context.ActiveProject.Id, projectId, StringComparison.Ordinal)) + var requestedOrganization = projectContext.Organization!; + var requestedProject = projectContext.Project!; + if (!String.Equals(requestedOrganization.Id, organizationId, StringComparison.Ordinal) + || !String.Equals(requestedProject.Id, projectId, StringComparison.Ordinal)) { return McpErrors.ContextMismatch( - "The requested resource does not match the active MCP context.", - context.ActiveOrganization.Id, + "The requested resource does not match the explicitly selected project.", + requestedOrganization.Id, organizationId, - context.ActiveProject.Id, + requestedProject.Id, projectId); } return null; } + private async Task ResolveOnlyProjectAsync() + { + var accessibleOrganizations = await GetAccessibleOrganizationsAsync(); + if (accessibleOrganizations.Count == 0) + return McpProjectContextResolution.Failed(McpErrors.NotAccessible("No accessible organizations were found.", "organization")); + + var projects = await projectRepository.GetByOrganizationIdsAsync( + accessibleOrganizations.Select(organization => organization.Id).ToArray(), + o => o.PageLimit(CandidateLimit)); + + if (projects.Total != 1) + { + var activeOrganization = accessibleOrganizations.Count == 1 ? accessibleOrganizations[0] : null; + var context = ToContextResult(activeOrganization, null, accessibleOrganizations, projects.Documents); + return McpProjectContextResolution.Failed(McpErrors.ContextRequired( + "Specify a project id before using this MCP tool.", + "project", + context.Organizations, + context.Projects), context); + } + + var project = projects.Documents.Single(); + var projectOrganization = accessibleOrganizations.Single(o => String.Equals(o.Id, project.OrganizationId, StringComparison.Ordinal)); + var resolvedContext = ToContextResult(projectOrganization, project, accessibleOrganizations, [project]); + return McpProjectContextResolution.Success(project, projectOrganization, resolvedContext); + } + private async Task> GetAccessibleOrganizationsAsync() { var organizationIds = Request.GetAssociatedOrganizationIds(); @@ -331,60 +257,16 @@ private async Task GetAccessibleProjectAsync(string 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) + IReadOnlyCollection projects) { return new McpContextResult( activeOrganization?.Id, @@ -394,8 +276,7 @@ private static McpContextResult ToContextResult( 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); + activeOrganization is not null && activeProject is null && projects.Count > 0); } private static Organization ToOrganization(McpOrganizationResult organization) @@ -429,8 +310,6 @@ private static McpProjectResult ToProjectResult(Project project) } } -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; diff --git a/src/Exceptionless.Web/Mcp/McpSessionMigrationHandler.cs b/src/Exceptionless.Web/Mcp/McpSessionMigrationHandler.cs deleted file mode 100644 index 3f29cf28d9..0000000000 --- a/src/Exceptionless.Web/Mcp/McpSessionMigrationHandler.cs +++ /dev/null @@ -1,146 +0,0 @@ -using System.Security.Claims; -using Exceptionless.Core.Authorization; -using Exceptionless.Core.Extensions; -using Foundatio.Caching; -using Foundatio.Serializer; -using Microsoft.Extensions.Options; -using ModelContextProtocol.AspNetCore; -using ModelContextProtocol.Protocol; - -namespace Exceptionless.Web.Mcp; - -public sealed class McpSessionMigrationHandler( - ICacheClient cacheClient, - ITextSerializer serializer, - IOptions transportOptions, - TimeProvider timeProvider, - ILogger logger) : ISessionMigrationHandler -{ - private const string CacheKeyPrefix = "mcp:session:"; - private const string UserClientId = "user"; - private static readonly TimeSpan LifetimeBuffer = TimeSpan.FromMinutes(5); - private static readonly TimeSpan DefaultIdleTimeout = TimeSpan.FromHours(2); - - public async ValueTask OnSessionInitializedAsync(HttpContext context, string sessionId, InitializeRequestParams initializeParams, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - var sessionIdentity = GetSessionIdentity(context); - if (sessionIdentity is null) - { - logger.LogWarning("Skipping MCP session migration persistence for unauthenticated session {SessionIdHash}", HashSessionId(sessionId)); - return; - } - - var state = new McpSessionMigrationState - { - UserId = sessionIdentity.UserId, - ClientId = sessionIdentity.ClientId, - Resource = sessionIdentity.Resource, - InitializeParams = initializeParams, - CreatedUtc = timeProvider.GetUtcNow().UtcDateTime - }; - - string serializedState = serializer.SerializeToString(state); - await cacheClient.SetAsync(GetCacheKey(sessionId), serializedState, GetCacheLifetime()); - } - - public async ValueTask AllowSessionMigrationAsync(HttpContext context, string sessionId, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - var sessionIdentity = GetSessionIdentity(context); - if (sessionIdentity is null) - return null; - - string cacheKey = GetCacheKey(sessionId); - string? serializedState = await cacheClient.GetAsync(cacheKey, null); - if (String.IsNullOrEmpty(serializedState)) - return null; - - McpSessionMigrationState? state; - try - { - state = serializer.Deserialize(serializedState); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to deserialize MCP session migration state for session {SessionIdHash}", HashSessionId(sessionId)); - await cacheClient.RemoveAsync(cacheKey); - return null; - } - - if (state is null) - { - await cacheClient.RemoveAsync(cacheKey); - return null; - } - - if (!Matches(state, sessionIdentity)) - { - logger.LogWarning("Rejected MCP session migration for session {SessionIdHash} because the authenticated caller changed", HashSessionId(sessionId)); - return null; - } - - await cacheClient.SetExpirationAsync(cacheKey, GetCacheLifetime()); - return state.InitializeParams; - } - - private TimeSpan GetCacheLifetime() - { - TimeSpan idleTimeout = transportOptions.Value.IdleTimeout; - if (idleTimeout <= TimeSpan.Zero) - idleTimeout = DefaultIdleTimeout; - - return idleTimeout + LifetimeBuffer; - } - - private static bool Matches(McpSessionMigrationState state, McpSessionIdentity sessionIdentity) - { - return String.Equals(state.UserId, sessionIdentity.UserId, StringComparison.Ordinal) - && String.Equals(state.ClientId, sessionIdentity.ClientId, StringComparison.Ordinal) - && String.Equals(state.Resource, sessionIdentity.Resource, StringComparison.Ordinal); - } - - private static McpSessionIdentity? GetSessionIdentity(HttpContext context) - { - var user = context.User; - if (!user.IsAuthenticated() || !user.IsInRole(AuthorizationRoles.McpRead)) - return null; - - string? userId = user.GetClaimValue(ClaimTypes.NameIdentifier); - if (String.IsNullOrWhiteSpace(userId)) - return null; - - string clientId = user.GetClaimValue(IdentityUtils.OAuthClientIdClaim) ?? UserClientId; - string resource = user.GetClaimValue(IdentityUtils.OAuthResourceClaim) ?? context.Request.Path.ToString(); - return String.IsNullOrWhiteSpace(resource) - ? null - : new McpSessionIdentity(userId, clientId, resource); - } - - private static string GetCacheKey(string sessionId) - { - return String.Concat(CacheKeyPrefix, sessionId.ToSHA1()); - } - - private static string HashSessionId(string sessionId) - { - return sessionId.ToSHA1(); - } - - private sealed record McpSessionIdentity(string UserId, string ClientId, string Resource); -} - -public sealed class McpSessionMigrationState -{ - public required string UserId { get; init; } - - public required string ClientId { get; init; } - - public required string Resource { get; init; } - - public required InitializeRequestParams InitializeParams { get; init; } - - public DateTime CreatedUtc { get; init; } -} diff --git a/src/Exceptionless.Web/Program.cs b/src/Exceptionless.Web/Program.cs index d9c24c4d40..3024850883 100644 --- a/src/Exceptionless.Web/Program.cs +++ b/src/Exceptionless.Web/Program.cs @@ -20,7 +20,6 @@ using Foundatio.Extensions.Hosting.Startup; using Foundatio.Mediator; using Foundatio.Repositories.Exceptions; -using HttpIResult = Microsoft.AspNetCore.Http.IResult; using Joonasw.AspNetCore.SecurityHeaders; using Joonasw.AspNetCore.SecurityHeaders.Csp; using Microsoft.AspNetCore.Authorization; @@ -38,6 +37,7 @@ using Serilog; using Serilog.Events; using Serilog.Sinks.Exceptionless; +using HttpIResult = Microsoft.AspNetCore.Http.IResult; namespace Exceptionless.Web; @@ -190,9 +190,10 @@ public static async Task Main(string[] args) .MapStatus(ResultStatus.Unavailable, ApiResultMapper.MapUnavailable)); Bootstrapper.RegisterServices(builder.Services, options, Log.Logger.ToLoggerFactory()); builder.Services.AddScoped(); - builder.Services.AddSingleton(); - builder.Services.AddMcpServer() - .WithHttpTransport(o => o.Stateless = false) + // Leave the protocol version unset so native v2 and down-level MCP clients can negotiate a supported version. + builder.Services.AddMcpServer(options => + options.ServerInstructions = "Exceptionless MCP tools are stateless. Scoped ids may be omitted when the current OAuth grant exposes exactly one matching organization or project; otherwise use list_organizations, list_projects, or resolve_project and pass the required id explicitly. Previous tool calls never change the scope of later calls.") + .WithHttpTransport() .WithTools(); builder.Services.AddSingleton(_ => new ThrottlingOptions { @@ -351,6 +352,9 @@ ApplicationException applicationException when applicationException.Message.Cont .AddPreferredSecuritySchemes("Bearer"); }); app.MapApiEndpoints(); + app.MapGet("/mcp", () => Results.StatusCode(StatusCodes.Status405MethodNotAllowed)) + .RequireAuthorization(AuthorizationRoles.McpPolicy) + .ExcludeFromDescription(); app.MapMcp("/mcp").RequireAuthorization(AuthorizationRoles.McpPolicy); app.MapFallback("{**slug:nonfile}", CreateRequestDelegate(app, "/index.html")); diff --git a/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs index 1258709ae4..00ca00733f 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs @@ -18,6 +18,8 @@ using Foundatio.Repositories.Utility; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; using Xunit; namespace Exceptionless.Tests.Api.Endpoints; @@ -332,6 +334,20 @@ public async Task McpAsync_GetWithUserAuth_ReturnsForbidden() Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } + [Fact] + public async Task McpAsync_GetWithOAuthBearer_ReturnsMethodNotAllowed() + { + var token = await IssueTokenAsync(); + using var client = _server.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, "/mcp"); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken); + + var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + } + [Fact] public async Task AuthorizeAsync_AnonymousUser_RedirectsToAuthorizeBridge() { @@ -795,6 +811,113 @@ public async Task OAuthBearer_RestApiResourceForMcp_ReturnsUnauthorized() Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } + [Fact] + public async Task OAuthBearer_McpV2Client_UsesNativeStatelessProtocolAndCallsTool() + { + const string nativeProtocolVersion = "2026-07-28"; + var token = await IssueTokenAsync(); + using var httpClient = _server.CreateClient(); + var transport = new HttpClientTransport( + new HttpClientTransportOptions + { + Endpoint = new Uri(_server.BaseAddress, "/mcp"), + AdditionalHeaders = new Dictionary + { + ["Authorization"] = $"Bearer {token.AccessToken}" + } + }, + httpClient, + GetService(), + ownsHttpClient: false); + + var options = new McpClientOptions + { + ProtocolVersion = nativeProtocolVersion, + ClientInfo = new Implementation + { + Name = "exceptionless-mcp-v2-tests", + Version = "1.0.0" + } + }; + + await using var client = await McpClient.CreateAsync( + transport, + options, + cancellationToken: TestContext.Current.CancellationToken); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var resolvedProject = await client.CallToolAsync( + "resolve_project", + new Dictionary + { + ["projectId"] = TestConstants.ProjectId + }, + cancellationToken: TestContext.Current.CancellationToken); + var project = await client.CallToolAsync( + "get_project", + new Dictionary + { + ["projectId"] = TestConstants.ProjectId + }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(nativeProtocolVersion, client.NegotiatedProtocolVersion); + Assert.Null(client.SessionId); + Assert.Contains("stateless", client.ServerInstructions, StringComparison.OrdinalIgnoreCase); + Assert.Contains(tools, tool => String.Equals(tool.Name, "resolve_project", StringComparison.Ordinal)); + Assert.DoesNotContain(tools, tool => String.Equals(tool.Name, "get_context", StringComparison.Ordinal)); + Assert.DoesNotContain(tools, tool => String.Equals(tool.Name, "switch_organization", StringComparison.Ordinal)); + Assert.DoesNotContain(tools, tool => String.Equals(tool.Name, "switch_project", StringComparison.Ordinal)); + Assert.DoesNotContain(tools, tool => String.Equals(tool.Name, "resolve_project_context", StringComparison.Ordinal)); + Assert.NotEqual(true, resolvedProject.IsError); + Assert.NotEqual(true, project.IsError); + Assert.NotNull(project.StructuredContent); + Assert.Contains(TestConstants.ProjectId, project.StructuredContent.ToString(), StringComparison.Ordinal); + } + + [Theory] + [InlineData("2025-06-18")] + [InlineData("2025-11-25")] + public async Task OAuthBearer_McpDownLevelClient_NegotiatesAndCallsTool(string downLevelProtocolVersion) + { + var token = await IssueTokenAsync(); + using var httpClient = _server.CreateClient(); + var transport = new HttpClientTransport( + new HttpClientTransportOptions + { + Endpoint = new Uri(_server.BaseAddress, "/mcp"), + AdditionalHeaders = new Dictionary + { + ["Authorization"] = $"Bearer {token.AccessToken}" + } + }, + httpClient, + GetService(), + ownsHttpClient: false); + + var options = new McpClientOptions + { + ProtocolVersion = downLevelProtocolVersion, + ClientInfo = new Implementation + { + Name = "exceptionless-mcp-down-level-tests", + Version = "1.0.0" + } + }; + + await using var client = await McpClient.CreateAsync( + transport, + options, + cancellationToken: TestContext.Current.CancellationToken); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var filterFields = await client.CallToolAsync("get_filter_fields", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(downLevelProtocolVersion, client.NegotiatedProtocolVersion); + Assert.Contains("stateless", client.ServerInstructions, StringComparison.OrdinalIgnoreCase); + Assert.Contains(tools, tool => String.Equals(tool.Name, "get_filter_fields", StringComparison.Ordinal)); + Assert.NotEqual(true, filterFields.IsError); + Assert.NotNull(filterFields.StructuredContent); + } + [Fact] public async Task OAuthBearer_RestApiResourceMissingScope_ReturnsForbidden() { diff --git a/tests/Exceptionless.Tests/Mcp/ExceptionlessMcpToolsTests.cs b/tests/Exceptionless.Tests/Mcp/ExceptionlessMcpToolsTests.cs index 16e4822d2f..9bcc65567b 100644 --- a/tests/Exceptionless.Tests/Mcp/ExceptionlessMcpToolsTests.cs +++ b/tests/Exceptionless.Tests/Mcp/ExceptionlessMcpToolsTests.cs @@ -10,7 +10,6 @@ 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; @@ -45,7 +44,7 @@ protected override async Task ResetDataAsync() } [Fact] - public async Task GetContextAsync_MultipleOrganizations_ReturnsContextRequired() + public async Task ResolveProjectAsync_MultipleOrganizationsWithoutOrganizationId_ReturnsContextRequired() { var tools = await CreateToolsForOrganizationsAsync( Guid.NewGuid().ToString("N"), @@ -53,7 +52,7 @@ public async Task GetContextAsync_MultipleOrganizations_ReturnsContextRequired() AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead); - var result = await tools.GetContextAsync(); + var result = await tools.ResolveProjectAsync(projectName: "Test"); Assert.False(result.Ok); Assert.Equal(McpErrorCodes.ContextRequired, result.Error?.Code); @@ -64,7 +63,7 @@ public async Task GetContextAsync_MultipleOrganizations_ReturnsContextRequired() } [Fact] - public async Task SwitchOrganizationAsync_FiltersProjectsToActiveOrganization() + public async Task ResolveProjectAsync_ProjectId_ReturnsRequestedProject() { var tools = await CreateToolsForOrganizationsAsync( Guid.NewGuid().ToString("N"), @@ -72,72 +71,86 @@ public async Task SwitchOrganizationAsync_FiltersProjectsToActiveOrganization() AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead); - var context = await tools.SwitchOrganizationAsync(SampleDataService.FREE_ORG_ID); - var projects = await tools.ListProjectsAsync(limit: 50); + var context = await tools.ResolveProjectAsync(projectId: TestConstants.ProjectId); 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); + Assert.Equal(TestConstants.OrganizationId, Data(context).ActiveOrganizationId); + Assert.Equal(TestConstants.ProjectId, Data(context).ActiveProjectId); } [Fact] - public async Task GetContextAsync_IsIsolatedPerMcpSession() + public async Task ResolveProjectAsync_SingleAccessibleProjectWithoutIds_ReturnsProject() { - 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 tools = await CreateToolsForOrganizationsAsync( + Guid.NewGuid().ToString("N"), + [SampleDataService.FREE_ORG_ID], + AuthorizationRoles.McpRead, + AuthorizationRoles.ProjectsRead); - var switchResult = await toolsA.SwitchOrganizationAsync(SampleDataService.FREE_ORG_ID); - var contextB = await toolsB.GetContextAsync(); + var context = await tools.ResolveProjectAsync(); - Assert.True(switchResult.Ok); - Assert.False(contextB.Ok); - Assert.Equal(McpErrorCodes.ContextRequired, contextB.Error?.Code); - Assert.Equal("organization", contextB.Error?.Details?["selection"]); + Assert.True(context.Ok); + Assert.Equal(SampleDataService.FREE_ORG_ID, Data(context).ActiveOrganizationId); + Assert.Equal(SampleDataService.FREE_PROJECT_ID, Data(context).ActiveProjectId); } [Fact] - public async Task GetContextAsync_StaleOrganizationContext_ReResolvesAccessibleOrganization() + public async Task ResolveProjectAsync_SingleAccessibleProjectAcrossMultipleOrganizationsWithoutIds_ReturnsProject() { - 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], + var tools = await CreateToolsForOrganizationsAsync( + Guid.NewGuid().ToString("N"), + [SampleDataService.FREE_ORG_ID, TestConstants.OrganizationId3], AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead); - var context = await toolsWithSingleOrganization.GetContextAsync(); + var context = await tools.ResolveProjectAsync(); Assert.True(context.Ok); - Assert.Equal(TestConstants.OrganizationId, Data(context).ActiveOrganizationId); - Assert.Null(Data(context).ActiveProjectId); + Assert.Equal(SampleDataService.FREE_ORG_ID, Data(context).ActiveOrganizationId); + Assert.Equal(SampleDataService.FREE_PROJECT_ID, Data(context).ActiveProjectId); } [Fact] - public async Task SearchStacksAsync_WithoutProjectId_UsesActiveProject() + public async Task ResolveProjectAsync_ProjectOutsideExplicitOrganization_ReturnsContextMismatch() { - 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 tools = await CreateToolsForOrganizationsAsync( + Guid.NewGuid().ToString("N"), + [TestConstants.OrganizationId, SampleDataService.FREE_ORG_ID], + AuthorizationRoles.McpRead); - var result = await tools.SearchStacksAsync(limit: 50); + var context = await tools.ResolveProjectAsync( + projectId: TestConstants.ProjectId, + organizationId: SampleDataService.FREE_ORG_ID); + + Assert.False(context.Ok); + Assert.Equal(McpErrorCodes.ContextMismatch, context.Error?.Code); + } + + [Fact] + public async Task SearchStacksAsync_ExplicitProject_ReturnsProjectStacks() + { + var (stacks, _) = await CreateDataAsync(d => d.Event().TestProject().Message("MCP explicit project stack")); + var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead, AuthorizationRoles.StacksRead); + var result = await tools.SearchStacksAsync(TestConstants.ProjectId, limit: 50); Assert.True(result.Ok); Assert.Contains(Items(result), stack => stack.Id == stacks[0].Id); } [Fact] - public async Task SearchStacksAsync_WithoutProjectContext_ReturnsContextRequired() + public async Task SearchStacksAsync_EmptyProjectId_ReturnsInvalidId() + { + var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.StacksRead); + + var result = await tools.SearchStacksAsync(String.Empty); + + Assert.False(result.Ok); + Assert.Equal(McpErrorCodes.InvalidId, result.Error?.Code); + Assert.Equal("projectId", result.Error?.Details?["field"]); + } + + [Fact] + public async Task SearchStacksAsync_MultipleAccessibleProjectsWithoutProjectId_ReturnsContextRequired() { var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.StacksRead); @@ -149,26 +162,57 @@ public async Task SearchStacksAsync_WithoutProjectContext_ReturnsContextRequired } [Fact] - public async Task SearchStacksAsync_ConflictingProjectId_ReturnsContextMismatch() + public async Task SearchStacksAsync_SingleAccessibleProjectWithoutProjectId_ReturnsProjectStacks() + { + var (stacks, _) = await CreateDataAsync(d => d.Event().FreeProject().Message("MCP inferred project stack")); + var tools = await CreateToolsForOrganizationsAsync( + Guid.NewGuid().ToString("N"), + [SampleDataService.FREE_ORG_ID], + AuthorizationRoles.McpRead, + AuthorizationRoles.StacksRead); + + 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 GetStackAsync_SingleAccessibleProjectWithoutProjectId_ReturnsStack() + { + var (stacks, _) = await CreateDataAsync(d => d.Event().FreeProject().Message("MCP inferred project stack lookup")); + var tools = await CreateToolsForOrganizationsAsync( + Guid.NewGuid().ToString("N"), + [SampleDataService.FREE_ORG_ID], + AuthorizationRoles.McpRead, + AuthorizationRoles.StacksRead); + + var result = await tools.GetStackAsync(stacks[0].Id); + + Assert.True(result.Ok); + Assert.Equal(stacks[0].Id, Data(result).Id); + } + + [Fact] + public async Task SearchStacksAsync_PriorProjectResolution_DoesNotConstrainExplicitProject() { + var (stacks, _) = await CreateDataAsync(d => d.Event().TestProject().Message("MCP stateless project stack")); var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead, AuthorizationRoles.StacksRead); - await tools.SwitchProjectAsync(TestConstants.ProjectId); + await tools.ResolveProjectAsync(projectId: SampleDataService.TEST_ROCKET_SHIP_PROJECT_ID); - var result = await tools.SearchStacksAsync(SampleDataService.TEST_ROCKET_SHIP_PROJECT_ID); + var result = await tools.SearchStacksAsync(TestConstants.ProjectId, limit: 50); - Assert.False(result.Ok); - Assert.Equal(McpErrorCodes.ContextMismatch, result.Error?.Code); + Assert.True(result.Ok); + Assert.Contains(Items(result), stack => stack.Id == stacks[0].Id); } [Fact] - public async Task GetEventAsync_EventOutsideActiveProject_ReturnsContextMismatch() + public async Task GetEventAsync_EventOutsideExplicitProject_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); + var result = await tools.GetEventAsync(events[0].Id, SampleDataService.TEST_ROCKET_SHIP_PROJECT_ID); Assert.False(result.Ok); Assert.Equal(McpErrorCodes.ContextMismatch, result.Error?.Code); @@ -178,13 +222,43 @@ public async Task ListProjectsAsync_ProjectsScope_ReturnsAccessibleProjects() { var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead); - var result = await tools.ListProjectsAsync(limit: 50); + var result = await tools.ListProjectsAsync(TestConstants.OrganizationId, limit: 50); Assert.True(result.Ok); Assert.Null(result.Error); Assert.Contains(Items(result), p => p.Id == TestConstants.ProjectId); } + [Fact] + public async Task ListProjectsAsync_SingleAccessibleOrganizationWithoutOrganizationId_ReturnsProjects() + { + var tools = await CreateToolsForOrganizationsAsync( + Guid.NewGuid().ToString("N"), + [SampleDataService.FREE_ORG_ID], + AuthorizationRoles.McpRead, + AuthorizationRoles.ProjectsRead); + + var result = await tools.ListProjectsAsync(limit: 50); + + Assert.True(result.Ok); + Assert.Contains(Items(result), project => project.Id == SampleDataService.FREE_PROJECT_ID); + } + + [Fact] + public async Task GetProjectAsync_SingleAccessibleProjectWithoutProjectId_ReturnsProject() + { + var tools = await CreateToolsForOrganizationsAsync( + Guid.NewGuid().ToString("N"), + [SampleDataService.FREE_ORG_ID], + AuthorizationRoles.McpRead, + AuthorizationRoles.ProjectsRead); + + var result = await tools.GetProjectAsync(); + + Assert.True(result.Ok); + Assert.Equal(SampleDataService.FREE_PROJECT_ID, Data(result).Id); + } + [Fact] public async Task GetClientSetupInstructionsAsync_Expo_ReturnsProjectSpecificInstructions() { @@ -253,9 +327,7 @@ 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); + var result = await tools.GetEventAsync(events[0].Id, TestConstants.ProjectId); Assert.True(result.Ok); Assert.Null(result.Error); @@ -283,9 +355,7 @@ 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 result = await tools.GetEventAsync(ev.Id, TestConstants.ProjectId); var item = Data(result); Assert.True(result.Ok); @@ -565,7 +635,7 @@ public async Task ListProjectsAsync_MalformedFilter_ReturnsSpecificError() { var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead); - var result = await tools.ListProjectsAsync(filter: "((((("); + var result = await tools.ListProjectsAsync(TestConstants.OrganizationId, filter: "((((("); Assert.False(result.Ok); Assert.Equal(McpErrorCodes.InvalidFilter, result.Error?.Code); @@ -616,7 +686,7 @@ public async Task GetEventAsync_InvalidEventId_ReturnsInvalidId() { var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.EventsRead); - var result = await tools.GetEventAsync("bad-event-id"); + var result = await tools.GetEventAsync("bad-event-id", TestConstants.ProjectId); Assert.False(result.Ok); Assert.Null(result.Data); @@ -628,7 +698,7 @@ public async Task GetEventAsync_MissingEventId_ReturnsNotFound() { var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.EventsRead); - var result = await tools.GetEventAsync("000000000000000000000000"); + var result = await tools.GetEventAsync("000000000000000000000000", TestConstants.ProjectId); Assert.False(result.Ok); Assert.Equal(McpErrorCodes.NotFound, result.Error?.Code); @@ -652,9 +722,7 @@ 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 result = await tools.GetEventAsync(ev.Id, TestConstants.ProjectId, maxDetailSize: 1024); var item = Data(result); Assert.True(result.Ok); @@ -671,7 +739,7 @@ public async Task GetEventAsync_InvalidMaxDetailSize_ReturnsError() var (_, events) = await CreateDataAsync(d => d.Event().TestProject().Message("MCP detail size")); var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.EventsRead); - var result = await tools.GetEventAsync(events[0].Id, maxDetailSize: 100); + var result = await tools.GetEventAsync(events[0].Id, TestConstants.ProjectId, maxDetailSize: 100); Assert.False(result.Ok); Assert.Equal(McpErrorCodes.InvalidDetailSize, result.Error?.Code); @@ -702,9 +770,7 @@ 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 result = await tools.UpdateStackStatusAsync(stacks[0].Id, "fixed", TestConstants.ProjectId, "1.0.2"); var data = Data(result); Assert.True(result.Ok); @@ -727,7 +793,7 @@ public async Task UpdateStackStatusAsync_MissingStacksWriteScope_ReturnsError() var (stacks, _) = await CreateDataAsync(d => d.Event().TestProject().Message("MCP write missing scope")); var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.StacksRead); - var result = await tools.UpdateStackStatusAsync(stacks[0].Id, "fixed"); + var result = await tools.UpdateStackStatusAsync(stacks[0].Id, "fixed", TestConstants.ProjectId); Assert.False(result.Ok); Assert.Equal(McpErrorCodes.Forbidden, result.Error?.Code); @@ -741,7 +807,7 @@ public async Task UpdateStackStatusAsync_UserRoleWithoutStacksWriteScope_Returns var (stacks, _) = await CreateDataAsync(d => d.Event().TestProject().Message("MCP write user role missing scope")); var tools = await CreateToolsWithUserRoleAsync(AuthorizationRoles.McpRead, AuthorizationRoles.StacksRead); - var result = await tools.UpdateStackStatusAsync(stacks[0].Id, "fixed", fixedInVersion: "1.2.3"); + var result = await tools.UpdateStackStatusAsync(stacks[0].Id, "fixed", TestConstants.ProjectId, fixedInVersion: "1.2.3"); Assert.False(result.Ok); Assert.Equal(McpErrorCodes.Forbidden, result.Error?.Code); @@ -755,7 +821,7 @@ public async Task UpdateStackStatusAsync_InvalidStatus_ReturnsError() var (stacks, _) = await CreateDataAsync(d => d.Event().TestProject().Message("MCP write invalid status")); var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.StacksWrite); - var result = await tools.UpdateStackStatusAsync(stacks[0].Id, "snoozed"); + var result = await tools.UpdateStackStatusAsync(stacks[0].Id, "snoozed", TestConstants.ProjectId); Assert.False(result.Ok); Assert.Equal(McpErrorCodes.InvalidStatus, result.Error?.Code); @@ -771,9 +837,7 @@ 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 result = await tools.SnoozeStackAsync(stacks[0].Id, TestConstants.ProjectId, duration: "2h"); var data = Data(result); Assert.True(result.Ok); @@ -799,7 +863,7 @@ public async Task SnoozeStackAsync_TooShort_ReturnsError() var (stacks, _) = await CreateDataAsync(d => d.Event().TestProject().Message("MCP write snooze short")); var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.StacksWrite); - var result = await tools.SnoozeStackAsync(stacks[0].Id, duration: "1m"); + var result = await tools.SnoozeStackAsync(stacks[0].Id, TestConstants.ProjectId, duration: "1m"); Assert.False(result.Ok); Assert.Equal(McpErrorCodes.InvalidSnooze, result.Error?.Code); @@ -812,9 +876,7 @@ 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 result = await tools.SetStackCriticalAsync(stacks[0].Id, critical: true, projectId: TestConstants.ProjectId); var data = Data(result); Assert.True(result.Ok); @@ -833,9 +895,7 @@ 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 result = await tools.AddStackReferenceLinkAsync(stacks[0].Id, " https://github.com/exceptionless/Exceptionless/issues/123 ", TestConstants.ProjectId); var data = Data(result); Assert.True(result.Ok); @@ -855,9 +915,7 @@ 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 result = await tools.AddStackReferenceLinkAsync(stacks[0].Id, url, TestConstants.ProjectId); var data = Data(result); Assert.True(result.Ok); @@ -873,7 +931,7 @@ public async Task AddStackReferenceLinkAsync_EmptyUrl_ReturnsError() var (stacks, _) = await CreateDataAsync(d => d.Event().TestProject().Message("MCP write empty reference")); var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.StacksWrite); - var result = await tools.AddStackReferenceLinkAsync(stacks[0].Id, " "); + var result = await tools.AddStackReferenceLinkAsync(stacks[0].Id, " ", TestConstants.ProjectId); Assert.False(result.Ok); Assert.Equal(McpErrorCodes.InvalidReferenceLink, result.Error?.Code); @@ -889,7 +947,7 @@ public async Task AddStackReferenceLinkAsync_InvalidUrl_ReturnsInvalidReferenceU 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); + var result = await tools.AddStackReferenceLinkAsync(stacks[0].Id, url, TestConstants.ProjectId); Assert.False(result.Ok); Assert.Equal(McpErrorCodes.InvalidReferenceUrl, result.Error?.Code); @@ -904,9 +962,7 @@ 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 result = await tools.RemoveStackReferenceLinkAsync(stacks[0].Id, url, TestConstants.ProjectId); var data = Data(result); Assert.True(result.Ok); @@ -926,9 +982,7 @@ 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 result = await tools.RemoveStackReferenceLinkAsync(stacks[0].Id, url, TestConstants.ProjectId); var data = Data(result); Assert.True(result.Ok); @@ -943,7 +997,7 @@ public async Task RemoveStackReferenceLinkAsync_EmptyUrl_ReturnsError() var (stacks, _) = await CreateDataAsync(d => d.Event().TestProject().Message("MCP remove empty reference")); var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.StacksWrite); - var result = await tools.RemoveStackReferenceLinkAsync(stacks[0].Id, " "); + var result = await tools.RemoveStackReferenceLinkAsync(stacks[0].Id, " ", TestConstants.ProjectId); Assert.False(result.Ok); Assert.Equal(McpErrorCodes.InvalidReferenceLink, result.Error?.Code); @@ -957,9 +1011,7 @@ 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); + var firstPage = await tools.GetStackEventsAsync(stacks[0].Id, TestConstants.ProjectId, limit: 1); Assert.True(firstPage.Ok); Assert.Null(firstPage.Error); @@ -968,7 +1020,7 @@ public async Task GetStackEventsAsync_WithAfterCursor_ReturnsNextPage() Assert.True(pagination.HasMore); Assert.NotNull(pagination.After); - var secondPage = await tools.GetStackEventsAsync(stacks[0].Id, limit: 1, after: pagination.After); + var secondPage = await tools.GetStackEventsAsync(stacks[0].Id, TestConstants.ProjectId, limit: 1, after: pagination.After); Assert.True(secondPage.Ok); Assert.Null(secondPage.Error); @@ -994,9 +1046,9 @@ public async Task PagedTools_InvalidCursor_ReturnsInvalidCursor(string methodNam (bool Ok, McpErrorInfo? Error, object? Data) result = methodName switch { - nameof(ExceptionlessMcpTools.ListProjectsAsync) => ToResult(await tools.ListProjectsAsync(after: after, before: before)), + nameof(ExceptionlessMcpTools.ListProjectsAsync) => ToResult(await tools.ListProjectsAsync(TestConstants.OrganizationId, after: after, before: before)), nameof(ExceptionlessMcpTools.SearchStacksAsync) => ToResult(await tools.SearchStacksAsync(TestConstants.ProjectId, after: after, before: before)), - nameof(ExceptionlessMcpTools.GetStackEventsAsync) => ToResult(await tools.GetStackEventsAsync(stacks[0].Id, after: after, before: before)), + nameof(ExceptionlessMcpTools.GetStackEventsAsync) => ToResult(await tools.GetStackEventsAsync(stacks[0].Id, TestConstants.ProjectId, after: after, before: before)), nameof(ExceptionlessMcpTools.SearchEventsAsync) => ToResult(await tools.SearchEventsAsync(TestConstants.ProjectId, after: after, before: before)), _ => throw new InvalidOperationException($"Unexpected method {methodName}.") }; @@ -1027,6 +1079,17 @@ public async Task ListProjectsAsync_OutputSchemaDoesNotRequireNullableFields() Assert.DoesNotContain("lastEventDateUtc", RequiredProperties(projectSchema)); } + [Fact] + public async Task ListProjectsAsync_OrganizationIdIsOptionalInSchema() + { + var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead); + var method = typeof(ExceptionlessMcpTools).GetMethod(nameof(ExceptionlessMcpTools.ListProjectsAsync)) ?? throw new InvalidOperationException("Could not find ListProjectsAsync."); + var tool = McpServerTool.Create(method, tools, new McpServerToolCreateOptions()); + + Assert.True(tool.ProtocolTool.InputSchema.GetProperty("properties").TryGetProperty("organizationId", out _)); + Assert.DoesNotContain("organizationId", RequiredProperties(tool.ProtocolTool.InputSchema)); + } + [Theory] [InlineData(nameof(ExceptionlessMcpTools.ListProjectsAsync))] [InlineData(nameof(ExceptionlessMcpTools.SearchStacksAsync))] @@ -1119,8 +1182,16 @@ public async Task GetClientSetupInstructionsAsync_SchemaAdvertisesExpoPlatform() [InlineData(nameof(ExceptionlessMcpTools.GetProjectAsync))] [InlineData(nameof(ExceptionlessMcpTools.GetClientSetupInstructionsAsync))] [InlineData(nameof(ExceptionlessMcpTools.SearchStacksAsync))] + [InlineData(nameof(ExceptionlessMcpTools.GetStackAsync))] + [InlineData(nameof(ExceptionlessMcpTools.GetStackEventsAsync))] [InlineData(nameof(ExceptionlessMcpTools.SearchEventsAsync))] + [InlineData(nameof(ExceptionlessMcpTools.GetEventAsync))] [InlineData(nameof(ExceptionlessMcpTools.CountEventsAsync))] + [InlineData(nameof(ExceptionlessMcpTools.UpdateStackStatusAsync))] + [InlineData(nameof(ExceptionlessMcpTools.SnoozeStackAsync))] + [InlineData(nameof(ExceptionlessMcpTools.SetStackCriticalAsync))] + [InlineData(nameof(ExceptionlessMcpTools.AddStackReferenceLinkAsync))] + [InlineData(nameof(ExceptionlessMcpTools.RemoveStackReferenceLinkAsync))] public async Task ProjectScopedTools_ProjectIdIsOptionalInSchema(string methodName) { var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead, AuthorizationRoles.StacksRead, AuthorizationRoles.EventsRead); @@ -1131,6 +1202,7 @@ public async Task ProjectScopedTools_ProjectIdIsOptionalInSchema(string methodNa 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))] @@ -1145,6 +1217,7 @@ public async Task StackWriteTools_SchemaAdvertisesWriteInputs(string methodName) var properties = tool.ProtocolTool.InputSchema.GetProperty("properties"); Assert.True(properties.TryGetProperty("stackId", out _), "The stackId input must be advertised in the MCP tool schema."); + Assert.True(properties.TryGetProperty("projectId", out _), "The projectId input must be advertised in the MCP tool schema."); switch (methodName) { case nameof(ExceptionlessMcpTools.UpdateStackStatusAsync): @@ -1168,29 +1241,22 @@ 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, sessionId: Guid.NewGuid().ToString("N"), organizationIds: null, scopes); + return CreateToolsAsync(includeUserRole: false, grantId: Guid.NewGuid().ToString("N"), organizationIds: null, scopes); } private Task CreateToolsWithUserRoleAsync(params string[] scopes) { - return CreateToolsAsync(includeUserRole: true, sessionId: Guid.NewGuid().ToString("N"), organizationIds: null, scopes); + return CreateToolsAsync(includeUserRole: true, grantId: Guid.NewGuid().ToString("N"), organizationIds: null, scopes); } - private Task CreateToolsForOrganizationsAsync(string sessionId, IReadOnlyCollection organizationIds, params string[] scopes) + private Task CreateToolsForOrganizationsAsync(string grantId, IReadOnlyCollection organizationIds, params string[] scopes) { - return CreateToolsAsync(includeUserRole: false, sessionId, organizationIds, scopes); + return CreateToolsAsync(includeUserRole: false, grantId, organizationIds, scopes); } - private Task CreateToolsAsync(bool includeUserRole, string sessionId, IReadOnlyCollection? organizationIds, params string[] scopes) + private Task CreateToolsAsync(bool includeUserRole, string grantId, IReadOnlyCollection? organizationIds, params string[] scopes) { organizationIds ??= [TestConstants.OrganizationId]; @@ -1207,10 +1273,10 @@ private Task CreateToolsAsync(bool includeUserRole, strin var utcNow = TimeProvider.GetUtcNow().UtcDateTime; var token = new OAuthToken { - Id = "oauth-test-token", + Id = Guid.NewGuid().ToString("N"), UserId = user.Id, ClientId = "test-client", - GrantId = "test-grant", + GrantId = grantId, Resource = "http://localhost/mcp", AccessTokenHash = OAuthService.CreateTokenHash("mcp-tools-access-token"), Scopes = scopes.ToHashSet(StringComparer.Ordinal), @@ -1230,16 +1296,11 @@ private Task CreateToolsAsync(bool includeUserRole, strin }; 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); + _projectRepository); return Task.FromResult(new ExceptionlessMcpTools( accessor, diff --git a/tests/Exceptionless.Tests/Mcp/McpSessionMigrationHandlerTests.cs b/tests/Exceptionless.Tests/Mcp/McpSessionMigrationHandlerTests.cs deleted file mode 100644 index 576d36d60c..0000000000 --- a/tests/Exceptionless.Tests/Mcp/McpSessionMigrationHandlerTests.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System.Security.Claims; -using Exceptionless.Core.Authorization; -using Exceptionless.Core.Extensions; -using Exceptionless.Web.Mcp; -using Foundatio.Caching; -using Foundatio.Serializer; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using ModelContextProtocol.AspNetCore; -using ModelContextProtocol.Protocol; -using Xunit; - -namespace Exceptionless.Tests.Mcp; - -public sealed class McpSessionMigrationHandlerTests : TestWithServices -{ - public McpSessionMigrationHandlerTests(ITestOutputHelper output) : base(output) { } - - [Fact] - public async Task AllowSessionMigrationAsync_SameCaller_ReturnsStoredInitializeParams() - { - var handler = CreateHandler(); - string sessionId = Guid.NewGuid().ToString("N"); - var context = CreateContext(); - var initializeParams = CreateInitializeParams(); - - await handler.OnSessionInitializedAsync(context, sessionId, initializeParams, CancellationToken.None); - - var restored = await handler.AllowSessionMigrationAsync(context, sessionId, CancellationToken.None); - - Assert.NotNull(restored); - Assert.Equal(initializeParams.ProtocolVersion, restored.ProtocolVersion); - Assert.Equal(initializeParams.ClientInfo.Name, restored.ClientInfo.Name); - Assert.Equal(initializeParams.ClientInfo.Version, restored.ClientInfo.Version); - } - - [Fact] - public async Task AllowSessionMigrationAsync_DifferentUser_ReturnsNull() - { - var handler = CreateHandler(); - string sessionId = Guid.NewGuid().ToString("N"); - var initializeContext = CreateContext(userId: "user-a"); - var migrationContext = CreateContext(userId: "user-b"); - - await handler.OnSessionInitializedAsync(initializeContext, sessionId, CreateInitializeParams(), CancellationToken.None); - - var restored = await handler.AllowSessionMigrationAsync(migrationContext, sessionId, CancellationToken.None); - - Assert.Null(restored); - } - - [Fact] - public async Task AllowSessionMigrationAsync_DifferentOAuthClient_ReturnsNull() - { - var handler = CreateHandler(); - string sessionId = Guid.NewGuid().ToString("N"); - var initializeContext = CreateContext(clientId: "client-a"); - var migrationContext = CreateContext(clientId: "client-b"); - - await handler.OnSessionInitializedAsync(initializeContext, sessionId, CreateInitializeParams(), CancellationToken.None); - - var restored = await handler.AllowSessionMigrationAsync(migrationContext, sessionId, CancellationToken.None); - - Assert.Null(restored); - } - - [Fact] - public async Task AllowSessionMigrationAsync_MissingMcpReadRole_ReturnsNull() - { - var handler = CreateHandler(); - string sessionId = Guid.NewGuid().ToString("N"); - var initializeContext = CreateContext(); - var migrationContext = CreateContext(includeMcpReadRole: false); - - await handler.OnSessionInitializedAsync(initializeContext, sessionId, CreateInitializeParams(), CancellationToken.None); - - var restored = await handler.AllowSessionMigrationAsync(migrationContext, sessionId, CancellationToken.None); - - Assert.Null(restored); - } - - private McpSessionMigrationHandler CreateHandler() - { - return new McpSessionMigrationHandler( - GetService(), - GetService(), - Options.Create(new HttpServerTransportOptions { IdleTimeout = TimeSpan.FromMinutes(30) }), - TimeProvider, - GetService>()); - } - - private static DefaultHttpContext CreateContext( - string userId = "user-1", - string clientId = "test-client", - string resource = "http://localhost/mcp", - bool includeMcpReadRole = true) - { - var claims = new List - { - new(ClaimTypes.NameIdentifier, userId), - new(IdentityUtils.OAuthClientIdClaim, clientId), - new(IdentityUtils.OAuthResourceClaim, resource) - }; - - if (includeMcpReadRole) - claims.Add(new Claim(ClaimTypes.Role, AuthorizationRoles.McpRead)); - - var context = new DefaultHttpContext - { - User = new ClaimsPrincipal(new ClaimsIdentity(claims, IdentityUtils.TokenAuthenticationType)) - }; - context.Request.Scheme = "http"; - context.Request.Host = new HostString("localhost"); - context.Request.Path = "/mcp"; - return context; - } - - private static InitializeRequestParams CreateInitializeParams() - { - return new InitializeRequestParams - { - ProtocolVersion = "2025-06-18", - Capabilities = new ClientCapabilities(), - ClientInfo = new Implementation - { - Name = "codex", - Version = "1.0.0" - } - }; - } -} diff --git a/tests/http/mcp.http b/tests/http/mcp.http index e7afa202be..67165b7702 100644 --- a/tests/http/mcp.http +++ b/tests/http/mcp.http @@ -1,39 +1,27 @@ -@apiUrl = http://localhost:7110/api/v2 @rootUrl = http://localhost:7110 -@email = admin@exceptionless.test -@password = tester +# Obtain an OAuth access token with mcp:read from oauth.http. +@token = replace-with-oauth-access-token -### login to test account -# @name login -POST {{apiUrl}}/auth/login -Content-Type: application/json - -{ - "email": "{{email}}", - "password": "{{password}}" -} - -### - -@token = {{login.response.body.$.token}} - -### Initialize MCP session -# @name initialize +### Discover native MCP v2 capabilities POST {{rootUrl}}/mcp Authorization: Bearer {{token}} +MCP-Protocol-Version: 2026-07-28 +Mcp-Method: server/discover Content-Type: application/json Accept: application/json, text/event-stream { "jsonrpc": "2.0", "id": 1, - "method": "initialize", + "method": "server/discover", "params": { - "protocolVersion": "2025-06-18", - "capabilities": {}, - "clientInfo": { - "name": "exceptionless-http-sample", - "version": "1.0.0" + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": { + "name": "exceptionless-http-sample", + "version": "1.0.0" + } } } } @@ -41,6 +29,8 @@ Accept: application/json, text/event-stream ### List MCP tools POST {{rootUrl}}/mcp Authorization: Bearer {{token}} +MCP-Protocol-Version: 2026-07-28 +Mcp-Method: tools/list Content-Type: application/json Accept: application/json, text/event-stream @@ -48,13 +38,25 @@ Accept: application/json, text/event-stream "jsonrpc": "2.0", "id": 2, "method": "tools/list", - "params": {} + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": { + "name": "exceptionless-http-sample", + "version": "1.0.0" + } + } + } } ### Search stacks with cursor paging # Use the "after" cursor returned by this response with the same limit to fetch the next page. POST {{rootUrl}}/mcp Authorization: Bearer {{token}} +MCP-Protocol-Version: 2026-07-28 +Mcp-Method: tools/call +Mcp-Name: search_stacks Content-Type: application/json Accept: application/json, text/event-stream @@ -63,6 +65,14 @@ Accept: application/json, text/event-stream "id": 3, "method": "tools/call", "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": { + "name": "exceptionless-http-sample", + "version": "1.0.0" + } + }, "name": "search_stacks", "arguments": { "projectId": "replace-with-project-id", diff --git a/tests/http/oauth.http b/tests/http/oauth.http index 7f226dbf0a..da2ff5d370 100644 --- a/tests/http/oauth.http +++ b/tests/http/oauth.http @@ -162,17 +162,52 @@ Content-Type: application/x-www-form-urlencoded client_id={{clientId}}&token=replace-with-access-or-refresh-token -### MCP with OAuth Bearer Token +### Discover native MCP v2 capabilities with OAuth Bearer Token POST {{rootUrl}}/mcp Authorization: Bearer replace-with-oauth-access-token +MCP-Protocol-Version: 2026-07-28 +Mcp-Method: server/discover Content-Type: application/json Accept: application/json, text/event-stream { "jsonrpc": "2.0", "id": 1, + "method": "server/discover", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": { + "name": "exceptionless-oauth-http-sample", + "version": "1.0.0" + } + } + } +} + +### List MCP tools with OAuth Bearer Token +POST {{rootUrl}}/mcp +Authorization: Bearer replace-with-oauth-access-token +MCP-Protocol-Version: 2026-07-28 +Mcp-Method: tools/list +Content-Type: application/json +Accept: application/json, text/event-stream + +{ + "jsonrpc": "2.0", + "id": 2, "method": "tools/list", - "params": {} + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": { + "name": "exceptionless-oauth-http-sample", + "version": "1.0.0" + } + } + } } ### Delete OAuth Application