Skip to content

Commit 91aec42

Browse files
authored
Add hosted MCP server (#2320)
* Add OAuth MCP API access * Add OpenSpec validation script * Move OpenSpec script into ClientApp * Fix ClientApp lint * Update controller manifest baseline * Advertise OAuth metadata for MCP challenges * Redirect OAuth authorize requests to login * Add OAuth authorize bridge * Handle MCP GET requests before app fallback * Add OAuth dynamic client registration * Update controller manifest for OAuth registration * Harden hosted MCP OAuth flow * Show account context on OAuth consent * Default missing OAuth MCP resource * Allow loopback OAuth redirect ports * Improve MCP query validation and pagination * Document MCP cursor pagination schema * Improve MCP tool error contracts * Expand MCP query tools * Add MCP stack write tools * Add MCP event count grouping * Return invalid cursor errors from MCP tools * Add MCP stack reference link tool * Refresh metadata OAuth client scopes * Add MCP stack reference removal * Address MCP PR feedback * Address OAuth validation feedback * Refine MCP feedback fixes * Clarify OAuth server options naming * Clarify OAuth application system user id * Harden MCP OAuth flows * Enforce MCP scopes for user principals * Expose MCP setup in account UI * Address OAuth MCP review feedback * Improve MCP setup instructions layout * Remove MCP setup page intro header * Add AI Tools entry points * Promote AI Tools from client setup * Simplify project AI Tools prompt * Add projects link to organization switcher * Add MCP client setup instructions tool
1 parent 57dda01 commit 91aec42

48 files changed

Lines changed: 7742 additions & 26 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/Exceptionless.Core/Authorization/AuthorizationRoles.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,12 @@ public static class AuthorizationRoles
88
public const string User = "user";
99
public const string GlobalAdminPolicy = nameof(GlobalAdminPolicy);
1010
public const string GlobalAdmin = "global";
11+
public const string McpPolicy = nameof(McpPolicy);
12+
public const string McpRead = "mcp:read";
13+
public const string ProjectsRead = "projects:read";
14+
public const string StacksRead = "stacks:read";
15+
public const string StacksWrite = "stacks:write";
16+
public const string EventsRead = "events:read";
17+
public const string OfflineAccess = "offline_access";
1118
public static readonly ISet<string> AllScopes = new HashSet<string>([Client, User, GlobalAdmin]);
1219
}

src/Exceptionless.Core/Bootstrapper.cs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
using System.Text.Json;
1+
using System.Net;
2+
using System.Net.Sockets;
3+
using System.Text.Json;
24
using Elastic.Clients.Elasticsearch;
35
using Exceptionless.Core.Authentication;
46
using Exceptionless.Core.Billing;
@@ -137,6 +139,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO
137139
services.AddSingleton<MigrationManager>();
138140
services.AddSingleton<MigrationIndex>(s => s.GetRequiredService<ExceptionlessElasticConfiguration>().Migrations);
139141
services.AddSingleton<IOrganizationRepository, OrganizationRepository>();
142+
services.AddSingleton<IOAuthApplicationRepository, OAuthApplicationRepository>();
140143
services.AddSingleton<IProjectRepository, ProjectRepository>();
141144
services.AddSingleton<IUserRepository, UserRepository>();
142145
services.AddSingleton<IWebHookRepository, WebHookRepository>();
@@ -182,13 +185,45 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO
182185
services.AddSingleton<NotificationService>();
183186
services.AddSingleton<OrganizationService>();
184187
services.AddStartupAction<OrganizationService>();
188+
services.AddHttpClient<IOAuthClientMetadataService, OAuthClientMetadataService>()
189+
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
190+
{
191+
AllowAutoRedirect = false,
192+
ConnectCallback = ConnectToPublicAddressAsync
193+
});
194+
services.AddSingleton<OAuthService>();
185195
services.AddSingleton<UsageService>();
186196
services.AddSingleton<SlackService>();
187197
services.AddSingleton<StackService>();
188198

189199
services.AddTransient<IDomainLoginProvider, ActiveDirectoryLoginProvider>();
190200
}
191201

202+
private static async ValueTask<Stream> ConnectToPublicAddressAsync(SocketsHttpConnectionContext context, CancellationToken cancellationToken)
203+
{
204+
var addresses = await Dns.GetHostAddressesAsync(context.DnsEndPoint.Host, cancellationToken);
205+
Exception? lastException = null;
206+
foreach (var address in addresses)
207+
{
208+
if (!OAuthClientMetadataService.IsPublicAddress(address))
209+
continue;
210+
211+
var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
212+
try
213+
{
214+
await socket.ConnectAsync(new IPEndPoint(address, context.DnsEndPoint.Port), cancellationToken);
215+
return new NetworkStream(socket, ownsSocket: true);
216+
}
217+
catch (SocketException ex)
218+
{
219+
lastException = ex;
220+
socket.Dispose();
221+
}
222+
}
223+
224+
throw new HttpRequestException($"OAuth client metadata host '{context.DnsEndPoint.Host}' did not resolve to a reachable public address.", lastException);
225+
}
226+
192227
public static void LogConfiguration(IServiceProvider serviceProvider, AppOptions appOptions, ILogger logger)
193228
{
194229
if (!logger.IsEnabled(LogLevel.Warning))

src/Exceptionless.Core/Configuration/AppOptions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ public class AppOptions
7979
public SlackOptions SlackOptions { get; internal set; } = null!;
8080
public StripeOptions StripeOptions { get; internal set; } = null!;
8181
public AuthOptions AuthOptions { get; internal set; } = null!;
82+
public OAuthServerOptions OAuthServerOptions { get; internal set; } = null!;
8283

8384
public static AppOptions ReadFromConfiguration(IConfiguration config)
8485
{
@@ -131,6 +132,7 @@ public static AppOptions ReadFromConfiguration(IConfiguration config)
131132
options.SlackOptions = SlackOptions.ReadFromConfiguration(config);
132133
options.StripeOptions = StripeOptions.ReadFromConfiguration(config);
133134
options.AuthOptions = AuthOptions.ReadFromConfiguration(config);
135+
options.OAuthServerOptions = OAuthServerOptions.ReadFromConfiguration(config);
134136

135137
return options;
136138
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
using Microsoft.Extensions.Configuration;
2+
3+
namespace Exceptionless.Core.Configuration;
4+
5+
public class OAuthServerOptions
6+
{
7+
public TimeSpan AuthorizationCodeLifetime { get; internal set; } = TimeSpan.FromMinutes(5);
8+
public TimeSpan AccessTokenLifetime { get; internal set; } = TimeSpan.FromHours(1);
9+
public TimeSpan RefreshTokenLifetime { get; internal set; } = TimeSpan.FromDays(30);
10+
public bool EnableClientIdMetadataDocuments { get; internal set; } = true;
11+
public int DynamicClientRegistrationIpLimit { get; internal set; } = 20;
12+
public TimeSpan ClientMetadataDocumentCacheLifetime { get; internal set; } = TimeSpan.FromHours(1);
13+
public TimeSpan ClientMetadataDocumentRequestTimeout { get; internal set; } = TimeSpan.FromSeconds(5);
14+
public int ClientMetadataDocumentMaxBytes { get; internal set; } = 32 * 1024;
15+
16+
public static OAuthServerOptions ReadFromConfiguration(IConfiguration config)
17+
{
18+
var options = new OAuthServerOptions();
19+
options.AuthorizationCodeLifetime = TimeSpan.FromMinutes(config.GetValue("OAuthServer:AuthorizationCodeLifetimeMinutes", 5));
20+
options.AccessTokenLifetime = TimeSpan.FromMinutes(config.GetValue("OAuthServer:AccessTokenLifetimeMinutes", 60));
21+
options.RefreshTokenLifetime = TimeSpan.FromDays(config.GetValue("OAuthServer:RefreshTokenLifetimeDays", 30));
22+
options.EnableClientIdMetadataDocuments = config.GetValue("OAuthServer:EnableClientIdMetadataDocuments", true);
23+
options.DynamicClientRegistrationIpLimit = config.GetValue("OAuthServer:DynamicClientRegistrationIpLimit", 20);
24+
options.ClientMetadataDocumentCacheLifetime = TimeSpan.FromMinutes(config.GetValue("OAuthServer:ClientMetadataDocumentCacheLifetimeMinutes", 60));
25+
options.ClientMetadataDocumentRequestTimeout = TimeSpan.FromSeconds(config.GetValue("OAuthServer:ClientMetadataDocumentRequestTimeoutSeconds", 5));
26+
options.ClientMetadataDocumentMaxBytes = config.GetValue("OAuthServer:ClientMetadataDocumentMaxBytes", 32 * 1024);
27+
return options;
28+
}
29+
}
30+
31+
public record OAuthClientOptions
32+
{
33+
public required string ClientId { get; init; }
34+
public required string Name { get; init; }
35+
public IReadOnlyCollection<string> RedirectUris { get; init; } = [];
36+
public IReadOnlyCollection<string> Scopes { get; init; } = [];
37+
public bool IsDisabled { get; init; }
38+
39+
internal OAuthClientOptions Normalize()
40+
{
41+
return this with
42+
{
43+
RedirectUris = RedirectUris
44+
.Where(u => !String.IsNullOrWhiteSpace(u))
45+
.Select(u => u.Trim())
46+
.Distinct(StringComparer.Ordinal)
47+
.ToArray(),
48+
Scopes = Scopes
49+
.Where(s => !String.IsNullOrWhiteSpace(s))
50+
.Select(s => s.Trim().ToLowerInvariant())
51+
.Distinct(StringComparer.Ordinal)
52+
.ToArray()
53+
};
54+
}
55+
}

src/Exceptionless.Core/Extensions/ConfigurationExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ public static IServiceCollection AddAppOptions(this IServiceCollection services,
1818
services.AddSingleton(appOptions.SlackOptions);
1919
services.AddSingleton(appOptions.StripeOptions);
2020
services.AddSingleton(appOptions.AuthOptions);
21+
services.AddSingleton(appOptions.OAuthServerOptions);
2122

2223
return services;
2324
}

src/Exceptionless.Core/Extensions/IdentityUtils.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ public static class IdentityUtils
1313
public const string OrganizationIdsClaim = "OrganizationIds";
1414
public const string ProjectIdClaim = "ProjectId";
1515
public const string DefaultProjectIdClaim = "DefaultProjectId";
16+
public const string OAuthClientIdClaim = "OAuthClientId";
17+
public const string OAuthResourceClaim = "OAuthResource";
1618

1719
public static ClaimsIdentity ToIdentity(this Token token)
1820
{
@@ -60,9 +62,20 @@ public static ClaimsIdentity ToIdentity(this User user, Token? token = null)
6062

6163
if (!String.IsNullOrEmpty(token.DefaultProjectId))
6264
claims.Add(new Claim(DefaultProjectIdClaim, token.DefaultProjectId));
65+
66+
if (!String.IsNullOrEmpty(token.OAuthClientId))
67+
claims.Add(new Claim(OAuthClientIdClaim, token.OAuthClientId));
68+
69+
if (!String.IsNullOrEmpty(token.OAuthResource))
70+
claims.Add(new Claim(OAuthResourceClaim, token.OAuthResource));
6371
}
6472

65-
if (user.Roles.Count > 0)
73+
if (token is { OAuthType: OAuthTokenType.Access } && token.Scopes.Count > 0)
74+
{
75+
foreach (string scope in token.Scopes)
76+
claims.Add(new Claim(ClaimTypes.Role, scope));
77+
}
78+
else if (user.Roles.Count > 0)
6679
{
6780
// add implied scopes
6881
var roles = user.Roles.ToHashSet();
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
using System.ComponentModel.DataAnnotations;
2+
using Exceptionless.Core.Attributes;
3+
using Exceptionless.Core.Authorization;
4+
using Foundatio.Repositories.Models;
5+
6+
namespace Exceptionless.Core.Models;
7+
8+
public class OAuthApplication : IIdentity, IHaveDates, IValidatableObject
9+
{
10+
public const string SystemUserId = "000000000000000000000001";
11+
12+
[ObjectId]
13+
public string Id { get; set; } = null!;
14+
15+
[Required]
16+
[MaxLength(2048)]
17+
public string ClientId { get; set; } = null!;
18+
19+
[Required]
20+
[MaxLength(200)]
21+
public string Name { get; set; } = null!;
22+
23+
[Required]
24+
[Length(1, 20)]
25+
public string[] RedirectUris { get; set; } = [];
26+
27+
[Required]
28+
[Length(1, 20)]
29+
public string[] Scopes { get; set; } = [];
30+
31+
[MaxLength(1000)]
32+
public string? Notes { get; set; }
33+
34+
public bool IsDisabled { get; set; }
35+
36+
[ObjectId]
37+
public string CreatedByUserId { get; set; } = null!;
38+
39+
[ObjectId]
40+
public string? UpdatedByUserId { get; set; }
41+
42+
public DateTime CreatedUtc { get; set; }
43+
public DateTime UpdatedUtc { get; set; }
44+
45+
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
46+
{
47+
foreach (string _ in RedirectUris.Where(String.IsNullOrWhiteSpace))
48+
yield return new ValidationResult("Redirect URI cannot be empty.", [nameof(RedirectUris)]);
49+
50+
foreach (string redirectUri in RedirectUris.Where(uri => !String.IsNullOrWhiteSpace(uri)))
51+
{
52+
if (!IsValidRedirectUri(redirectUri))
53+
yield return new ValidationResult($"'{redirectUri}' must be an absolute HTTPS URI or loopback HTTP URI without a fragment.", [nameof(RedirectUris)]);
54+
}
55+
56+
foreach (string _ in Scopes.Where(String.IsNullOrWhiteSpace))
57+
yield return new ValidationResult("Scope cannot be empty.", [nameof(Scopes)]);
58+
59+
foreach (string scope in Scopes.Where(s => !String.IsNullOrWhiteSpace(s)))
60+
{
61+
if (!AllScopes.Contains(scope, StringComparer.Ordinal))
62+
yield return new ValidationResult($"'{scope}' is not a supported OAuth scope.", [nameof(Scopes)]);
63+
}
64+
}
65+
66+
public static readonly IReadOnlyCollection<string> AllScopes =
67+
[
68+
AuthorizationRoles.McpRead,
69+
AuthorizationRoles.ProjectsRead,
70+
AuthorizationRoles.StacksRead,
71+
AuthorizationRoles.StacksWrite,
72+
AuthorizationRoles.EventsRead,
73+
AuthorizationRoles.OfflineAccess
74+
];
75+
76+
public static bool IsValidRedirectUri(string redirectUri)
77+
{
78+
if (String.IsNullOrWhiteSpace(redirectUri) || !Uri.TryCreate(redirectUri, UriKind.Absolute, out var uri) || !String.IsNullOrEmpty(uri.Fragment))
79+
return false;
80+
81+
if (String.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
82+
return true;
83+
84+
return String.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && (uri.IsLoopback || String.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase));
85+
}
86+
}

src/Exceptionless.Core/Models/Token.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ public class Token : IOwnedByOrganizationAndProjectWithIdentity, IHaveDates, IVa
3636
public string? DefaultProjectId { get; set; }
3737
public string? Refresh { get; set; }
3838
public TokenType Type { get; set; }
39+
public OAuthTokenType OAuthType { get; set; }
40+
public string? OAuthClientId { get; set; }
41+
public string? OAuthResource { get; set; }
42+
public DateTime? OAuthRefreshExpiresUtc { get; set; }
3943
public HashSet<string> Scopes { get; set; } = new();
4044
public DateTime? ExpiresUtc { get; set; }
4145
public string? Notes { get; set; }
@@ -87,3 +91,9 @@ public enum TokenType
8791
Authentication,
8892
Access
8993
}
94+
95+
public enum OAuthTokenType
96+
{
97+
None,
98+
Access
99+
}

src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ ILoggerFactory loggerFactory
4343
AddIndex(Events = new EventIndex(this, serviceProvider, appOptions));
4444
AddIndex(Migrations = new MigrationIndex(this, _appOptions.ElasticsearchOptions.ScopePrefix + "migrations", appOptions.ElasticsearchOptions.NumberOfReplicas));
4545
AddIndex(Organizations = new OrganizationIndex(this));
46+
AddIndex(OAuthApplications = new OAuthApplicationIndex(this));
4647
AddIndex(Projects = new ProjectIndex(this));
4748
AddIndex(SavedViews = new SavedViewIndex(this));
4849
AddIndex(Tokens = new TokenIndex(this));
@@ -71,6 +72,7 @@ public override void ConfigureGlobalQueryBuilders(ElasticQueryBuilder builder)
7172
public EventIndex Events { get; }
7273
public MigrationIndex Migrations { get; }
7374
public OrganizationIndex Organizations { get; }
75+
public OAuthApplicationIndex OAuthApplications { get; }
7476
public ProjectIndex Projects { get; }
7577
public SavedViewIndex SavedViews { get; }
7678
public TokenIndex Tokens { get; }
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
using Elastic.Clients.Elasticsearch.IndexManagement;
2+
using Elastic.Clients.Elasticsearch.Mapping;
3+
using Exceptionless.Core.Models;
4+
using Foundatio.Parsers.ElasticQueries.Extensions;
5+
using Foundatio.Repositories.Elasticsearch.Configuration;
6+
using Foundatio.Repositories.Elasticsearch.Extensions;
7+
8+
namespace Exceptionless.Core.Repositories.Configuration;
9+
10+
public sealed class OAuthApplicationIndex : VersionedIndex<OAuthApplication>
11+
{
12+
internal const string KEYWORD_LOWERCASE_ANALYZER = "keyword_lowercase";
13+
private readonly ExceptionlessElasticConfiguration _configuration;
14+
15+
public OAuthApplicationIndex(ExceptionlessElasticConfiguration configuration) : base(configuration, configuration.Options.ScopePrefix + "oauth-applications", 1)
16+
{
17+
_configuration = configuration;
18+
}
19+
20+
public override void ConfigureIndexMapping(TypeMappingDescriptor<OAuthApplication> map)
21+
{
22+
map
23+
.Dynamic(DynamicMapping.False)
24+
.Properties(p => p
25+
.SetupDefaults()
26+
.Text(e => e.Name, t => t.Analyzer(KEYWORD_LOWERCASE_ANALYZER).AddKeywordField())
27+
.Keyword(e => e.ClientId)
28+
.Keyword(e => e.RedirectUris)
29+
.Keyword(e => e.Scopes)
30+
.Keyword(e => e.CreatedByUserId)
31+
.Keyword(e => e.UpdatedByUserId)
32+
.Boolean(e => e.IsDisabled));
33+
}
34+
35+
public override void ConfigureIndex(CreateIndexRequestDescriptor idx)
36+
{
37+
base.ConfigureIndex(idx);
38+
idx.Settings(s => s
39+
.Analysis(d => d.Analyzers(b => b.Custom(KEYWORD_LOWERCASE_ANALYZER, c => c.Filter("lowercase").Tokenizer("keyword"))))
40+
.NumberOfShards(_configuration.Options.NumberOfShards)
41+
.NumberOfReplicas(_configuration.Options.NumberOfReplicas)
42+
.Priority(5));
43+
}
44+
}

0 commit comments

Comments
 (0)