Skip to content
Merged
2 changes: 1 addition & 1 deletion src/Exceptionless.Web/Exceptionless.Web.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
<!-- Microsoft.AspNetCore.OpenApi 10.x is built against the Microsoft.OpenApi 2.x API surface.
Keep this direct reference on the latest 2.x release until ASP.NET Core supports 3.x. -->
<PackageReference Include="Microsoft.OpenApi" Version="2.11.0" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.4.1" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="2.0.0" />
<PackageReference Include="Riok.Mapperly" Version="4.3.1" />
<PackageReference Include="MiniValidation" Version="0.10.0" />
<PackageReference Include="OAuth2" Version="1.0.0" />
Expand Down
211 changes: 95 additions & 116 deletions src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs

Large diffs are not rendered by default.

391 changes: 135 additions & 256 deletions src/Exceptionless.Web/Mcp/McpContextService.cs

Large diffs are not rendered by default.

146 changes: 0 additions & 146 deletions src/Exceptionless.Web/Mcp/McpSessionMigrationHandler.cs

This file was deleted.

12 changes: 8 additions & 4 deletions src/Exceptionless.Web/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -38,6 +37,7 @@
using Serilog;
using Serilog.Events;
using Serilog.Sinks.Exceptionless;
using HttpIResult = Microsoft.AspNetCore.Http.IResult;

namespace Exceptionless.Web;

Expand Down Expand Up @@ -190,9 +190,10 @@ public static async Task<int> Main(string[] args)
.MapStatus(ResultStatus.Unavailable, ApiResultMapper.MapUnavailable));
Bootstrapper.RegisterServices(builder.Services, options, Log.Logger.ToLoggerFactory());
builder.Services.AddScoped<McpContextService>();
builder.Services.AddSingleton<ISessionMigrationHandler, McpSessionMigrationHandler>();
builder.Services.AddMcpServer()
.WithHttpTransport(o => o.Stateless = false)
// Leave the protocol version unset so native v2 and down-level MCP clients can negotiate a supported version.
builder.Services.AddMcpServer(options =>
options.ServerInstructions = "Exceptionless MCP tools are stateless. Scoped ids may be omitted when the current OAuth grant exposes exactly one matching organization or project; otherwise use list_organizations, list_projects, or resolve_project and pass the required id explicitly. Previous tool calls never change the scope of later calls.")
.WithHttpTransport()
.WithTools<ExceptionlessMcpTools>();
builder.Services.AddSingleton(_ => new ThrottlingOptions
{
Expand Down Expand Up @@ -351,6 +352,9 @@ ApplicationException applicationException when applicationException.Message.Cont
.AddPreferredSecuritySchemes("Bearer");
});
app.MapApiEndpoints();
app.MapGet("/mcp", () => Results.StatusCode(StatusCodes.Status405MethodNotAllowed))
.RequireAuthorization(AuthorizationRoles.McpPolicy)
.ExcludeFromDescription();
app.MapMcp("/mcp").RequireAuthorization(AuthorizationRoles.McpPolicy);
app.MapFallback("{**slug:nonfile}", CreateRequestDelegate(app, "/index.html"));

Expand Down
123 changes: 123 additions & 0 deletions tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -332,6 +334,20 @@ public async Task McpAsync_GetWithUserAuth_ReturnsForbidden()
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}

[Fact]
public async Task McpAsync_GetWithOAuthBearer_ReturnsMethodNotAllowed()
{
var token = await IssueTokenAsync();
using var client = _server.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Get, "/mcp");
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken);

var response = await client.SendAsync(request, TestContext.Current.CancellationToken);

Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode);
}

[Fact]
public async Task AuthorizeAsync_AnonymousUser_RedirectsToAuthorizeBridge()
{
Expand Down Expand Up @@ -795,6 +811,113 @@ public async Task OAuthBearer_RestApiResourceForMcp_ReturnsUnauthorized()
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}

[Fact]
public async Task OAuthBearer_McpV2Client_UsesNativeStatelessProtocolAndCallsTool()
{
const string nativeProtocolVersion = "2026-07-28";
var token = await IssueTokenAsync();
using var httpClient = _server.CreateClient();
var transport = new HttpClientTransport(
new HttpClientTransportOptions
{
Endpoint = new Uri(_server.BaseAddress, "/mcp"),
AdditionalHeaders = new Dictionary<string, string>
{
["Authorization"] = $"Bearer {token.AccessToken}"
}
},
httpClient,
GetService<ILoggerFactory>(),
ownsHttpClient: false);

var options = new McpClientOptions
{
ProtocolVersion = nativeProtocolVersion,
ClientInfo = new Implementation
{
Name = "exceptionless-mcp-v2-tests",
Version = "1.0.0"
}
};

await using var client = await McpClient.CreateAsync(
transport,
options,
cancellationToken: TestContext.Current.CancellationToken);
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
var resolvedProject = await client.CallToolAsync(
"resolve_project",
new Dictionary<string, object?>
{
["projectId"] = TestConstants.ProjectId
},
cancellationToken: TestContext.Current.CancellationToken);
var project = await client.CallToolAsync(
"get_project",
new Dictionary<string, object?>
{
["projectId"] = TestConstants.ProjectId
},
cancellationToken: TestContext.Current.CancellationToken);

Assert.Equal(nativeProtocolVersion, client.NegotiatedProtocolVersion);
Assert.Null(client.SessionId);
Assert.Contains("stateless", client.ServerInstructions, StringComparison.OrdinalIgnoreCase);
Assert.Contains(tools, tool => String.Equals(tool.Name, "resolve_project", StringComparison.Ordinal));
Assert.DoesNotContain(tools, tool => String.Equals(tool.Name, "get_context", StringComparison.Ordinal));
Assert.DoesNotContain(tools, tool => String.Equals(tool.Name, "switch_organization", StringComparison.Ordinal));
Assert.DoesNotContain(tools, tool => String.Equals(tool.Name, "switch_project", StringComparison.Ordinal));
Assert.DoesNotContain(tools, tool => String.Equals(tool.Name, "resolve_project_context", StringComparison.Ordinal));
Assert.NotEqual(true, resolvedProject.IsError);
Assert.NotEqual(true, project.IsError);
Assert.NotNull(project.StructuredContent);
Assert.Contains(TestConstants.ProjectId, project.StructuredContent.ToString(), StringComparison.Ordinal);
}

[Theory]
[InlineData("2025-06-18")]
[InlineData("2025-11-25")]
public async Task OAuthBearer_McpDownLevelClient_NegotiatesAndCallsTool(string downLevelProtocolVersion)
{
var token = await IssueTokenAsync();
using var httpClient = _server.CreateClient();
var transport = new HttpClientTransport(
new HttpClientTransportOptions
{
Endpoint = new Uri(_server.BaseAddress, "/mcp"),
AdditionalHeaders = new Dictionary<string, string>
{
["Authorization"] = $"Bearer {token.AccessToken}"
}
},
httpClient,
GetService<ILoggerFactory>(),
ownsHttpClient: false);

var options = new McpClientOptions
{
ProtocolVersion = downLevelProtocolVersion,
ClientInfo = new Implementation
{
Name = "exceptionless-mcp-down-level-tests",
Version = "1.0.0"
}
};

await using var client = await McpClient.CreateAsync(
transport,
options,
cancellationToken: TestContext.Current.CancellationToken);
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
var filterFields = await client.CallToolAsync("get_filter_fields", cancellationToken: TestContext.Current.CancellationToken);

Assert.Equal(downLevelProtocolVersion, client.NegotiatedProtocolVersion);
Assert.Contains("stateless", client.ServerInstructions, StringComparison.OrdinalIgnoreCase);
Assert.Contains(tools, tool => String.Equals(tool.Name, "get_filter_fields", StringComparison.Ordinal));
Assert.NotEqual(true, filterFields.IsError);
Assert.NotNull(filterFields.StructuredContent);
}

[Fact]
public async Task OAuthBearer_RestApiResourceMissingScope_ReturnsForbidden()
{
Expand Down
Loading
Loading