From 33ee94a190aaa8fa6d51d94a0e72a325f5f5cf66 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 29 Jul 2026 23:12:25 -0500 Subject: [PATCH 1/6] Upgrade MCP C# SDK to v2 --- .../Exceptionless.Web.csproj | 2 +- .../Mcp/McpSessionMigrationHandler.cs | 11 ++--- src/Exceptionless.Web/Program.cs | 4 +- .../Api/Endpoints/OAuthEndpointTests.cs | 43 +++++++++++++++++++ .../Mcp/McpSessionMigrationHandlerTests.cs | 3 -- tests/http/mcp.http | 35 ++++++++------- tests/http/oauth.http | 36 +++++++++++++++- 7 files changed, 102 insertions(+), 32 deletions(-) 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/McpSessionMigrationHandler.cs b/src/Exceptionless.Web/Mcp/McpSessionMigrationHandler.cs index 3f29cf28d9..c27dfcb0b0 100644 --- a/src/Exceptionless.Web/Mcp/McpSessionMigrationHandler.cs +++ b/src/Exceptionless.Web/Mcp/McpSessionMigrationHandler.cs @@ -3,7 +3,6 @@ using Exceptionless.Core.Extensions; using Foundatio.Caching; using Foundatio.Serializer; -using Microsoft.Extensions.Options; using ModelContextProtocol.AspNetCore; using ModelContextProtocol.Protocol; @@ -12,14 +11,14 @@ 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); + // Matches the SDK v2 legacy stateful transport's documented idle-session lifetime. + private static readonly TimeSpan SessionIdleTimeout = TimeSpan.FromHours(2); public async ValueTask OnSessionInitializedAsync(HttpContext context, string sessionId, InitializeRequestParams initializeParams, CancellationToken cancellationToken) { @@ -88,11 +87,7 @@ public async ValueTask OnSessionInitializedAsync(HttpContext context, string ses private TimeSpan GetCacheLifetime() { - TimeSpan idleTimeout = transportOptions.Value.IdleTimeout; - if (idleTimeout <= TimeSpan.Zero) - idleTimeout = DefaultIdleTimeout; - - return idleTimeout + LifetimeBuffer; + return SessionIdleTimeout + LifetimeBuffer; } private static bool Matches(McpSessionMigrationState state, McpSessionIdentity sessionIdentity) diff --git a/src/Exceptionless.Web/Program.cs b/src/Exceptionless.Web/Program.cs index d9c24c4d40..56b6996ff4 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; @@ -192,6 +192,8 @@ public static async Task Main(string[] args) builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddMcpServer() + // MCP context is session-scoped. SDK v2 clients automatically downgrade from + // the stateless 2026-07-28 protocol to the stateful 2025-11-25 protocol here. .WithHttpTransport(o => o.Stateless = false) .WithTools(); builder.Services.AddSingleton(_ => new ThrottlingOptions diff --git a/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs index 1258709ae4..5d31240848 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; @@ -795,6 +797,47 @@ public async Task OAuthBearer_RestApiResourceForMcp_ReturnsUnauthorized() Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } + [Fact] + public async Task OAuthBearer_McpV2Client_DowngradesAndCallsTool() + { + 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 + { + 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 context = await client.CallToolAsync("get_context", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + Assert.False(String.IsNullOrWhiteSpace(client.SessionId)); + Assert.Contains(tools, tool => String.Equals(tool.Name, "get_context", StringComparison.Ordinal)); + Assert.NotEqual(true, context.IsError); + Assert.NotNull(context.StructuredContent); + } + [Fact] public async Task OAuthBearer_RestApiResourceMissingScope_ReturnsForbidden() { diff --git a/tests/Exceptionless.Tests/Mcp/McpSessionMigrationHandlerTests.cs b/tests/Exceptionless.Tests/Mcp/McpSessionMigrationHandlerTests.cs index 576d36d60c..841b45a8e4 100644 --- a/tests/Exceptionless.Tests/Mcp/McpSessionMigrationHandlerTests.cs +++ b/tests/Exceptionless.Tests/Mcp/McpSessionMigrationHandlerTests.cs @@ -6,8 +6,6 @@ using Foundatio.Serializer; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using ModelContextProtocol.AspNetCore; using ModelContextProtocol.Protocol; using Xunit; @@ -85,7 +83,6 @@ private McpSessionMigrationHandler CreateHandler() return new McpSessionMigrationHandler( GetService(), GetService(), - Options.Create(new HttpServerTransportOptions { IdleTimeout = TimeSpan.FromMinutes(30) }), TimeProvider, GetService>()); } diff --git a/tests/http/mcp.http b/tests/http/mcp.http index e7afa202be..fcf62ff64e 100644 --- a/tests/http/mcp.http +++ b/tests/http/mcp.http @@ -1,21 +1,6 @@ -@apiUrl = http://localhost:7110/api/v2 @rootUrl = http://localhost:7110 -@email = admin@exceptionless.test -@password = tester - -### login to test account -# @name login -POST {{apiUrl}}/auth/login -Content-Type: application/json - -{ - "email": "{{email}}", - "password": "{{password}}" -} - -### - -@token = {{login.response.body.$.token}} +# Obtain an OAuth access token with mcp:read from oauth.http. +@token = replace-with-oauth-access-token ### Initialize MCP session # @name initialize @@ -29,7 +14,7 @@ Accept: application/json, text/event-stream "id": 1, "method": "initialize", "params": { - "protocolVersion": "2025-06-18", + "protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": { "name": "exceptionless-http-sample", @@ -38,9 +23,22 @@ Accept: application/json, text/event-stream } } +### Complete MCP session initialization +POST {{rootUrl}}/mcp +Authorization: Bearer {{token}} +MCP-Session-Id: {{initialize.response.headers.MCP-Session-Id}} +Content-Type: application/json +Accept: application/json, text/event-stream + +{ + "jsonrpc": "2.0", + "method": "notifications/initialized" +} + ### List MCP tools POST {{rootUrl}}/mcp Authorization: Bearer {{token}} +MCP-Session-Id: {{initialize.response.headers.MCP-Session-Id}} Content-Type: application/json Accept: application/json, text/event-stream @@ -55,6 +53,7 @@ Accept: application/json, text/event-stream # Use the "after" cursor returned by this response with the same limit to fetch the next page. POST {{rootUrl}}/mcp Authorization: Bearer {{token}} +MCP-Session-Id: {{initialize.response.headers.MCP-Session-Id}} Content-Type: application/json Accept: application/json, text/event-stream diff --git a/tests/http/oauth.http b/tests/http/oauth.http index 7f226dbf0a..f40766d777 100644 --- a/tests/http/oauth.http +++ b/tests/http/oauth.http @@ -162,7 +162,8 @@ Content-Type: application/x-www-form-urlencoded client_id={{clientId}}&token=replace-with-access-or-refresh-token -### MCP with OAuth Bearer Token +### Initialize MCP with OAuth Bearer Token +# @name oauthMcpInitialize POST {{rootUrl}}/mcp Authorization: Bearer replace-with-oauth-access-token Content-Type: application/json @@ -171,6 +172,39 @@ Accept: application/json, text/event-stream { "jsonrpc": "2.0", "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": { + "name": "exceptionless-oauth-http-sample", + "version": "1.0.0" + } + } +} + +### Complete MCP session initialization +POST {{rootUrl}}/mcp +Authorization: Bearer replace-with-oauth-access-token +MCP-Session-Id: {{oauthMcpInitialize.response.headers.MCP-Session-Id}} +Content-Type: application/json +Accept: application/json, text/event-stream + +{ + "jsonrpc": "2.0", + "method": "notifications/initialized" +} + +### List MCP tools with OAuth Bearer Token +POST {{rootUrl}}/mcp +Authorization: Bearer replace-with-oauth-access-token +MCP-Session-Id: {{oauthMcpInitialize.response.headers.MCP-Session-Id}} +Content-Type: application/json +Accept: application/json, text/event-stream + +{ + "jsonrpc": "2.0", + "id": 2, "method": "tools/list", "params": {} } From da4c8e1a4e246d205e71e76b4cbe5e3dc677a358 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 30 Jul 2026 05:40:52 -0500 Subject: [PATCH 2/6] Use native stateless MCP v2 transport --- .../Extensions/IdentityUtils.cs | 2 + .../Mcp/ExceptionlessMcpTools.cs | 10 +- .../Mcp/McpContextService.cs | 30 ++-- .../Mcp/McpSessionMigrationHandler.cs | 141 ------------------ src/Exceptionless.Web/Program.cs | 8 +- .../Api/Endpoints/OAuthEndpointTests.cs | 31 +++- .../Mcp/ExceptionlessMcpToolsTests.cs | 42 ++++-- .../Mcp/McpSessionMigrationHandlerTests.cs | 129 ---------------- tests/http/mcp.http | 53 ++++--- tests/http/oauth.http | 43 +++--- 10 files changed, 128 insertions(+), 361 deletions(-) delete mode 100644 src/Exceptionless.Web/Mcp/McpSessionMigrationHandler.cs delete mode 100644 tests/Exceptionless.Tests/Mcp/McpSessionMigrationHandlerTests.cs diff --git a/src/Exceptionless.Core/Extensions/IdentityUtils.cs b/src/Exceptionless.Core/Extensions/IdentityUtils.cs index 2d4274b1ad..be207f6bb8 100644 --- a/src/Exceptionless.Core/Extensions/IdentityUtils.cs +++ b/src/Exceptionless.Core/Extensions/IdentityUtils.cs @@ -14,6 +14,7 @@ public static class IdentityUtils public const string ProjectIdClaim = "ProjectId"; public const string DefaultProjectIdClaim = "DefaultProjectId"; public const string OAuthClientIdClaim = "OAuthClientId"; + public const string OAuthGrantIdClaim = "OAuthGrantId"; public const string OAuthResourceClaim = "OAuthResource"; public static ClaimsIdentity ToIdentity(this Token token) @@ -109,6 +110,7 @@ public static ClaimsIdentity ToIdentity(this User user, OAuthToken token, IReadO new(OrganizationIdsClaim, String.Join(",", organizationIds)), new(LoggedInUsersTokenId, token.Id), new(OAuthClientIdClaim, token.ClientId), + new(OAuthGrantIdClaim, token.GrantId), new(OAuthResourceClaim, token.Resource) }; diff --git a/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs index 66facf201e..5da658406a 100644 --- a/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs +++ b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs @@ -91,7 +91,7 @@ public ExceptionlessMcpTools( } [McpServerTool(Name = "get_context", ReadOnly = true, UseStructuredContent = true)] - [Description("Gets the active MCP organization and project context for this session.")] + [Description("Gets the active MCP organization and project context for the authenticated OAuth grant.")] public async Task> GetContextAsync() { try @@ -105,7 +105,7 @@ public async Task> GetContextAsync() } catch (Exception ex) when (IsLookupError(ex)) { - return McpResponse.Failed(ToLookupError("MCP context", "current session", ex)); + return McpResponse.Failed(ToLookupError("MCP context", "current authorization", ex)); } } @@ -126,7 +126,7 @@ 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.")] + [Description("Sets the active MCP organization for the authenticated OAuth grant 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) @@ -150,7 +150,7 @@ public async Task> SwitchOrganizationAsync( } [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.")] + [Description("Sets the active MCP project for the authenticated OAuth grant and switches the active organization to the project's organization.")] public async Task> SwitchProjectAsync( [Description("The Exceptionless project id to make active.")] string projectId) @@ -200,7 +200,7 @@ 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 ?? "current authorization", ex)); } } [McpServerTool(Name = "list_projects", ReadOnly = true, UseStructuredContent = true)] diff --git a/src/Exceptionless.Web/Mcp/McpContextService.cs b/src/Exceptionless.Web/Mcp/McpContextService.cs index 6064dbcd67..4c6b5359e7 100644 --- a/src/Exceptionless.Web/Mcp/McpContextService.cs +++ b/src/Exceptionless.Web/Mcp/McpContextService.cs @@ -6,8 +6,6 @@ using Foundatio.Caching; using Foundatio.Repositories; using Foundatio.Repositories.Options; -using Microsoft.Extensions.DependencyInjection; -using ModelContextProtocol; namespace Exceptionless.Web.Mcp; @@ -16,12 +14,10 @@ public sealed class McpContextService( ICacheClient cacheClient, IOrganizationRepository organizationRepository, IProjectRepository projectRepository, - IServiceProvider serviceProvider, TimeProvider timeProvider) { private const int CandidateLimit = 100; - private const string CacheKeyPrefix = "mcp:context:"; - private const string SessionHeaderName = "MCP-Session-Id"; + private const string CacheKeyPrefix = "mcp:context:grant:"; private static readonly TimeSpan ContextLifetime = TimeSpan.FromHours(12); private HttpRequest Request => httpContextAccessor.HttpContext?.Request @@ -36,8 +32,8 @@ public async Task GetContextAsync(bool requireProject = fa if (String.IsNullOrEmpty(cacheKey)) { return McpContextResolution.Failed(McpErrors.ContextRequired( - "A stable MCP session is required before project context can be stored.", - "session", + "An authenticated MCP OAuth grant is required before project context can be stored.", + "authorization", [], [])); } @@ -135,8 +131,8 @@ public async Task SwitchOrganizationAsync(string organizat if (String.IsNullOrEmpty(cacheKey)) { return McpContextResolution.Failed(McpErrors.ContextRequired( - "A stable MCP session is required before organization context can be stored.", - "session", + "An authenticated MCP OAuth grant is required before organization context can be stored.", + "authorization", [], [])); } @@ -159,8 +155,8 @@ public async Task SwitchProjectAsync(string projectId) if (String.IsNullOrEmpty(cacheKey)) { return McpContextResolution.Failed(McpErrors.ContextRequired( - "A stable MCP session is required before project context can be stored.", - "session", + "An authenticated MCP OAuth grant is required before project context can be stored.", + "authorization", [], [])); } @@ -355,22 +351,16 @@ private Task SaveContextAsync(string cacheKey, string? activeOrganizationId, str 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? grantId = User.GetClaimValue(IdentityUtils.OAuthGrantIdClaim); string? userId = User.GetClaimValue(ClaimTypes.NameIdentifier); - if (String.IsNullOrWhiteSpace(sessionId) || String.IsNullOrWhiteSpace(userId)) + if (String.IsNullOrWhiteSpace(grantId) || 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(), + grantId.ToSHA1(), ":", userId.ToSHA1(), ":", diff --git a/src/Exceptionless.Web/Mcp/McpSessionMigrationHandler.cs b/src/Exceptionless.Web/Mcp/McpSessionMigrationHandler.cs deleted file mode 100644 index c27dfcb0b0..0000000000 --- a/src/Exceptionless.Web/Mcp/McpSessionMigrationHandler.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System.Security.Claims; -using Exceptionless.Core.Authorization; -using Exceptionless.Core.Extensions; -using Foundatio.Caching; -using Foundatio.Serializer; -using ModelContextProtocol.AspNetCore; -using ModelContextProtocol.Protocol; - -namespace Exceptionless.Web.Mcp; - -public sealed class McpSessionMigrationHandler( - ICacheClient cacheClient, - ITextSerializer serializer, - TimeProvider timeProvider, - ILogger logger) : ISessionMigrationHandler -{ - private const string CacheKeyPrefix = "mcp:session:"; - private const string UserClientId = "user"; - private static readonly TimeSpan LifetimeBuffer = TimeSpan.FromMinutes(5); - // Matches the SDK v2 legacy stateful transport's documented idle-session lifetime. - private static readonly TimeSpan SessionIdleTimeout = 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() - { - return SessionIdleTimeout + 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 56b6996ff4..3781242746 100644 --- a/src/Exceptionless.Web/Program.cs +++ b/src/Exceptionless.Web/Program.cs @@ -190,11 +190,8 @@ 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() - // MCP context is session-scoped. SDK v2 clients automatically downgrade from - // the stateless 2026-07-28 protocol to the stateful 2025-11-25 protocol here. - .WithHttpTransport(o => o.Stateless = false) + .WithHttpTransport() .WithTools(); builder.Services.AddSingleton(_ => new ThrottlingOptions { @@ -353,6 +350,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 5d31240848..3aacbdcf56 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs @@ -334,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() { @@ -798,8 +812,9 @@ public async Task OAuthBearer_RestApiResourceForMcp_ReturnsUnauthorized() } [Fact] - public async Task OAuthBearer_McpV2Client_DowngradesAndCallsTool() + 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( @@ -817,6 +832,7 @@ public async Task OAuthBearer_McpV2Client_DowngradesAndCallsTool() var options = new McpClientOptions { + ProtocolVersion = nativeProtocolVersion, ClientInfo = new Implementation { Name = "exceptionless-mcp-v2-tests", @@ -829,13 +845,22 @@ public async Task OAuthBearer_McpV2Client_DowngradesAndCallsTool() options, cancellationToken: TestContext.Current.CancellationToken); var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var switchedContext = await client.CallToolAsync( + "switch_project", + new Dictionary + { + ["projectId"] = TestConstants.ProjectId + }, + cancellationToken: TestContext.Current.CancellationToken); var context = await client.CallToolAsync("get_context", cancellationToken: TestContext.Current.CancellationToken); - Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); - Assert.False(String.IsNullOrWhiteSpace(client.SessionId)); + Assert.Equal(nativeProtocolVersion, client.NegotiatedProtocolVersion); + Assert.Null(client.SessionId); Assert.Contains(tools, tool => String.Equals(tool.Name, "get_context", StringComparison.Ordinal)); + Assert.NotEqual(true, switchedContext.IsError); Assert.NotEqual(true, context.IsError); Assert.NotNull(context.StructuredContent); + Assert.Contains(TestConstants.ProjectId, context.StructuredContent.ToString(), StringComparison.Ordinal); } [Fact] diff --git a/tests/Exceptionless.Tests/Mcp/ExceptionlessMcpToolsTests.cs b/tests/Exceptionless.Tests/Mcp/ExceptionlessMcpToolsTests.cs index 16e4822d2f..e4041ba163 100644 --- a/tests/Exceptionless.Tests/Mcp/ExceptionlessMcpToolsTests.cs +++ b/tests/Exceptionless.Tests/Mcp/ExceptionlessMcpToolsTests.cs @@ -84,7 +84,7 @@ public async Task SwitchOrganizationAsync_FiltersProjectsToActiveOrganization() } [Fact] - public async Task GetContextAsync_IsIsolatedPerMcpSession() + public async Task GetContextAsync_IsIsolatedPerOAuthGrant() { var organizationIds = new[] { TestConstants.OrganizationId, SampleDataService.FREE_ORG_ID }; var toolsA = await CreateToolsForOrganizationsAsync(Guid.NewGuid().ToString("N"), organizationIds, AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead); @@ -99,19 +99,36 @@ public async Task GetContextAsync_IsIsolatedPerMcpSession() Assert.Equal("organization", contextB.Error?.Details?["selection"]); } + [Fact] + public async Task GetContextAsync_NewAccessTokenForSameOAuthGrant_PreservesContext() + { + string grantId = Guid.NewGuid().ToString("N"); + var organizationIds = new[] { TestConstants.OrganizationId, SampleDataService.FREE_ORG_ID }; + var toolsA = await CreateToolsForOrganizationsAsync(grantId, organizationIds, AuthorizationRoles.McpRead); + var toolsB = await CreateToolsForOrganizationsAsync(grantId, organizationIds, AuthorizationRoles.McpRead); + + var switchResult = await toolsA.SwitchOrganizationAsync(SampleDataService.FREE_ORG_ID); + var contextB = await toolsB.GetContextAsync(); + + Assert.True(switchResult.Ok); + Assert.True(contextB.Ok); + Assert.Equal(SampleDataService.FREE_ORG_ID, Data(contextB).ActiveOrganizationId); + Assert.Equal(SampleDataService.FREE_PROJECT_ID, Data(contextB).ActiveProjectId); + } + [Fact] public async Task GetContextAsync_StaleOrganizationContext_ReResolvesAccessibleOrganization() { - string sessionId = Guid.NewGuid().ToString("N"); + string grantId = Guid.NewGuid().ToString("N"); var toolsWithBothOrganizations = await CreateToolsForOrganizationsAsync( - sessionId, + grantId, [TestConstants.OrganizationId, SampleDataService.FREE_ORG_ID], AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead); await toolsWithBothOrganizations.SwitchOrganizationAsync(SampleDataService.FREE_ORG_ID); var toolsWithSingleOrganization = await CreateToolsForOrganizationsAsync( - sessionId, + grantId, [TestConstants.OrganizationId], AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead); @@ -1177,20 +1194,20 @@ private static async Task SelectTestProjectAsync(ExceptionlessMcpTools tools) 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 +1224,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,15 +1247,12 @@ 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); return Task.FromResult(new ExceptionlessMcpTools( diff --git a/tests/Exceptionless.Tests/Mcp/McpSessionMigrationHandlerTests.cs b/tests/Exceptionless.Tests/Mcp/McpSessionMigrationHandlerTests.cs deleted file mode 100644 index 841b45a8e4..0000000000 --- a/tests/Exceptionless.Tests/Mcp/McpSessionMigrationHandlerTests.cs +++ /dev/null @@ -1,129 +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 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(), - 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 fcf62ff64e..cfa75b7103 100644 --- a/tests/http/mcp.http +++ b/tests/http/mcp.http @@ -2,43 +2,33 @@ # Obtain an OAuth access token with mcp:read from oauth.http. @token = replace-with-oauth-access-token -### Initialize MCP session -# @name initialize +### Discover native MCP v2 capabilities POST {{rootUrl}}/mcp Authorization: Bearer {{token}} +MCP-Protocol-Version: 2026-07-28 Content-Type: application/json Accept: application/json, text/event-stream { "jsonrpc": "2.0", "id": 1, - "method": "initialize", + "method": "server/discover", "params": { - "protocolVersion": "2025-11-25", - "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" + } } } } -### Complete MCP session initialization -POST {{rootUrl}}/mcp -Authorization: Bearer {{token}} -MCP-Session-Id: {{initialize.response.headers.MCP-Session-Id}} -Content-Type: application/json -Accept: application/json, text/event-stream - -{ - "jsonrpc": "2.0", - "method": "notifications/initialized" -} - ### List MCP tools POST {{rootUrl}}/mcp Authorization: Bearer {{token}} -MCP-Session-Id: {{initialize.response.headers.MCP-Session-Id}} +MCP-Protocol-Version: 2026-07-28 Content-Type: application/json Accept: application/json, text/event-stream @@ -46,14 +36,23 @@ 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-Session-Id: {{initialize.response.headers.MCP-Session-Id}} +MCP-Protocol-Version: 2026-07-28 Content-Type: application/json Accept: application/json, text/event-stream @@ -62,6 +61,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 f40766d777..85d06e6c1b 100644 --- a/tests/http/oauth.http +++ b/tests/http/oauth.http @@ -162,43 +162,33 @@ Content-Type: application/x-www-form-urlencoded client_id={{clientId}}&token=replace-with-access-or-refresh-token -### Initialize MCP with OAuth Bearer Token -# @name oauthMcpInitialize +### 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 Content-Type: application/json Accept: application/json, text/event-stream { "jsonrpc": "2.0", "id": 1, - "method": "initialize", + "method": "server/discover", "params": { - "protocolVersion": "2025-11-25", - "capabilities": {}, - "clientInfo": { - "name": "exceptionless-oauth-http-sample", - "version": "1.0.0" + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": { + "name": "exceptionless-oauth-http-sample", + "version": "1.0.0" + } } } } -### Complete MCP session initialization -POST {{rootUrl}}/mcp -Authorization: Bearer replace-with-oauth-access-token -MCP-Session-Id: {{oauthMcpInitialize.response.headers.MCP-Session-Id}} -Content-Type: application/json -Accept: application/json, text/event-stream - -{ - "jsonrpc": "2.0", - "method": "notifications/initialized" -} - ### List MCP tools with OAuth Bearer Token POST {{rootUrl}}/mcp Authorization: Bearer replace-with-oauth-access-token -MCP-Session-Id: {{oauthMcpInitialize.response.headers.MCP-Session-Id}} +MCP-Protocol-Version: 2026-07-28 Content-Type: application/json Accept: application/json, text/event-stream @@ -206,7 +196,16 @@ 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 From a90ffccce2d5a8bc1dc11fa01ac64d469ec84fad Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 30 Jul 2026 12:05:53 -0500 Subject: [PATCH 3/6] Fix MCP v2 context isolation and compatibility --- .../Extensions/IdentityUtils.cs | 2 - .../Mcp/ExceptionlessMcpTools.cs | 164 ++++++--- .../Mcp/McpContextService.cs | 341 +++++------------- src/Exceptionless.Web/Program.cs | 1 + .../Api/Endpoints/OAuthEndpointTests.cs | 50 ++- .../Mcp/ExceptionlessMcpToolsTests.cs | 205 +++++------ tests/http/mcp.http | 4 + tests/http/oauth.http | 2 + 8 files changed, 359 insertions(+), 410 deletions(-) diff --git a/src/Exceptionless.Core/Extensions/IdentityUtils.cs b/src/Exceptionless.Core/Extensions/IdentityUtils.cs index be207f6bb8..2d4274b1ad 100644 --- a/src/Exceptionless.Core/Extensions/IdentityUtils.cs +++ b/src/Exceptionless.Core/Extensions/IdentityUtils.cs @@ -14,7 +14,6 @@ public static class IdentityUtils public const string ProjectIdClaim = "ProjectId"; public const string DefaultProjectIdClaim = "DefaultProjectId"; public const string OAuthClientIdClaim = "OAuthClientId"; - public const string OAuthGrantIdClaim = "OAuthGrantId"; public const string OAuthResourceClaim = "OAuthResource"; public static ClaimsIdentity ToIdentity(this Token token) @@ -110,7 +109,6 @@ public static ClaimsIdentity ToIdentity(this User user, OAuthToken token, IReadO new(OrganizationIdsClaim, String.Join(",", organizationIds)), new(LoggedInUsersTokenId, token.Id), new(OAuthClientIdClaim, token.ClientId), - new(OAuthGrantIdClaim, token.GrantId), new(OAuthResourceClaim, token.Resource) }; diff --git a/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs index 5da658406a..337041e1df 100644 --- a/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs +++ b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs @@ -91,13 +91,23 @@ public ExceptionlessMcpTools( } [McpServerTool(Name = "get_context", ReadOnly = true, UseStructuredContent = true)] - [Description("Gets the active MCP organization and project context for the authenticated OAuth grant.")] - public async Task> GetContextAsync() + [Description("Resolves an Exceptionless organization and project context for this call. Context is not stored; pass the returned ids to subsequent scoped tools.")] + public async Task> GetContextAsync( + [Description("Optional organization id to resolve.")] + string? organizationId = null, + [Description("Optional project id to resolve. Its organization is resolved automatically.")] + string? projectId = null) { try { EnsureScope(AuthorizationRoles.McpRead); - var context = await _mcpContextService.GetContextAsync(requireProject: false); + if (!String.IsNullOrWhiteSpace(organizationId) && !TryValidateId(organizationId, "organizationId", out var organizationIdError)) + return McpResponse.Failed(organizationIdError); + + if (!String.IsNullOrWhiteSpace(projectId) && !TryValidateId(projectId, "projectId", out var projectIdError)) + return McpResponse.Failed(projectIdError); + + var context = await _mcpContextService.GetContextAsync(organizationId, projectId, requireProject: false); if (!context.Succeeded) return McpResponse.Failed(context.Error!); @@ -105,7 +115,7 @@ public async Task> GetContextAsync() } catch (Exception ex) when (IsLookupError(ex)) { - return McpResponse.Failed(ToLookupError("MCP context", "current authorization", ex)); + return McpResponse.Failed(ToLookupError("MCP context", projectId ?? organizationId ?? "current authorization", ex)); } } @@ -125,10 +135,10 @@ public async Task>> ListOrganizat } } - [McpServerTool(Name = "switch_organization", ReadOnly = false, UseStructuredContent = true)] - [Description("Sets the active MCP organization for the authenticated OAuth grant and clears any active project unless the organization has exactly one project.")] + [McpServerTool(Name = "switch_organization", ReadOnly = true, UseStructuredContent = true)] + [Description("Resolves an organization context for this call. The selection is not stored; pass organizationId to subsequent organization-scoped tools.")] public async Task> SwitchOrganizationAsync( - [Description("The Exceptionless organization id to make active.")] + [Description("The Exceptionless organization id to resolve.")] string organizationId) { try @@ -149,10 +159,10 @@ public async Task> SwitchOrganizationAsync( } } - [McpServerTool(Name = "switch_project", ReadOnly = false, UseStructuredContent = true)] - [Description("Sets the active MCP project for the authenticated OAuth grant and switches the active organization to the project's organization.")] + [McpServerTool(Name = "switch_project", ReadOnly = true, UseStructuredContent = true)] + [Description("Resolves a project and its organization for this call. The selection is not stored; pass projectId to subsequent project-scoped tools.")] public async Task> SwitchProjectAsync( - [Description("The Exceptionless project id to make active.")] + [Description("The Exceptionless project id to resolve.")] string projectId) { try @@ -173,12 +183,12 @@ public async Task> SwitchProjectAsync( } } - [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.")] + [McpServerTool(Name = "resolve_project_context", ReadOnly = true, UseStructuredContent = true)] + [Description("Resolves a project context by project id or exact project name. The selection is not stored; pass the returned projectId to subsequent project-scoped tools.")] public async Task> ResolveProjectContextAsync( - [Description("Optional Exceptionless project id to make active.")] + [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.")] string? organizationId = null) @@ -200,12 +210,15 @@ public async Task> ResolveProjectContextAsync( } catch (Exception ex) when (IsLookupError(ex)) { - return McpResponse.Failed(ToLookupError("Project", projectId ?? projectName ?? "current authorization", 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 in an explicitly specified organization. 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("The Exceptionless organization id to list projects within.")] + string organizationId, [Description(ProjectFilterDescription)] string? filter = null, [Description("Optional sort expression. Defaults to project name.")] @@ -220,6 +233,9 @@ public async Task>> ListProjectsAsync( try { EnsureScope(AuthorizationRoles.ProjectsRead); + if (!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 +245,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 @@ -260,13 +276,13 @@ public async Task>> ListProjectsAsync( [McpServerTool(Name = "get_project", ReadOnly = true, UseStructuredContent = true)] [Description("Gets summary details for a specific Exceptionless project.")] public async Task> GetProjectAsync( - [Description("Optional Exceptionless project id. Defaults to the active MCP project context.")] - string? projectId = null) + [Description("The Exceptionless project id.")] + string projectId) { try { EnsureScope(AuthorizationRoles.ProjectsRead); - if (!String.IsNullOrWhiteSpace(projectId) && !TryValidateId(projectId, "projectId", out var idError)) + if (!TryValidateId(projectId, "projectId", out var idError)) return McpResponse.Failed(idError); var projectContext = await _mcpContextService.ResolveProjectAsync(projectId); @@ -277,22 +293,22 @@ 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, 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.")] - string? projectId = null, + [Description("The Exceptionless project id to configure.")] + string projectId, [Description("Client platform to configure. Supported values: expo, react-native. Use expo for Expo apps.")] string platform = "expo") { try { EnsureScope(AuthorizationRoles.ProjectsRead); - if (!String.IsNullOrWhiteSpace(projectId) && !TryValidateId(projectId, "projectId", out var idError)) + if (!TryValidateId(projectId, "projectId", out var idError)) return McpResponse.Failed(idError); string normalizedPlatform = platform.Trim().ToLowerInvariant(); @@ -315,7 +331,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 +370,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, ex)); } catch (Exception ex) when (IsExpectedToolError(ex)) { @@ -365,8 +381,8 @@ public async Task> GetClientSetupI [McpServerTool(Name = "search_stacks", ReadOnly = true, UseStructuredContent = true)] [Description("Searches stacks in an Exceptionless project, useful for top issues, top 404s, or recent problem groups. When pagination.hasMore is true, pass pagination.after to fetch the next page or pagination.before to fetch the previous page.")] public async Task>> SearchStacksAsync( - [Description("Optional Exceptionless project id to search within. Defaults to the active MCP project context.")] - string? projectId = null, + [Description("The Exceptionless project id to search within.")] + string projectId, [Description(StackFilterDescription)] string? filter = null, [Description("Optional sort expression. Defaults to -last_occurrence.")] @@ -387,7 +403,7 @@ public async Task>> SearchStacksAsync( try { EnsureScope(AuthorizationRoles.StacksRead); - if (!String.IsNullOrWhiteSpace(projectId) && !TryValidateId(projectId, "projectId", out var idError)) + if (!TryValidateId(projectId, "projectId", out var idError)) return McpResponse>.Failed(idError); var validation = await ValidateSearchAsync(filter, sort, limit, StackFilterFields, StackSortFields, _stackQueryValidator); @@ -419,7 +435,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, ex)); } catch (Exception ex) when (IsExpectedToolError(ex)) { @@ -431,7 +447,9 @@ public async Task>> SearchStacksAsync( [Description("Gets summary details for a specific Exceptionless stack.")] public async Task> GetStackAsync( [Description("The Exceptionless stack id.")] - string stackId) + string stackId, + [Description("The Exceptionless project id that owns the stack.")] + string projectId) { try { @@ -439,7 +457,10 @@ public async Task> GetStackAsync( if (!TryValidateId(stackId, "stackId", out var idError)) return McpResponse.Failed(idError); - var stack = await GetAccessibleStackAsync(stackId); + if (!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 +474,8 @@ public async Task> GetStackAsync( public async Task>> GetStackEventsAsync( [Description("The Exceptionless stack id.")] string stackId, + [Description("The Exceptionless project id that owns the stack.")] + string projectId, [Description(EventFilterDescription)] string? filter = null, [Description("Optional sort expression. Defaults to -date.")] @@ -476,6 +499,9 @@ public async Task>> GetStackEventsAsync( if (!TryValidateId(stackId, "stackId", out var idError)) return McpResponse>.Failed(idError); + if (!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 +512,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,8 +537,8 @@ public async Task>> GetStackEventsAsync( [McpServerTool(Name = "search_events", ReadOnly = true, UseStructuredContent = true)] [Description("Searches event summary rows in an Exceptionless project. Use this for event-first triage across correlation ids, order ids, users, sessions, recent windows, or data.* fields. When pagination.hasMore is true, pass pagination.after or pagination.before to page.")] public async Task>> SearchEventsAsync( - [Description("Optional Exceptionless project id to search within. Defaults to the active MCP project context.")] - string? projectId = null, + [Description("The Exceptionless project id to search within.")] + string projectId, [Description(EventFilterDescription)] string? filter = null, [Description("Optional sort expression. Defaults to -date.")] @@ -533,7 +559,7 @@ public async Task>> SearchEventsAsync( try { EnsureScope(AuthorizationRoles.EventsRead); - if (!String.IsNullOrWhiteSpace(projectId) && !TryValidateId(projectId, "projectId", out var idError)) + if (!TryValidateId(projectId, "projectId", out var idError)) return McpResponse>.Failed(idError); var validation = await ValidateSearchAsync(filter, sort, limit, EventFilterFields, EventSortFields, _eventQueryValidator); @@ -565,7 +591,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, ex)); } catch (Exception ex) when (IsExpectedToolError(ex)) { @@ -578,6 +604,8 @@ public async Task>> SearchEventsAsync( public async Task> GetEventAsync( [Description("The Exceptionless event id.")] string eventId, + [Description("The Exceptionless project id that owns the event.")] + string projectId, [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 +617,9 @@ public async Task> GetEventAsync( if (!TryValidateId(eventId, "eventId", out var idError)) return McpResponse.Failed(idError); + if (!TryValidateId(projectId, "projectId", out var projectIdError)) + return McpResponse.Failed(projectIdError); + if (includeDetails && !TryValidateDetailSize(maxDetailSize, out var detailSizeError)) return McpResponse.Failed(detailSizeError); @@ -597,7 +628,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,8 +643,8 @@ public async Task> GetEventAsync( [McpServerTool(Name = "count_events", ReadOnly = true, UseStructuredContent = true)] [Description("Counts Exceptionless events and occurrences in a project, with optional time buckets and groupBy dimensions for questions like occurrences by version, tag, user, or error type.")] public async Task> CountEventsAsync( - [Description("Optional Exceptionless project id to count within. Defaults to the active MCP project context.")] - string? projectId = null, + [Description("The Exceptionless project id to count within.")] + string projectId, [Description(EventFilterDescription)] string? filter = null, [Description(LastDescription)] @@ -632,7 +663,7 @@ public async Task> CountEventsAsync( try { EnsureScope(AuthorizationRoles.EventsRead); - if (!String.IsNullOrWhiteSpace(projectId) && !TryValidateId(projectId, "projectId", out var idError)) + if (!TryValidateId(projectId, "projectId", out var idError)) return McpResponse.Failed(idError); var validation = await ValidateSearchAsync(filter, sort: null, DefaultLimit, EventFilterFields, EventSortFields, _eventQueryValidator); @@ -719,7 +750,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, ex)); } catch (Exception ex) when (IsExpectedToolError(ex)) { @@ -732,6 +763,8 @@ public async Task> CountEventsAsync( public async Task> UpdateStackStatusAsync( [Description("The Exceptionless stack id.")] string stackId, + [Description("The Exceptionless project id that owns the stack.")] + string projectId, [Description("Target status: open, fixed, ignored, or discarded. Regressed and snoozed cannot be set directly.")] string status, [Description("Optional semantic version for fixed status, such as 1.0.2. Only allowed when status is fixed.")] @@ -743,13 +776,16 @@ public async Task> UpdateStackStatusAsync( if (!TryValidateId(stackId, "stackId", out var idError)) return McpResponse.Failed(idError); + if (!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 +825,8 @@ public async Task> UpdateStackStatusAsync( public async Task> SnoozeStackAsync( [Description("The Exceptionless stack id.")] string stackId, + [Description("The Exceptionless project id that owns the stack.")] + string projectId, [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 +838,13 @@ public async Task> SnoozeStackAsync( if (!TryValidateId(stackId, "stackId", out var idError)) return McpResponse.Failed(idError); + if (!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; @@ -831,6 +872,8 @@ public async Task> SnoozeStackAsync( public async Task> SetStackCriticalAsync( [Description("The Exceptionless stack id.")] string stackId, + [Description("The Exceptionless project id that owns the stack.")] + string projectId, [Description("True marks future events for this stack as critical; false clears that behavior.")] bool critical) { @@ -840,7 +883,10 @@ public async Task> SetStackCriticalAsync( if (!TryValidateId(stackId, "stackId", out var idError)) return McpResponse.Failed(idError); - var stack = await GetAccessibleStackForWriteAsync(stackId); + if (!TryValidateId(projectId, "projectId", out var projectIdError)) + return McpResponse.Failed(projectIdError); + + var stack = await GetAccessibleStackForWriteAsync(stackId, projectId); bool changed = stack.OccurrencesAreCritical != critical; stack.OccurrencesAreCritical = critical; @@ -867,6 +913,8 @@ public async Task> SetStackCriticalAsync( public async Task> AddStackReferenceLinkAsync( [Description("The Exceptionless stack id.")] string stackId, + [Description("The Exceptionless project id that owns the stack.")] + string projectId, [Description("The reference link to add to the stack, such as an issue, pull request, deployment, or incident URL.")] string url) { @@ -876,10 +924,13 @@ public async Task> AddStackReferenceLinkAsync( if (!TryValidateId(stackId, "stackId", out var idError)) return McpResponse.Failed(idError); + if (!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) { @@ -909,6 +960,8 @@ public async Task> AddStackReferenceLinkAsync( public async Task> RemoveStackReferenceLinkAsync( [Description("The Exceptionless stack id.")] string stackId, + [Description("The Exceptionless project id that owns the stack.")] + string projectId, [Description("The reference link to remove from the stack.")] string url) { @@ -918,11 +971,14 @@ public async Task> RemoveStackReferenceLinkAsy if (!TryValidateId(stackId, "stackId", out var idError)) return McpResponse.Failed(idError); + if (!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 +1066,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 +1076,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 +1093,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 4c6b5359e7..21d122e516 100644 --- a/src/Exceptionless.Web/Mcp/McpContextService.cs +++ b/src/Exceptionless.Web/Mcp/McpContextService.cs @@ -1,9 +1,6 @@ -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; @@ -11,194 +8,134 @@ namespace Exceptionless.Web.Mcp; public sealed class McpContextService( IHttpContextAccessor httpContextAccessor, - ICacheClient cacheClient, IOrganizationRepository organizationRepository, - IProjectRepository projectRepository, - TimeProvider timeProvider) + IProjectRepository projectRepository) { private const int CandidateLimit = 100; - private const string CacheKeyPrefix = "mcp:context:grant:"; - 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( - "An authenticated MCP OAuth grant is required before project context can be stored.", - "authorization", - [], - [])); - } - 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); + Organization? activeOrganization = null; + Project? activeProject = null; - bool changed = false; - string? activeOrganizationId = storedContext?.ActiveOrganizationId; - string? activeProjectId = storedContext?.ActiveProjectId; - - var accessibleOrganization = accessibleOrganizations.FirstOrDefault(o => String.Equals(o.Id, activeOrganizationId, StringComparison.Ordinal)); - if (!String.IsNullOrEmpty(activeOrganizationId) && accessibleOrganization is null) + if (!String.IsNullOrWhiteSpace(projectId)) { - 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 (!String.IsNullOrWhiteSpace(organizationId) && !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 (!String.IsNullOrWhiteSpace(organizationId)) + { + 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); + return McpContextResolution.Success( + ToContextResult(null, null, accessibleOrganizations, []), + null, + null); } - public async Task SwitchOrganizationAsync(string organizationId) + public Task SwitchOrganizationAsync(string organizationId) { - string? cacheKey = GetCacheKey(); - if (String.IsNullOrEmpty(cacheKey)) - { - return McpContextResolution.Failed(McpErrors.ContextRequired( - "An authenticated MCP OAuth grant is required before organization context can be stored.", - "authorization", - [], - [])); - } - - 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); + return GetContextAsync(organizationId: organizationId); } - public async Task SwitchProjectAsync(string projectId) + public Task SwitchProjectAsync(string projectId) { - string? cacheKey = GetCacheKey(); - if (String.IsNullOrEmpty(cacheKey)) - { - return McpContextResolution.Failed(McpErrors.ContextRequired( - "An authenticated MCP OAuth grant is required before project context can be stored.", - "authorization", - [], - [])); - } - - 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 GetContextAsync(projectId: projectId, requireProject: true); } - public async Task ResolveProjectContextAsync(string? projectId = null, string? projectName = null, string? organizationId = null) + public async Task ResolveProjectContextAsync( + string? projectId = null, + string? projectName = null, + string? organizationId = null) { if (!String.IsNullOrWhiteSpace(projectId)) - return await SwitchProjectAsync(projectId.Trim()); + return await GetContextAsync(organizationId, projectId.Trim(), requireProject: true); if (String.IsNullOrWhiteSpace(projectName)) - return await GetContextAsync(requireProject: true); + return await GetContextAsync(organizationId: organizationId, requireProject: true); - McpContextResolution context; - if (!String.IsNullOrWhiteSpace(organizationId)) - { - context = await SwitchOrganizationAsync(organizationId.Trim()); - if (!context.Succeeded) - return context; - } - else - { - context = await GetContextAsync(requireProject: false); - if (!context.Succeeded) - return context; - } - - if (context.ActiveOrganization is null) + var context = await GetContextAsync(organizationId: organizationId, requireProject: false); + if (!context.Succeeded || context.ActiveOrganization is null) return context; var projects = await GetOrganizationProjectsAsync(context.ActiveOrganization.Id); @@ -207,95 +144,48 @@ 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); } - public async Task ResolveProjectAsync(string? projectId) + public async Task ResolveProjectAsync(string projectId) { if (String.IsNullOrWhiteSpace(projectId)) - { - var resolvedContext = await GetContextAsync(requireProject: true); - return resolvedContext.Succeeded && resolvedContext.ActiveProject is not null && resolvedContext.ActiveOrganization is not null - ? McpProjectContextResolution.Success(resolvedContext.ActiveProject, resolvedContext.ActiveOrganization, resolvedContext.Context) - : McpProjectContextResolution.Failed(resolvedContext.Error!, resolvedContext.Context); - } - - var projectAccess = await GetAccessibleProjectAsync(projectId.Trim()); - if (projectAccess.Error is not null) - { - return McpProjectContextResolution.Failed(projectAccess.Error); - } - - var project = projectAccess.Project!; - var context = await GetContextAsync(requireProject: false); - if (!context.Succeeded) - return McpProjectContextResolution.Failed(context.Error!, context.Context); - - if (context.ActiveOrganization is null) - { - return McpProjectContextResolution.Failed(McpErrors.ContextRequired( - "Select an active organization before using project-scoped MCP tools.", - "organization", - context.Context.Organizations, - context.Context.Projects), context.Context); - } - - if (!String.Equals(project.OrganizationId, context.ActiveOrganization.Id, StringComparison.Ordinal)) - { - return McpProjectContextResolution.Failed(McpErrors.ContextMismatch( - "The requested project is not in the active organization.", - context.ActiveOrganization.Id, - project.OrganizationId, - context.ActiveProject?.Id, - project.Id), context.Context); - } - - if (context.ActiveProject is not null && !String.Equals(project.Id, context.ActiveProject.Id, StringComparison.Ordinal)) - { - return McpProjectContextResolution.Failed(McpErrors.ContextMismatch( - "The requested project does not match the active project.", - context.ActiveOrganization.Id, - project.OrganizationId, - context.ActiveProject.Id, - project.Id), context.Context); - } - - if (context.ActiveProject is null) - context = await SwitchProjectAsync(project.Id); + return McpProjectContextResolution.Failed(McpErrors.InvalidId("projectId is required.", "projectId", projectId)); - return context.Succeeded && context.ActiveOrganization is not null - ? McpProjectContextResolution.Success(project, context.ActiveOrganization, context.Context) + var context = await GetContextAsync(projectId: projectId.Trim(), requireProject: true); + return context.Succeeded && context.ActiveProject is not null && context.ActiveOrganization is not null + ? McpProjectContextResolution.Success(context.ActiveProject, context.ActiveOrganization, context.Context) : McpProjectContextResolution.Failed(context.Error!, context.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 (context.ActiveOrganization is null || context.ActiveProject is null) - return McpErrors.ContextRequired("Select an active project before using this MCP tool.", "project", context.Context.Organizations, context.Context.Projects); - - if (!String.Equals(context.ActiveOrganization.Id, organizationId, StringComparison.Ordinal) || !String.Equals(context.ActiveProject.Id, projectId, StringComparison.Ordinal)) + var projectContext = await ResolveProjectAsync(requestedProjectId); + if (!projectContext.Succeeded) + return projectContext.Error; + + 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); } @@ -327,54 +217,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? grantId = User.GetClaimValue(IdentityUtils.OAuthGrantIdClaim); - string? userId = User.GetClaimValue(ClaimTypes.NameIdentifier); - if (String.IsNullOrWhiteSpace(grantId) || 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, - grantId.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, @@ -384,8 +236,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) @@ -419,8 +270,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/Program.cs b/src/Exceptionless.Web/Program.cs index 3781242746..6c4932a54e 100644 --- a/src/Exceptionless.Web/Program.cs +++ b/src/Exceptionless.Web/Program.cs @@ -190,6 +190,7 @@ public static async Task Main(string[] args) .MapStatus(ResultStatus.Unavailable, ApiResultMapper.MapUnavailable)); Bootstrapper.RegisterServices(builder.Services, options, Log.Logger.ToLoggerFactory()); builder.Services.AddScoped(); + // Leave the protocol version unset so native v2 and down-level MCP clients can negotiate a supported version. builder.Services.AddMcpServer() .WithHttpTransport() .WithTools(); diff --git a/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs index 3aacbdcf56..9d15f89700 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs @@ -852,7 +852,13 @@ public async Task OAuthBearer_McpV2Client_UsesNativeStatelessProtocolAndCallsToo ["projectId"] = TestConstants.ProjectId }, cancellationToken: TestContext.Current.CancellationToken); - var context = await client.CallToolAsync("get_context", cancellationToken: TestContext.Current.CancellationToken); + var context = await client.CallToolAsync( + "get_context", + new Dictionary + { + ["projectId"] = TestConstants.ProjectId + }, + cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(nativeProtocolVersion, client.NegotiatedProtocolVersion); Assert.Null(client.SessionId); @@ -863,6 +869,48 @@ public async Task OAuthBearer_McpV2Client_UsesNativeStatelessProtocolAndCallsToo Assert.Contains(TestConstants.ProjectId, context.StructuredContent.ToString(), StringComparison.Ordinal); } + [Fact] + public async Task OAuthBearer_McpDownLevelClient_NegotiatesAndCallsTool() + { + const string downLevelProtocolVersion = "2025-06-18"; + 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(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 e4041ba163..2fae390cc5 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; @@ -64,7 +63,7 @@ public async Task GetContextAsync_MultipleOrganizations_ReturnsContextRequired() } [Fact] - public async Task SwitchOrganizationAsync_FiltersProjectsToActiveOrganization() + public async Task SwitchOrganizationAsync_ResolvesRequestedOrganization() { var tools = await CreateToolsForOrganizationsAsync( Guid.NewGuid().ToString("N"), @@ -73,34 +72,33 @@ public async Task SwitchOrganizationAsync_FiltersProjectsToActiveOrganization() AuthorizationRoles.ProjectsRead); var context = await tools.SwitchOrganizationAsync(SampleDataService.FREE_ORG_ID); - var projects = await tools.ListProjectsAsync(limit: 50); + var projects = await tools.ListProjectsAsync(SampleDataService.FREE_ORG_ID, limit: 50); Assert.True(context.Ok); Assert.Equal(SampleDataService.FREE_ORG_ID, Data(context).ActiveOrganizationId); - Assert.Equal(SampleDataService.FREE_PROJECT_ID, Data(context).ActiveProjectId); + Assert.Null(Data(context).ActiveProjectId); Assert.True(projects.Ok); Assert.All(Items(projects), project => Assert.Equal(SampleDataService.FREE_ORG_ID, project.OrganizationId)); Assert.Contains(Items(projects), project => project.Id == SampleDataService.FREE_PROJECT_ID); } [Fact] - public async Task GetContextAsync_IsIsolatedPerOAuthGrant() + public async Task SwitchOrganizationAsync_DoesNotPersistSelectionAcrossCalls() { 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"), organizationIds, AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead); - var switchResult = await toolsA.SwitchOrganizationAsync(SampleDataService.FREE_ORG_ID); - var contextB = await toolsB.GetContextAsync(); + var switchResult = await tools.SwitchOrganizationAsync(SampleDataService.FREE_ORG_ID); + var nextContext = await tools.GetContextAsync(); Assert.True(switchResult.Ok); - Assert.False(contextB.Ok); - Assert.Equal(McpErrorCodes.ContextRequired, contextB.Error?.Code); - Assert.Equal("organization", contextB.Error?.Details?["selection"]); + Assert.False(nextContext.Ok); + Assert.Equal(McpErrorCodes.ContextRequired, nextContext.Error?.Code); + Assert.Equal("organization", nextContext.Error?.Details?["selection"]); } [Fact] - public async Task GetContextAsync_NewAccessTokenForSameOAuthGrant_PreservesContext() + public async Task GetContextAsync_NewAccessTokenForSameOAuthGrant_DoesNotShareSelection() { string grantId = Guid.NewGuid().ToString("N"); var organizationIds = new[] { TestConstants.OrganizationId, SampleDataService.FREE_ORG_ID }; @@ -111,81 +109,86 @@ public async Task GetContextAsync_NewAccessTokenForSameOAuthGrant_PreservesConte var contextB = await toolsB.GetContextAsync(); Assert.True(switchResult.Ok); - Assert.True(contextB.Ok); - Assert.Equal(SampleDataService.FREE_ORG_ID, Data(contextB).ActiveOrganizationId); - Assert.Equal(SampleDataService.FREE_PROJECT_ID, Data(contextB).ActiveProjectId); + Assert.False(contextB.Ok); + Assert.Equal(McpErrorCodes.ContextRequired, contextB.Error?.Code); + Assert.Equal("organization", contextB.Error?.Details?["selection"]); } [Fact] - public async Task GetContextAsync_StaleOrganizationContext_ReResolvesAccessibleOrganization() + public async Task GetContextAsync_ExplicitOrganization_ResolvesRequestedOrganization() { - string grantId = Guid.NewGuid().ToString("N"); - var toolsWithBothOrganizations = await CreateToolsForOrganizationsAsync( - grantId, + var tools = await CreateToolsForOrganizationsAsync( + Guid.NewGuid().ToString("N"), [TestConstants.OrganizationId, SampleDataService.FREE_ORG_ID], AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead); - await toolsWithBothOrganizations.SwitchOrganizationAsync(SampleDataService.FREE_ORG_ID); - - var toolsWithSingleOrganization = await CreateToolsForOrganizationsAsync( - grantId, - [TestConstants.OrganizationId], - AuthorizationRoles.McpRead, - AuthorizationRoles.ProjectsRead); - var context = await toolsWithSingleOrganization.GetContextAsync(); + var context = await tools.GetContextAsync(organizationId: SampleDataService.FREE_ORG_ID); Assert.True(context.Ok); - Assert.Equal(TestConstants.OrganizationId, Data(context).ActiveOrganizationId); + Assert.Equal(SampleDataService.FREE_ORG_ID, Data(context).ActiveOrganizationId); Assert.Null(Data(context).ActiveProjectId); } [Fact] - public async Task SearchStacksAsync_WithoutProjectId_UsesActiveProject() + public async Task ResolveProjectContextAsync_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.ResolveProjectContextAsync( + 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_WithoutProjectId_ReturnsInvalidId() { var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.StacksRead); - var result = await tools.SearchStacksAsync(); + var result = await tools.SearchStacksAsync(String.Empty); Assert.False(result.Ok); - Assert.Equal(McpErrorCodes.ContextRequired, result.Error?.Code); - Assert.Equal("project", result.Error?.Details?["selection"]); + Assert.Equal(McpErrorCodes.InvalidId, result.Error?.Code); + Assert.Equal("projectId", result.Error?.Details?["field"]); } [Fact] - public async Task SearchStacksAsync_ConflictingProjectId_ReturnsContextMismatch() + 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.SwitchProjectAsync(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); @@ -195,7 +198,7 @@ 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); @@ -270,9 +273,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); @@ -300,9 +301,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); @@ -582,7 +581,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); @@ -633,7 +632,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); @@ -645,7 +644,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); @@ -669,9 +668,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); @@ -688,7 +685,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); @@ -719,9 +716,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, TestConstants.ProjectId, "fixed", "1.0.2"); var data = Data(result); Assert.True(result.Ok); @@ -744,7 +739,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, TestConstants.ProjectId, "fixed"); Assert.False(result.Ok); Assert.Equal(McpErrorCodes.Forbidden, result.Error?.Code); @@ -758,7 +753,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, TestConstants.ProjectId, "fixed", fixedInVersion: "1.2.3"); Assert.False(result.Ok); Assert.Equal(McpErrorCodes.Forbidden, result.Error?.Code); @@ -772,7 +767,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, TestConstants.ProjectId, "snoozed"); Assert.False(result.Ok); Assert.Equal(McpErrorCodes.InvalidStatus, result.Error?.Code); @@ -788,9 +783,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); @@ -816,7 +809,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); @@ -829,9 +822,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, TestConstants.ProjectId, critical: true); var data = Data(result); Assert.True(result.Ok); @@ -850,9 +841,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, TestConstants.ProjectId, " https://github.com/exceptionless/Exceptionless/issues/123 "); var data = Data(result); Assert.True(result.Ok); @@ -872,9 +861,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, TestConstants.ProjectId, url); var data = Data(result); Assert.True(result.Ok); @@ -890,7 +877,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); @@ -906,7 +893,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, TestConstants.ProjectId, url); Assert.False(result.Ok); Assert.Equal(McpErrorCodes.InvalidReferenceUrl, result.Error?.Code); @@ -921,9 +908,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, TestConstants.ProjectId, url); var data = Data(result); Assert.True(result.Ok); @@ -943,9 +928,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, TestConstants.ProjectId, url); var data = Data(result); Assert.True(result.Ok); @@ -960,7 +943,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); @@ -974,9 +957,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); @@ -985,7 +966,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); @@ -1011,9 +992,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}.") }; @@ -1044,6 +1025,16 @@ public async Task ListProjectsAsync_OutputSchemaDoesNotRequireNullableFields() Assert.DoesNotContain("lastEventDateUtc", RequiredProperties(projectSchema)); } + [Fact] + public async Task ListProjectsAsync_OrganizationIdIsRequiredInSchema() + { + 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.Contains("organizationId", RequiredProperties(tool.ProtocolTool.InputSchema)); + } + [Theory] [InlineData(nameof(ExceptionlessMcpTools.ListProjectsAsync))] [InlineData(nameof(ExceptionlessMcpTools.SearchStacksAsync))] @@ -1136,9 +1127,17 @@ 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))] - public async Task ProjectScopedTools_ProjectIdIsOptionalInSchema(string methodName) + [InlineData(nameof(ExceptionlessMcpTools.UpdateStackStatusAsync))] + [InlineData(nameof(ExceptionlessMcpTools.SnoozeStackAsync))] + [InlineData(nameof(ExceptionlessMcpTools.SetStackCriticalAsync))] + [InlineData(nameof(ExceptionlessMcpTools.AddStackReferenceLinkAsync))] + [InlineData(nameof(ExceptionlessMcpTools.RemoveStackReferenceLinkAsync))] + public async Task ProjectScopedTools_ProjectIdIsRequiredInSchema(string methodName) { var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead, AuthorizationRoles.StacksRead, AuthorizationRoles.EventsRead); var method = typeof(ExceptionlessMcpTools).GetMethod(methodName) ?? throw new InvalidOperationException($"Could not find {methodName}."); @@ -1146,7 +1145,7 @@ public async Task ProjectScopedTools_ProjectIdIsOptionalInSchema(string methodNa var properties = tool.ProtocolTool.InputSchema.GetProperty("properties"); Assert.True(properties.TryGetProperty("projectId", out _), "The projectId input must be advertised in the MCP tool schema."); - Assert.DoesNotContain("projectId", RequiredProperties(tool.ProtocolTool.InputSchema)); + Assert.Contains("projectId", RequiredProperties(tool.ProtocolTool.InputSchema)); } [Theory] [InlineData(nameof(ExceptionlessMcpTools.UpdateStackStatusAsync))] @@ -1162,6 +1161,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): @@ -1185,13 +1185,6 @@ 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, grantId: Guid.NewGuid().ToString("N"), organizationIds: null, scopes); @@ -1250,10 +1243,8 @@ private Task CreateToolsAsync(bool includeUserRole, strin var accessor = new TestHttpContextAccessor { HttpContext = context }; var contextService = new McpContextService( accessor, - GetService(), _organizationRepository, - _projectRepository, - TimeProvider); + _projectRepository); return Task.FromResult(new ExceptionlessMcpTools( accessor, diff --git a/tests/http/mcp.http b/tests/http/mcp.http index cfa75b7103..67165b7702 100644 --- a/tests/http/mcp.http +++ b/tests/http/mcp.http @@ -6,6 +6,7 @@ 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 @@ -29,6 +30,7 @@ Accept: application/json, text/event-stream 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 @@ -53,6 +55,8 @@ Accept: application/json, text/event-stream 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 diff --git a/tests/http/oauth.http b/tests/http/oauth.http index 85d06e6c1b..da2ff5d370 100644 --- a/tests/http/oauth.http +++ b/tests/http/oauth.http @@ -166,6 +166,7 @@ client_id={{clientId}}&token=replace-with-access-or-refresh-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 @@ -189,6 +190,7 @@ Accept: application/json, text/event-stream 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 From c454db144b1314866a12672dccbb356fd7db592e Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 30 Jul 2026 12:55:54 -0500 Subject: [PATCH 4/6] Simplify stateless MCP project resolution --- .../Mcp/ExceptionlessMcpTools.cs | 4 +-- .../Mcp/McpContextService.cs | 35 +++++++++++-------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs index 337041e1df..bb984d44e4 100644 --- a/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs +++ b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs @@ -147,7 +147,7 @@ public async Task> SwitchOrganizationAsync( if (!TryValidateId(organizationId, "organizationId", out var idError)) return McpResponse.Failed(idError); - var context = await _mcpContextService.SwitchOrganizationAsync(organizationId); + var context = await _mcpContextService.GetContextAsync(organizationId: organizationId); if (!context.Succeeded) return McpResponse.Failed(context.Error!); @@ -171,7 +171,7 @@ public async Task> SwitchProjectAsync( if (!TryValidateId(projectId, "projectId", out var idError)) return McpResponse.Failed(idError); - var context = await _mcpContextService.SwitchProjectAsync(projectId); + var context = await _mcpContextService.GetContextAsync(projectId: projectId, requireProject: true); if (!context.Succeeded) return McpResponse.Failed(context.Error!); diff --git a/src/Exceptionless.Web/Mcp/McpContextService.cs b/src/Exceptionless.Web/Mcp/McpContextService.cs index 21d122e516..d82f2cdc3d 100644 --- a/src/Exceptionless.Web/Mcp/McpContextService.cs +++ b/src/Exceptionless.Web/Mcp/McpContextService.cs @@ -113,16 +113,6 @@ public async Task ListOrganizationsAsync() null); } - public Task SwitchOrganizationAsync(string organizationId) - { - return GetContextAsync(organizationId: organizationId); - } - - public Task SwitchProjectAsync(string projectId) - { - return GetContextAsync(projectId: projectId, requireProject: true); - } - public async Task ResolveProjectContextAsync( string? projectId = null, string? projectName = null, @@ -156,7 +146,7 @@ public async Task ResolveProjectContextAsync( 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) @@ -164,14 +154,29 @@ public async Task ResolveProjectAsync(string projec if (String.IsNullOrWhiteSpace(projectId)) return McpProjectContextResolution.Failed(McpErrors.InvalidId("projectId is required.", "projectId", projectId)); - var context = await GetContextAsync(projectId: projectId.Trim(), requireProject: true); - return context.Succeeded && context.ActiveProject is not null && context.ActiveOrganization is not null - ? McpProjectContextResolution.Success(context.ActiveProject, context.ActiveOrganization, context.Context) - : McpProjectContextResolution.Failed(context.Error!, context.Context); + var projectAccess = await GetAccessibleProjectAsync(projectId.Trim()); + if (!projectAccess.Succeeded) + return McpProjectContextResolution.Failed(projectAccess.Error!); + + var project = projectAccess.Project!; + var organization = await organizationRepository.GetByIdAsync(project.OrganizationId, o => o.Cache()); + if (organization is null) + { + return McpProjectContextResolution.Failed(McpErrors.NotAccessible( + $"Organization {project.OrganizationId} was not found or is not accessible.", + "organizationId", + project.OrganizationId)); + } + + var context = ToContextResult(organization, project, [organization], [project]); + return McpProjectContextResolution.Success(project, organization, context); } public async Task ValidateProjectScopeAsync(string organizationId, string projectId, string requestedProjectId) { + if (String.Equals(projectId, requestedProjectId, StringComparison.Ordinal)) + return null; + var projectContext = await ResolveProjectAsync(requestedProjectId); if (!projectContext.Succeeded) return projectContext.Error; From df073d4155da688d26d9b7e2050bc2853a816929 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 30 Jul 2026 14:39:16 -0500 Subject: [PATCH 5/6] Clarify stateless MCP tool contract --- .../Mcp/ExceptionlessMcpTools.cs | 85 +------------------ .../Mcp/McpContextService.cs | 2 +- src/Exceptionless.Web/Program.cs | 3 +- .../Api/Endpoints/OAuthEndpointTests.cs | 35 +++++--- .../Mcp/ExceptionlessMcpToolsTests.cs | 70 ++------------- 5 files changed, 37 insertions(+), 158 deletions(-) diff --git a/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs index bb984d44e4..2199886dda 100644 --- a/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs +++ b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs @@ -90,35 +90,6 @@ public ExceptionlessMcpTools( _mcpContextService = mcpContextService; } - [McpServerTool(Name = "get_context", ReadOnly = true, UseStructuredContent = true)] - [Description("Resolves an Exceptionless organization and project context for this call. Context is not stored; pass the returned ids to subsequent scoped tools.")] - public async Task> GetContextAsync( - [Description("Optional organization id to resolve.")] - string? organizationId = null, - [Description("Optional project id to resolve. Its organization is resolved automatically.")] - string? projectId = null) - { - try - { - EnsureScope(AuthorizationRoles.McpRead); - if (!String.IsNullOrWhiteSpace(organizationId) && !TryValidateId(organizationId, "organizationId", out var organizationIdError)) - return McpResponse.Failed(organizationIdError); - - if (!String.IsNullOrWhiteSpace(projectId) && !TryValidateId(projectId, "projectId", out var projectIdError)) - return McpResponse.Failed(projectIdError); - - var context = await _mcpContextService.GetContextAsync(organizationId, projectId, 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", projectId ?? organizationId ?? "current authorization", ex)); - } - } - [McpServerTool(Name = "list_organizations", ReadOnly = true, UseStructuredContent = true)] [Description("Lists organizations available to the current MCP OAuth grant.")] public async Task>> ListOrganizationsAsync() @@ -135,57 +106,9 @@ public async Task>> ListOrganizat } } - [McpServerTool(Name = "switch_organization", ReadOnly = true, UseStructuredContent = true)] - [Description("Resolves an organization context for this call. The selection is not stored; pass organizationId to subsequent organization-scoped tools.")] - public async Task> SwitchOrganizationAsync( - [Description("The Exceptionless organization id to resolve.")] - string organizationId) - { - try - { - EnsureScope(AuthorizationRoles.McpRead); - if (!TryValidateId(organizationId, "organizationId", out var idError)) - return McpResponse.Failed(idError); - - var context = await _mcpContextService.GetContextAsync(organizationId: 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 = true, UseStructuredContent = true)] - [Description("Resolves a project and its organization for this call. The selection is not stored; pass projectId to subsequent project-scoped tools.")] - public async Task> SwitchProjectAsync( - [Description("The Exceptionless project id to resolve.")] - string projectId) - { - try - { - EnsureScope(AuthorizationRoles.McpRead); - if (!TryValidateId(projectId, "projectId", out var idError)) - return McpResponse.Failed(idError); - - var context = await _mcpContextService.GetContextAsync(projectId: projectId, requireProject: true); - 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 = true, UseStructuredContent = true)] - [Description("Resolves a project context by project id or exact project name. The selection is not stored; pass the returned projectId to subsequent project-scoped tools.")] - public async Task> ResolveProjectContextAsync( + [McpServerTool(Name = "resolve_project", ReadOnly = true, UseStructuredContent = true)] + [Description("Resolves a project by id or exact name. Pass the returned projectId explicitly to subsequent project-scoped tools.")] + public async Task> ResolveProjectAsync( [Description("Optional Exceptionless project id to resolve.")] string? projectId = null, [Description("Optional exact project name to resolve within the specified organization.")] @@ -202,7 +125,7 @@ public async Task> ResolveProjectContextAsync( if (!String.IsNullOrWhiteSpace(organizationId) && !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!); diff --git a/src/Exceptionless.Web/Mcp/McpContextService.cs b/src/Exceptionless.Web/Mcp/McpContextService.cs index d82f2cdc3d..6d27436a22 100644 --- a/src/Exceptionless.Web/Mcp/McpContextService.cs +++ b/src/Exceptionless.Web/Mcp/McpContextService.cs @@ -113,7 +113,7 @@ public async Task ListOrganizationsAsync() null); } - public async Task ResolveProjectContextAsync( + public async Task ResolveProjectByIdOrNameAsync( string? projectId = null, string? projectName = null, string? organizationId = null) diff --git a/src/Exceptionless.Web/Program.cs b/src/Exceptionless.Web/Program.cs index 6c4932a54e..a49b3a7f28 100644 --- a/src/Exceptionless.Web/Program.cs +++ b/src/Exceptionless.Web/Program.cs @@ -191,7 +191,8 @@ public static async Task Main(string[] args) Bootstrapper.RegisterServices(builder.Services, options, Log.Logger.ToLoggerFactory()); builder.Services.AddScoped(); // Leave the protocol version unset so native v2 and down-level MCP clients can negotiate a supported version. - builder.Services.AddMcpServer() + builder.Services.AddMcpServer(options => + options.ServerInstructions = "Exceptionless MCP tools are stateless. Use list_organizations and list_projects to discover ids, or resolve_project to resolve a project by name, then pass organizationId and projectId explicitly to every scoped tool. Previous tool calls never change the scope of later calls.") .WithHttpTransport() .WithTools(); builder.Services.AddSingleton(_ => new ThrottlingOptions diff --git a/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs index 9d15f89700..00ca00733f 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs @@ -845,15 +845,15 @@ public async Task OAuthBearer_McpV2Client_UsesNativeStatelessProtocolAndCallsToo options, cancellationToken: TestContext.Current.CancellationToken); var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); - var switchedContext = await client.CallToolAsync( - "switch_project", + var resolvedProject = await client.CallToolAsync( + "resolve_project", new Dictionary { ["projectId"] = TestConstants.ProjectId }, cancellationToken: TestContext.Current.CancellationToken); - var context = await client.CallToolAsync( - "get_context", + var project = await client.CallToolAsync( + "get_project", new Dictionary { ["projectId"] = TestConstants.ProjectId @@ -862,17 +862,23 @@ public async Task OAuthBearer_McpV2Client_UsesNativeStatelessProtocolAndCallsToo Assert.Equal(nativeProtocolVersion, client.NegotiatedProtocolVersion); Assert.Null(client.SessionId); - Assert.Contains(tools, tool => String.Equals(tool.Name, "get_context", StringComparison.Ordinal)); - Assert.NotEqual(true, switchedContext.IsError); - Assert.NotEqual(true, context.IsError); - Assert.NotNull(context.StructuredContent); - Assert.Contains(TestConstants.ProjectId, context.StructuredContent.ToString(), StringComparison.Ordinal); - } - - [Fact] - public async Task OAuthBearer_McpDownLevelClient_NegotiatesAndCallsTool() + 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) { - const string downLevelProtocolVersion = "2025-06-18"; var token = await IssueTokenAsync(); using var httpClient = _server.CreateClient(); var transport = new HttpClientTransport( @@ -906,6 +912,7 @@ public async Task OAuthBearer_McpDownLevelClient_NegotiatesAndCallsTool() 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); diff --git a/tests/Exceptionless.Tests/Mcp/ExceptionlessMcpToolsTests.cs b/tests/Exceptionless.Tests/Mcp/ExceptionlessMcpToolsTests.cs index 2fae390cc5..8edde6551c 100644 --- a/tests/Exceptionless.Tests/Mcp/ExceptionlessMcpToolsTests.cs +++ b/tests/Exceptionless.Tests/Mcp/ExceptionlessMcpToolsTests.cs @@ -44,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"), @@ -52,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); @@ -63,7 +63,7 @@ public async Task GetContextAsync_MultipleOrganizations_ReturnsContextRequired() } [Fact] - public async Task SwitchOrganizationAsync_ResolvesRequestedOrganization() + public async Task ResolveProjectAsync_ProjectId_ReturnsRequestedProject() { var tools = await CreateToolsForOrganizationsAsync( Guid.NewGuid().ToString("N"), @@ -71,74 +71,22 @@ public async Task SwitchOrganizationAsync_ResolvesRequestedOrganization() AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead); - var context = await tools.SwitchOrganizationAsync(SampleDataService.FREE_ORG_ID); - var projects = await tools.ListProjectsAsync(SampleDataService.FREE_ORG_ID, limit: 50); + var context = await tools.ResolveProjectAsync(projectId: TestConstants.ProjectId); Assert.True(context.Ok); - Assert.Equal(SampleDataService.FREE_ORG_ID, Data(context).ActiveOrganizationId); - Assert.Null(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 SwitchOrganizationAsync_DoesNotPersistSelectionAcrossCalls() - { - var organizationIds = new[] { TestConstants.OrganizationId, SampleDataService.FREE_ORG_ID }; - var tools = await CreateToolsForOrganizationsAsync(Guid.NewGuid().ToString("N"), organizationIds, AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead); - - var switchResult = await tools.SwitchOrganizationAsync(SampleDataService.FREE_ORG_ID); - var nextContext = await tools.GetContextAsync(); - - Assert.True(switchResult.Ok); - Assert.False(nextContext.Ok); - Assert.Equal(McpErrorCodes.ContextRequired, nextContext.Error?.Code); - Assert.Equal("organization", nextContext.Error?.Details?["selection"]); - } - - [Fact] - public async Task GetContextAsync_NewAccessTokenForSameOAuthGrant_DoesNotShareSelection() - { - string grantId = Guid.NewGuid().ToString("N"); - var organizationIds = new[] { TestConstants.OrganizationId, SampleDataService.FREE_ORG_ID }; - var toolsA = await CreateToolsForOrganizationsAsync(grantId, organizationIds, AuthorizationRoles.McpRead); - var toolsB = await CreateToolsForOrganizationsAsync(grantId, organizationIds, AuthorizationRoles.McpRead); - - var switchResult = await toolsA.SwitchOrganizationAsync(SampleDataService.FREE_ORG_ID); - var contextB = await toolsB.GetContextAsync(); - - Assert.True(switchResult.Ok); - Assert.False(contextB.Ok); - Assert.Equal(McpErrorCodes.ContextRequired, contextB.Error?.Code); - Assert.Equal("organization", contextB.Error?.Details?["selection"]); - } - - [Fact] - public async Task GetContextAsync_ExplicitOrganization_ResolvesRequestedOrganization() - { - var tools = await CreateToolsForOrganizationsAsync( - Guid.NewGuid().ToString("N"), - [TestConstants.OrganizationId, SampleDataService.FREE_ORG_ID], - AuthorizationRoles.McpRead, - AuthorizationRoles.ProjectsRead); - - var context = await tools.GetContextAsync(organizationId: SampleDataService.FREE_ORG_ID); - - Assert.True(context.Ok); - Assert.Equal(SampleDataService.FREE_ORG_ID, Data(context).ActiveOrganizationId); - Assert.Null(Data(context).ActiveProjectId); - } - - [Fact] - public async Task ResolveProjectContextAsync_ProjectOutsideExplicitOrganization_ReturnsContextMismatch() + public async Task ResolveProjectAsync_ProjectOutsideExplicitOrganization_ReturnsContextMismatch() { var tools = await CreateToolsForOrganizationsAsync( Guid.NewGuid().ToString("N"), [TestConstants.OrganizationId, SampleDataService.FREE_ORG_ID], AuthorizationRoles.McpRead); - var context = await tools.ResolveProjectContextAsync( + var context = await tools.ResolveProjectAsync( projectId: TestConstants.ProjectId, organizationId: SampleDataService.FREE_ORG_ID); @@ -174,7 +122,7 @@ public async Task SearchStacksAsync_PriorProjectResolution_DoesNotConstrainExpli { 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(SampleDataService.TEST_ROCKET_SHIP_PROJECT_ID); + await tools.ResolveProjectAsync(projectId: SampleDataService.TEST_ROCKET_SHIP_PROJECT_ID); var result = await tools.SearchStacksAsync(TestConstants.ProjectId, limit: 50); From 0c67564d3c1fa973c488eca240d56b0752380e25 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 30 Jul 2026 15:25:07 -0500 Subject: [PATCH 6/6] Infer unambiguous MCP scope --- .../Mcp/ExceptionlessMcpTools.cs | 120 +++++++-------- .../Mcp/McpContextService.cs | 55 +++++-- src/Exceptionless.Web/Program.cs | 2 +- .../Mcp/ExceptionlessMcpToolsTests.cs | 142 +++++++++++++++--- 4 files changed, 231 insertions(+), 88 deletions(-) diff --git a/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs index 2199886dda..c9df660288 100644 --- a/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs +++ b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs @@ -107,22 +107,22 @@ public async Task>> ListOrganizat } [McpServerTool(Name = "resolve_project", ReadOnly = true, UseStructuredContent = true)] - [Description("Resolves a project by id or exact name. Pass the returned projectId explicitly to subsequent project-scoped tools.")] + [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 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.ResolveProjectByIdOrNameAsync(projectId, projectName, organizationId); @@ -138,10 +138,10 @@ public async Task> ResolveProjectAsync( } [McpServerTool(Name = "list_projects", ReadOnly = true, UseStructuredContent = true)] - [Description("Lists projects the authenticated Exceptionless user can access in an explicitly specified organization. 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("The Exceptionless organization id to list projects within.")] - string organizationId, + [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.")] @@ -156,7 +156,7 @@ public async Task>> ListProjectsAsync( try { EnsureScope(AuthorizationRoles.ProjectsRead); - if (!TryValidateId(organizationId, "organizationId", out var idError)) + if (organizationId is not null && !TryValidateId(organizationId, "organizationId", out var idError)) return McpResponse>.Failed(idError); var validation = ValidateProjectSearch(filter, sort, limit); @@ -197,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("The Exceptionless project id.")] - string projectId) + [Description("Optional Exceptionless project id. May be omitted when only one project is accessible.")] + string? projectId = null) { try { EnsureScope(AuthorizationRoles.ProjectsRead); - if (!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); @@ -216,22 +216,22 @@ public async Task> GetProjectAsync( } catch (Exception ex) when (IsLookupError(ex)) { - return McpResponse.Failed(ToLookupError("Project", projectId, 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("The Exceptionless project id to configure.")] - string projectId, + [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") { try { EnsureScope(AuthorizationRoles.ProjectsRead); - if (!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(); @@ -293,7 +293,7 @@ public async Task> GetClientSetupI } catch (Exception ex) when (IsLookupError(ex)) { - return McpResponse.Failed(ToLookupError("Project", projectId, ex)); + return McpResponse.Failed(ToLookupError("Project", projectId ?? "current authorization", ex)); } catch (Exception ex) when (IsExpectedToolError(ex)) { @@ -304,8 +304,8 @@ public async Task> GetClientSetupI [McpServerTool(Name = "search_stacks", ReadOnly = true, UseStructuredContent = true)] [Description("Searches stacks in an Exceptionless project, useful for top issues, top 404s, or recent problem groups. When pagination.hasMore is true, pass pagination.after to fetch the next page or pagination.before to fetch the previous page.")] public async Task>> SearchStacksAsync( - [Description("The Exceptionless project id to search within.")] - string projectId, + [Description("Optional Exceptionless project id to search within. May be omitted when only one project is accessible.")] + string? projectId = null, [Description(StackFilterDescription)] string? filter = null, [Description("Optional sort expression. Defaults to -last_occurrence.")] @@ -326,7 +326,7 @@ public async Task>> SearchStacksAsync( try { EnsureScope(AuthorizationRoles.StacksRead); - if (!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); @@ -358,7 +358,7 @@ public async Task>> SearchStacksAsync( } catch (Exception ex) when (IsLookupError(ex)) { - return McpResponse>.Failed(ToLookupError("Project", projectId, ex)); + return McpResponse>.Failed(ToLookupError("Project", projectId ?? "current authorization", ex)); } catch (Exception ex) when (IsExpectedToolError(ex)) { @@ -367,12 +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, - [Description("The Exceptionless project id that owns the stack.")] - string projectId) + [Description("Optional Exceptionless project id that owns the stack. May be omitted when only one project is accessible.")] + string? projectId = null) { try { @@ -380,7 +380,7 @@ public async Task> GetStackAsync( if (!TryValidateId(stackId, "stackId", out var idError)) return McpResponse.Failed(idError); - if (!TryValidateId(projectId, "projectId", out var projectIdError)) + if (projectId is not null && !TryValidateId(projectId, "projectId", out var projectIdError)) return McpResponse.Failed(projectIdError); var stack = await GetAccessibleStackAsync(stackId, projectId); @@ -397,8 +397,8 @@ public async Task> GetStackAsync( public async Task>> GetStackEventsAsync( [Description("The Exceptionless stack id.")] string stackId, - [Description("The Exceptionless project id that owns the stack.")] - string projectId, + [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.")] @@ -422,7 +422,7 @@ public async Task>> GetStackEventsAsync( if (!TryValidateId(stackId, "stackId", out var idError)) return McpResponse>.Failed(idError); - if (!TryValidateId(projectId, "projectId", out var projectIdError)) + 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); @@ -460,8 +460,8 @@ public async Task>> GetStackEventsAsync( [McpServerTool(Name = "search_events", ReadOnly = true, UseStructuredContent = true)] [Description("Searches event summary rows in an Exceptionless project. Use this for event-first triage across correlation ids, order ids, users, sessions, recent windows, or data.* fields. When pagination.hasMore is true, pass pagination.after or pagination.before to page.")] public async Task>> SearchEventsAsync( - [Description("The Exceptionless project id to search within.")] - string projectId, + [Description("Optional Exceptionless project id to search within. May be omitted when only one project is accessible.")] + string? projectId = null, [Description(EventFilterDescription)] string? filter = null, [Description("Optional sort expression. Defaults to -date.")] @@ -482,7 +482,7 @@ public async Task>> SearchEventsAsync( try { EnsureScope(AuthorizationRoles.EventsRead); - if (!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); @@ -514,7 +514,7 @@ public async Task>> SearchEventsAsync( } catch (Exception ex) when (IsLookupError(ex)) { - return McpResponse>.Failed(ToLookupError("Project", projectId, ex)); + return McpResponse>.Failed(ToLookupError("Project", projectId ?? "current authorization", ex)); } catch (Exception ex) when (IsExpectedToolError(ex)) { @@ -527,8 +527,8 @@ public async Task>> SearchEventsAsync( public async Task> GetEventAsync( [Description("The Exceptionless event id.")] string eventId, - [Description("The Exceptionless project id that owns the event.")] - string projectId, + [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.")] @@ -540,7 +540,7 @@ public async Task> GetEventAsync( if (!TryValidateId(eventId, "eventId", out var idError)) return McpResponse.Failed(idError); - if (!TryValidateId(projectId, "projectId", out var projectIdError)) + if (projectId is not null && !TryValidateId(projectId, "projectId", out var projectIdError)) return McpResponse.Failed(projectIdError); if (includeDetails && !TryValidateDetailSize(maxDetailSize, out var detailSizeError)) @@ -566,8 +566,8 @@ public async Task> GetEventAsync( [McpServerTool(Name = "count_events", ReadOnly = true, UseStructuredContent = true)] [Description("Counts Exceptionless events and occurrences in a project, with optional time buckets and groupBy dimensions for questions like occurrences by version, tag, user, or error type.")] public async Task> CountEventsAsync( - [Description("The Exceptionless project id to count within.")] - string projectId, + [Description("Optional Exceptionless project id to count within. May be omitted when only one project is accessible.")] + string? projectId = null, [Description(EventFilterDescription)] string? filter = null, [Description(LastDescription)] @@ -586,7 +586,7 @@ public async Task> CountEventsAsync( try { EnsureScope(AuthorizationRoles.EventsRead); - if (!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); @@ -673,7 +673,7 @@ public async Task> CountEventsAsync( } catch (Exception ex) when (IsLookupError(ex)) { - return McpResponse.Failed(ToLookupError("Project", projectId, ex)); + return McpResponse.Failed(ToLookupError("Project", projectId ?? "current authorization", ex)); } catch (Exception ex) when (IsExpectedToolError(ex)) { @@ -686,10 +686,10 @@ public async Task> CountEventsAsync( public async Task> UpdateStackStatusAsync( [Description("The Exceptionless stack id.")] string stackId, - [Description("The Exceptionless project id that owns the stack.")] - string projectId, [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) { @@ -699,7 +699,7 @@ public async Task> UpdateStackStatusAsync( if (!TryValidateId(stackId, "stackId", out var idError)) return McpResponse.Failed(idError); - if (!TryValidateId(projectId, "projectId", out var projectIdError)) + if (projectId is not null && !TryValidateId(projectId, "projectId", out var projectIdError)) return McpResponse.Failed(projectIdError); if (!TryParseWritableStackStatus(status, out var stackStatus, out var statusError)) @@ -748,8 +748,8 @@ public async Task> UpdateStackStatusAsync( public async Task> SnoozeStackAsync( [Description("The Exceptionless stack id.")] string stackId, - [Description("The Exceptionless project id that owns the stack.")] - string projectId, + [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.")] @@ -761,7 +761,7 @@ public async Task> SnoozeStackAsync( if (!TryValidateId(stackId, "stackId", out var idError)) return McpResponse.Failed(idError); - if (!TryValidateId(projectId, "projectId", out var projectIdError)) + 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)) @@ -795,10 +795,10 @@ public async Task> SnoozeStackAsync( public async Task> SetStackCriticalAsync( [Description("The Exceptionless stack id.")] string stackId, - [Description("The Exceptionless project id that owns the stack.")] - string projectId, [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 { @@ -806,7 +806,7 @@ public async Task> SetStackCriticalAsync( if (!TryValidateId(stackId, "stackId", out var idError)) return McpResponse.Failed(idError); - if (!TryValidateId(projectId, "projectId", out var projectIdError)) + if (projectId is not null && !TryValidateId(projectId, "projectId", out var projectIdError)) return McpResponse.Failed(projectIdError); var stack = await GetAccessibleStackForWriteAsync(stackId, projectId); @@ -836,10 +836,10 @@ public async Task> SetStackCriticalAsync( public async Task> AddStackReferenceLinkAsync( [Description("The Exceptionless stack id.")] string stackId, - [Description("The Exceptionless project id that owns the stack.")] - string projectId, [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 { @@ -847,7 +847,7 @@ public async Task> AddStackReferenceLinkAsync( if (!TryValidateId(stackId, "stackId", out var idError)) return McpResponse.Failed(idError); - if (!TryValidateId(projectId, "projectId", out var projectIdError)) + if (projectId is not null && !TryValidateId(projectId, "projectId", out var projectIdError)) return McpResponse.Failed(projectIdError); if (!TryNormalizeReferenceUrl(url, out string referenceLink, out var referenceError)) @@ -883,10 +883,10 @@ public async Task> AddStackReferenceLinkAsync( public async Task> RemoveStackReferenceLinkAsync( [Description("The Exceptionless stack id.")] string stackId, - [Description("The Exceptionless project id that owns the stack.")] - string projectId, [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 { @@ -894,7 +894,7 @@ public async Task> RemoveStackReferenceLinkAsy if (!TryValidateId(stackId, "stackId", out var idError)) return McpResponse.Failed(idError); - if (!TryValidateId(projectId, "projectId", out var projectIdError)) + if (projectId is not null && !TryValidateId(projectId, "projectId", out var projectIdError)) return McpResponse.Failed(projectIdError); string? referenceLink = NormalizeReferenceLink(url); @@ -989,7 +989,7 @@ private async Task GetAccessibleProjectAsync(string projectId) return (project, organization); } - private async Task GetAccessibleStackAsync(string stackId, string projectId) + private async Task GetAccessibleStackAsync(string stackId, string? projectId) { if (String.IsNullOrWhiteSpace(stackId)) throw new ArgumentException("Stack id is required.", nameof(stackId)); @@ -1006,7 +1006,7 @@ private async Task GetAccessibleStackAsync(string stackId, string project return stack; } - private async Task GetAccessibleStackForWriteAsync(string stackId, string projectId) + private async Task GetAccessibleStackForWriteAsync(string stackId, string? projectId) { if (String.IsNullOrWhiteSpace(stackId)) throw new ArgumentException("Stack id is required.", nameof(stackId)); @@ -1023,7 +1023,7 @@ private async Task GetAccessibleStackForWriteAsync(string stackId, string return stack; } - private async Task<(Stack Stack, Organization Organization)> GetStackAndOrganizationAsync(string stackId, string projectId) + private async Task<(Stack Stack, Organization Organization)> GetStackAndOrganizationAsync(string stackId, string? projectId) { var stack = await GetAccessibleStackAsync(stackId, projectId); var organization = await _organizationRepository.GetByIdAsync(stack.OrganizationId, o => o.Cache()); diff --git a/src/Exceptionless.Web/Mcp/McpContextService.cs b/src/Exceptionless.Web/Mcp/McpContextService.cs index 6d27436a22..9ecf80a1a3 100644 --- a/src/Exceptionless.Web/Mcp/McpContextService.cs +++ b/src/Exceptionless.Web/Mcp/McpContextService.cs @@ -30,7 +30,7 @@ public async Task GetContextAsync( Organization? activeOrganization = null; Project? activeProject = null; - if (!String.IsNullOrWhiteSpace(projectId)) + if (projectId is not null) { var projectAccess = await GetAccessibleProjectAsync(projectId.Trim()); if (projectAccess.Error is not null) @@ -46,7 +46,7 @@ public async Task GetContextAsync( activeProject.OrganizationId)); } - if (!String.IsNullOrWhiteSpace(organizationId) && !String.Equals(organizationId.Trim(), activeOrganization.Id, StringComparison.Ordinal)) + 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.", @@ -56,7 +56,7 @@ public async Task GetContextAsync( activeProject.Id)); } } - else if (!String.IsNullOrWhiteSpace(organizationId)) + else if (organizationId is not null) { activeOrganization = accessibleOrganizations.FirstOrDefault(o => String.Equals(o.Id, organizationId.Trim(), StringComparison.Ordinal)); if (activeOrganization is null) @@ -118,11 +118,19 @@ public async Task ResolveProjectByIdOrNameAsync( string? projectName = null, string? organizationId = null) { - if (!String.IsNullOrWhiteSpace(projectId)) + if (projectId is not null) return await GetContextAsync(organizationId, projectId.Trim(), requireProject: true); if (String.IsNullOrWhiteSpace(projectName)) - return await GetContextAsync(organizationId: organizationId, requireProject: true); + { + 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); + } var context = await GetContextAsync(organizationId: organizationId, requireProject: false); if (!context.Succeeded || context.ActiveOrganization is null) @@ -149,10 +157,10 @@ public async Task ResolveProjectByIdOrNameAsync( 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)) - return McpProjectContextResolution.Failed(McpErrors.InvalidId("projectId is required.", "projectId", projectId)); + if (projectId is null) + return await ResolveOnlyProjectAsync(); var projectAccess = await GetAccessibleProjectAsync(projectId.Trim()); if (!projectAccess.Succeeded) @@ -172,9 +180,9 @@ public async Task ResolveProjectAsync(string projec return McpProjectContextResolution.Success(project, organization, context); } - public async Task ValidateProjectScopeAsync(string organizationId, string projectId, string requestedProjectId) + public async Task ValidateProjectScopeAsync(string organizationId, string projectId, string? requestedProjectId) { - if (String.Equals(projectId, requestedProjectId, StringComparison.Ordinal)) + if (requestedProjectId is not null && String.Equals(projectId, requestedProjectId, StringComparison.Ordinal)) return null; var projectContext = await ResolveProjectAsync(requestedProjectId); @@ -197,6 +205,33 @@ public async Task ResolveProjectAsync(string projec 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(); diff --git a/src/Exceptionless.Web/Program.cs b/src/Exceptionless.Web/Program.cs index a49b3a7f28..3024850883 100644 --- a/src/Exceptionless.Web/Program.cs +++ b/src/Exceptionless.Web/Program.cs @@ -192,7 +192,7 @@ public static async Task Main(string[] args) builder.Services.AddScoped(); // 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. Use list_organizations and list_projects to discover ids, or resolve_project to resolve a project by name, then pass organizationId and projectId explicitly to every scoped tool. Previous tool calls never change the scope of later calls.") + 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 diff --git a/tests/Exceptionless.Tests/Mcp/ExceptionlessMcpToolsTests.cs b/tests/Exceptionless.Tests/Mcp/ExceptionlessMcpToolsTests.cs index 8edde6551c..9bcc65567b 100644 --- a/tests/Exceptionless.Tests/Mcp/ExceptionlessMcpToolsTests.cs +++ b/tests/Exceptionless.Tests/Mcp/ExceptionlessMcpToolsTests.cs @@ -78,6 +78,38 @@ public async Task ResolveProjectAsync_ProjectId_ReturnsRequestedProject() Assert.Equal(TestConstants.ProjectId, Data(context).ActiveProjectId); } + [Fact] + public async Task ResolveProjectAsync_SingleAccessibleProjectWithoutIds_ReturnsProject() + { + var tools = await CreateToolsForOrganizationsAsync( + Guid.NewGuid().ToString("N"), + [SampleDataService.FREE_ORG_ID], + AuthorizationRoles.McpRead, + AuthorizationRoles.ProjectsRead); + + var context = await tools.ResolveProjectAsync(); + + 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 ResolveProjectAsync_SingleAccessibleProjectAcrossMultipleOrganizationsWithoutIds_ReturnsProject() + { + var tools = await CreateToolsForOrganizationsAsync( + Guid.NewGuid().ToString("N"), + [SampleDataService.FREE_ORG_ID, TestConstants.OrganizationId3], + AuthorizationRoles.McpRead, + AuthorizationRoles.ProjectsRead); + + var context = await tools.ResolveProjectAsync(); + + 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 ResolveProjectAsync_ProjectOutsideExplicitOrganization_ReturnsContextMismatch() { @@ -106,7 +138,7 @@ public async Task SearchStacksAsync_ExplicitProject_ReturnsProjectStacks() } [Fact] - public async Task SearchStacksAsync_WithoutProjectId_ReturnsInvalidId() + public async Task SearchStacksAsync_EmptyProjectId_ReturnsInvalidId() { var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.StacksRead); @@ -117,6 +149,50 @@ public async Task SearchStacksAsync_WithoutProjectId_ReturnsInvalidId() Assert.Equal("projectId", result.Error?.Details?["field"]); } + [Fact] + public async Task SearchStacksAsync_MultipleAccessibleProjectsWithoutProjectId_ReturnsContextRequired() + { + var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.StacksRead); + + var result = await tools.SearchStacksAsync(); + + Assert.False(result.Ok); + Assert.Equal(McpErrorCodes.ContextRequired, result.Error?.Code); + Assert.Equal("project", result.Error?.Details?["selection"]); + } + + [Fact] + public async Task SearchStacksAsync_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() { @@ -153,6 +229,36 @@ public async Task ListProjectsAsync_ProjectsScope_ReturnsAccessibleProjects() 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() { @@ -664,7 +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); - var result = await tools.UpdateStackStatusAsync(stacks[0].Id, TestConstants.ProjectId, "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); @@ -687,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, TestConstants.ProjectId, "fixed"); + var result = await tools.UpdateStackStatusAsync(stacks[0].Id, "fixed", TestConstants.ProjectId); Assert.False(result.Ok); Assert.Equal(McpErrorCodes.Forbidden, result.Error?.Code); @@ -701,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, TestConstants.ProjectId, "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); @@ -715,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, TestConstants.ProjectId, "snoozed"); + var result = await tools.UpdateStackStatusAsync(stacks[0].Id, "snoozed", TestConstants.ProjectId); Assert.False(result.Ok); Assert.Equal(McpErrorCodes.InvalidStatus, result.Error?.Code); @@ -770,7 +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); - var result = await tools.SetStackCriticalAsync(stacks[0].Id, TestConstants.ProjectId, critical: true); + var result = await tools.SetStackCriticalAsync(stacks[0].Id, critical: true, projectId: TestConstants.ProjectId); var data = Data(result); Assert.True(result.Ok); @@ -789,7 +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); - var result = await tools.AddStackReferenceLinkAsync(stacks[0].Id, TestConstants.ProjectId, " 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); @@ -809,7 +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); - var result = await tools.AddStackReferenceLinkAsync(stacks[0].Id, TestConstants.ProjectId, url); + var result = await tools.AddStackReferenceLinkAsync(stacks[0].Id, url, TestConstants.ProjectId); var data = Data(result); Assert.True(result.Ok); @@ -825,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, TestConstants.ProjectId, " "); + var result = await tools.AddStackReferenceLinkAsync(stacks[0].Id, " ", TestConstants.ProjectId); Assert.False(result.Ok); Assert.Equal(McpErrorCodes.InvalidReferenceLink, result.Error?.Code); @@ -841,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, TestConstants.ProjectId, url); + var result = await tools.AddStackReferenceLinkAsync(stacks[0].Id, url, TestConstants.ProjectId); Assert.False(result.Ok); Assert.Equal(McpErrorCodes.InvalidReferenceUrl, result.Error?.Code); @@ -856,7 +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); - var result = await tools.RemoveStackReferenceLinkAsync(stacks[0].Id, TestConstants.ProjectId, url); + var result = await tools.RemoveStackReferenceLinkAsync(stacks[0].Id, url, TestConstants.ProjectId); var data = Data(result); Assert.True(result.Ok); @@ -876,7 +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); - var result = await tools.RemoveStackReferenceLinkAsync(stacks[0].Id, TestConstants.ProjectId, url); + var result = await tools.RemoveStackReferenceLinkAsync(stacks[0].Id, url, TestConstants.ProjectId); var data = Data(result); Assert.True(result.Ok); @@ -891,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, TestConstants.ProjectId, " "); + var result = await tools.RemoveStackReferenceLinkAsync(stacks[0].Id, " ", TestConstants.ProjectId); Assert.False(result.Ok); Assert.Equal(McpErrorCodes.InvalidReferenceLink, result.Error?.Code); @@ -974,13 +1080,14 @@ public async Task ListProjectsAsync_OutputSchemaDoesNotRequireNullableFields() } [Fact] - public async Task ListProjectsAsync_OrganizationIdIsRequiredInSchema() + 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.Contains("organizationId", RequiredProperties(tool.ProtocolTool.InputSchema)); + Assert.True(tool.ProtocolTool.InputSchema.GetProperty("properties").TryGetProperty("organizationId", out _)); + Assert.DoesNotContain("organizationId", RequiredProperties(tool.ProtocolTool.InputSchema)); } [Theory] @@ -1085,7 +1192,7 @@ public async Task GetClientSetupInstructionsAsync_SchemaAdvertisesExpoPlatform() [InlineData(nameof(ExceptionlessMcpTools.SetStackCriticalAsync))] [InlineData(nameof(ExceptionlessMcpTools.AddStackReferenceLinkAsync))] [InlineData(nameof(ExceptionlessMcpTools.RemoveStackReferenceLinkAsync))] - public async Task ProjectScopedTools_ProjectIdIsRequiredInSchema(string methodName) + public async Task ProjectScopedTools_ProjectIdIsOptionalInSchema(string methodName) { var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead, AuthorizationRoles.StacksRead, AuthorizationRoles.EventsRead); var method = typeof(ExceptionlessMcpTools).GetMethod(methodName) ?? throw new InvalidOperationException($"Could not find {methodName}."); @@ -1093,8 +1200,9 @@ public async Task ProjectScopedTools_ProjectIdIsRequiredInSchema(string methodNa var properties = tool.ProtocolTool.InputSchema.GetProperty("properties"); Assert.True(properties.TryGetProperty("projectId", out _), "The projectId input must be advertised in the MCP tool schema."); - Assert.Contains("projectId", RequiredProperties(tool.ProtocolTool.InputSchema)); + Assert.DoesNotContain("projectId", RequiredProperties(tool.ProtocolTool.InputSchema)); } + [Theory] [InlineData(nameof(ExceptionlessMcpTools.UpdateStackStatusAsync))] [InlineData(nameof(ExceptionlessMcpTools.SnoozeStackAsync))]