Outermost layer of the Clean Architecture. Acts as the composition root and MCP transport host. Contains MCP tools, prompts, middleware, telemetry filter, and all ASP.NET Core / MCP SDK wiring. No business logic lives here: tools delegate to use cases in McpServer.Application.
- Target Framework: .NET 10 (Web SDK)
- NuGet packages:
ModelContextProtocol.AspNetCorev1.2.0 - Project references:
McpServer.Application,McpServer.Infrastructure,McpServer.ServiceDefaults,McpServer.Shared
For the full project overview (architecture, tools catalog, authorization, OBO security posture, configuration, running), see the project-level README.
Minimal startup (~44 lines) orchestrating the three Clean Architecture layers:
builder.Services
.AddApplication()
.AddInfrastructure(builder.Configuration)
.AddPresentation(builder.Configuration, builder.Environment);Public entry point for Presentation DI registration:
services.AddPresentation(configuration, environment);Orchestrates: authentication, CORS, rate limiting, and MCP server setup.
Configures JWT Bearer authentication for Microsoft Entra ID with MCP-aware challenge scheme.
- Validates JWTs against
{ClientId}andapi://{ClientId}audiences - Uses
BuildTokenValidationParameters()fromEntraIdBaseOptionsfor issuer/signing key validation - Default authenticate:
JwtBearerDefaults.AuthenticationScheme - Default challenge:
McpAuthenticationDefaults.AuthenticationScheme(MCP-aware 401 with RFC 9728 metadata) - Rate limiting: fixed window 100 req/min per user identity or IP
Stays in Server because it depends on .AddMcp() from the MCP SDK.
Registers the MCP server with tools, prompts, and filters. Tools and prompts are registered explicitly with the generic WithTools<T>() / WithPrompts<T>() overloads, which are AOT-safe ([DynamicallyAccessedMembers]) and avoid an assembly scan at startup. Consider switching to WithToolsFromAssembly() / WithPromptsFromAssembly() when the number of types exceeds ~8 to reduce boilerplate, accepting the [RequiresUnreferencedCode] constraint and a one-time startup scan.
.AddAuthorizationFilters()
.WithRequestFilters(filters =>
{
filters.AddCallToolFilter(McpTelemetryFilter.Create());
})
.WithTools<TaskTools>()
.WithTools<ProjectsTools>()
.WithTools<BalancesTools>()
.WithPrompts<TaskPrompts>()
.WithPrompts<ProjectPrompts>()Maps anonymous endpoints for OAuth/MCP discovery:
GET /.well-known/oauth-protected-resource: RFC 9728 protected resource metadataGET /.well-known/oauth-authorization-server: RFC 8414 proxy to Entra ID OpenID configuration
Registers McpCorrelationMiddleware in the ASP.NET Core pipeline via UseMcpCorrelation().
Extracts correlation headers from MCP requests and sets them as Activity tags on the HTTP-level span:
Mcp-Session-Id→mcp.session.id- W3C
traceparent/tracestate→ standard trace context - Azure SDK headers (
x-ms-client-request-id,x-ms-correlation-request-id)
Strongly-typed options class for the in-process fixed-window rate limiter. Bound from the RateLimit section in appsettings.json and validated on start with [Range] data annotations.
| Property | Type | Default | Description |
|---|---|---|---|
PermitLimit |
int |
100 | Maximum requests per user or IP within the window |
WindowSeconds |
int |
60 | Fixed window duration in seconds |
QueueLimit |
int |
10 | Queued requests before returning HTTP 429 |
The queue absorbs short bursts without dropping requests and enforces backpressure before business logic, protecting downstream infrastructure even when APIM is bypassed (direct App Service URL).
"RateLimit": {
"PermitLimit": 100,
"WindowSeconds": 60,
"QueueLimit": 10
}Three sealed tool classes, one per domain. Each class uses primary constructor to inject use cases (from McpServer.Application/UseCases/). Every method delegates to exactly one use case via ExecuteAsync() and returns result.ToJson().
Mandatory patterns (enforced by project conventions):
[Authorize]at class level AND[Authorize(Roles = Permissions.XXX)]at method level- Inject use cases only, not
IDownstreamApiService,IHttpContextAccessor, orILogger - No
Stopwatch,McpActivitySource,McpMetrics.RecordToolInvocation(), orMcpMetrics.RecordResponseSize()(handled byMcpTelemetryFilter) - No try/catch for general error handling (handled by
McpTelemetryFilter) - All
if/else/for/foreach/whilemust have curly braces (SonarQube S121) CancellationTokenwithout= default(MCP SDK injects it)
[McpServerToolType]
[Authorize]
public sealed class TaskTools(
GetTasksUseCase getTasksUseCase,
CreateTaskUseCase createTaskUseCase)
{
[McpServerTool(Name = "get_tasks", Title = "Get Tasks", ReadOnly = true)]
[Description("Get all tasks for the authenticated user.")]
[Authorize(Roles = Permissions.TASK_READ)]
public async Task<string> GetTasks(CancellationToken cancellationToken)
{
var result = await getTasksUseCase.ExecuteAsync(cancellationToken);
return result.ToJson();
}
}| Class | Tools | Domain |
|---|---|---|
TaskTools |
4 | Tasks |
ProjectsTools |
2 | Projects |
BalancesTools |
2 | Balances |
For the full tools catalog with parameters, see the project-level README.
Two sealed prompt classes. Return ChatMessage (from Microsoft.Extensions.AI) with structured prompt text. Same authorization pattern as tools.
[McpServerPromptType]
[Authorize]
public sealed class TaskPrompts
{
[McpServerPrompt(Name = "summarize_tasks")]
[Description("Generate a summary of all user tasks.")]
[Authorize(Roles = Permissions.TASK_READ)]
public ChatMessage SummarizeTasks([Description("Optional status filter")] string? statusFilter = null)
{
return new ChatMessage(ChatRole.User, "Structured prompt text...");
}
}| Class | Prompts | Domain |
|---|---|---|
TaskPrompts |
2 | Tasks |
ProjectPrompts |
2 | Projects |
For the full prompts catalog with arguments, see the project-level README.
Centralized CallTool filter registered via AddCallToolFilter. Handles all tool telemetry so that tool classes contain only business logic.
Stays in Server (not Infrastructure) because it depends on ModelContextProtocol.Protocol types (CallToolRequestParams, CallToolResult).
The filter executes in the MCP request pipeline after authorization:
AuthorizationFilter → McpTelemetryFilter → ToolHandler
For every tool invocation, the filter automatically:
- Starts an OpenTelemetry
ActivityviaMcpActivitySource.StartToolActivity(toolName) - Enriches the activity with user context (
oid,azp, roles,tid, scopes, IP) fromHttpContext - Propagates
mcp.session.idfrom theMcp-Session-IdHTTP header (child spans do not inherit parent tags) - Tags tools with
mcp.tool.data_classificationwhen registered in the classification dictionary - Records execution time via
StopwatchandMcpMetrics.RecordToolInvocation() - Measures response size via
McpMetrics.RecordResponseSize() - Logs invocation (
LogInformation) and errors (LogError) with structured{ToolName}property - On exceptions, records the error on the activity via
McpActivitySource.RecordError()
Log SourceContext: McpServer.Presentation.Telemetry.McpTelemetryFilter (the class emitting the log, not the tool class). The tool name is a structured property {ToolName} filterable in Serilog/Seq/Aspire Dashboard.
McpTelemetryFilter maintains a static ToolDataClassifications dictionary mapping tool names to classification labels. The PDP uses this label to apply tighter controls.
No tools are currently classified as sensitive. Add entries to ToolDataClassifications when tools return PII or security-sensitive data.
Span hierarchy showing how mcp.session.id flows:
POST /mcp (HTTP span) ← mcp.session.id set by McpCorrelationMiddleware
└── mcp.tool.get_tasks (custom) ← mcp.session.id propagated by McpTelemetryFilter
└── GET /api/tasks (HTTP) ← auto-instrumented, no mcp.session.id
Centralized in McpServer.ServiceDefaults via AddSerilogDefaults(). The MCP Server calls builder.Host.AddSerilogDefaults() and app.UseSerilogRequestLogging(). No Serilog section in appsettings.json.
Custom sources registered via ServiceTelemetryOptions in Program.cs:
builder.AddServiceDefaults(telemetry =>
{
telemetry.ActivitySourceNames.Add(McpActivitySource.Name);
telemetry.MeterNames.Add(McpMetrics.MeterName);
});Custom Metrics (meter: McpServer.Presentation):
| Metric | Type | Tags | Description |
|---|---|---|---|
mcp.tool.invocations |
Counter → Dist. | mcp.tool.name, mcp.tool.success |
Tool invocation count |
mcp.tool.errors |
Counter → Dist. | mcp.tool.name, mcp.tool.success |
Tool error count |
mcp.tool.duration |
Histogram | mcp.tool.name, mcp.tool.success |
Execution time in ms |
mcp.tool.validation.errors |
Counter → Dist. | mcp.tool.name, validation.parameter |
Input validation failures |
mcp.tool.response.size |
Histogram | mcp.tool.name |
Response payload size (bytes) |
McpActivitySource.EnrichWithUserContext() (in Infrastructure) extracts JWT claims from HttpContext.User and sets them as span tags. The code handles MapInboundClaims = true (ASP.NET Core default) by using fallback URIs from EntraClaimTypes:
| JWT Claim (raw) | Span Tag | Lookup Strategy |
|---|---|---|
oid |
enduser.id |
Try "oid" → try EntraClaimTypes.ObjectId |
roles (array) |
enduser.roles |
Use ClaimsIdentity.RoleClaimType (respects any mapping) |
tid |
tenant.id |
Try "tid" → try EntraClaimTypes.TenantId |
scp |
enduser.scope |
Try "scp" → try EntraClaimTypes.Scope → try "scope" |
azp |
oauth.client.id |
Try "azp" → try "client_id" |
| (connection) | client.address |
HttpContext.Connection.RemoteIpAddress |
enduser.id always uses oid (Entra ID Object ID), never sub, for cross-service span correlation.
| Environment | Exporter | Target |
|---|---|---|
| Local dev (Aspire) | OTLP/gRPC | Aspire Dashboard |
| Production (App Service) | Azure Monitor SDK | Application Insights |
Configured in PresentationServiceExtensions for browser-based MCP clients. Origins read from Cors:AllowedOrigins in config, with localhost dev port fallbacks:
| Origin | Purpose |
|---|---|
http://localhost:6274 / http://127.0.0.1:6274 |
MCP Inspector |
http://localhost:5173 / http://127.0.0.1:5173 |
Vite dev server |
http://localhost:3000 / http://127.0.0.1:3000 |
React dev server |
UseSerilogRequestLogging() → UseCors() → UseRateLimiter() → UseAuthentication() → UseMcpCorrelation() → UseAuthorization()
McpServer.Presentation/
├── Program.cs # Composition root (~44 lines)
├── McpServer.Presentation.csproj
├── appsettings.json # Application configuration (EntraId, DownstreamApi, Cors, RateLimit)
├── appsettings.Development.json # Development overrides
│
├── Configuration/
│ └── RateLimitOptions.cs # Strongly-typed options for the in-process rate limiter
│
├── Extensions/
│ ├── PresentationServiceExtensions.cs # AddPresentation: orchestrates all Presentation DI
│ ├── AuthenticationExtensions.cs # JWT + MCP SDK challenge scheme
│ ├── McpServerExtensions.cs # Tools, prompts, filters registration
│ ├── McpCorrelationMiddlewareExtensions.cs
│ └── WellKnownEndpointExtensions.cs # RFC 9728 / 8414 endpoints
│
├── Middleware/
│ └── McpCorrelationMiddleware.cs # Session ID and trace context propagation
│
├── Telemetry/
│ └── McpTelemetryFilter.cs # Centralized CallTool telemetry filter
│
├── Tools/
│ ├── TaskTools.cs # 4 tools: CRUD tasks
│ ├── ProjectsTools.cs # 2 tools: backend projects
│ ├── BalancesTools.cs # 1 tool: backend balance
│ └── AdminTools.cs # 1 tool: admin operations
│
├── Prompts/
│ ├── TaskPrompts.cs # 2 prompts: task summarization/analysis
│ ├── ProjectPrompts.cs # 2 prompts: project analysis/comparison
│ └── AdminPrompts.cs # 2 prompts: compliance/audit
│
├── Properties/
│ └── launchSettings.json
└── logs/ # Rolling log files
-
Minimal composition root:
Program.cscalls.AddApplication().AddInfrastructure().AddPresentation(). Each layer registers its own services. -
McpTelemetryFilterstays in Server: It depends onModelContextProtocol.Protocoltypes (CallToolRequestParams,CallToolResult). Moving it to Infrastructure would add an MCP SDK dependency to a layer that should not have it. -
AuthenticationExtensionsstays in Server: It depends on.AddMcp()from the MCP SDK for the challenge scheme. -
Tools inject use cases only: No direct
IDownstreamApiService,IHttpContextAccessor,ILogger, or telemetry primitives. Use cases handle validation and orchestration; the filter handles telemetry. -
MCP authentication challenge: Uses
McpAuthenticationDefaults.AuthenticationSchemefor MCP-specification-compliant 401 responses with RFC 9728 protected resource metadata.
McpServer.Domain
↑
McpServer.Application
↑
McpServer.Infrastructure
↑
McpServer.Presentation (this project)
For layer details: Domain README, Application README, Infrastructure README.