Skip to content

MCP Security — Authorization, GDPR Sanitization & DPoP

MCP exposes application logic to external AI agents. Granit ensures every tool invocation is authorized, every response is sanitized, and every action is audited.

Standard [Authorize] — no custom attributes

Section titled “Standard [Authorize] — no custom attributes”

The MCP SDK’s .AddAuthorizationFilters() (wired by AddGranitMcpServer()) enables standard ASP.NET Core [Authorize] on tool classes and methods. Granit’s DynamicPermissionPolicyProvider maps policy names to RBAC permissions:

[McpServerToolType, McpExposed]
[Authorize(Policy = McpPermissions.Tools.Execute)] // Class-level default
public sealed class NotificationMcpTools(INotificationSender sender)
{
[McpServerTool, Description("Sends a notification to a user.")]
[Authorize(Policy = "Notifications.Messages.Create")]
[McpToolOptions(Destructive = true)]
public async Task SendNotificationAsync(
[Description("Recipient user ID")] Guid userId,
[Description("Message body")] string message,
CancellationToken ct) { ... }
[McpServerTool, Description("Lists notification templates.")]
[Authorize(Policy = "Notifications.Templates.Read")]
[McpToolOptions(ReadOnly = true)]
public async Task<string> ListTemplatesAsync(CancellationToken ct) { ... }
}

MapGranitMcpServer() applies Mcp.Server.Access on the HTTP endpoint itself. A user needs this permission to connect — then per-tool permissions apply on each invocation.

Inject ClaimsPrincipal for dynamic authorization:

[McpServerTool, Description("Returns patient medical history.")]
public async Task<string> GetMedicalHistoryAsync(
ClaimsPrincipal user,
IPermissionChecker permissionChecker,
[Description("Patient ID")] Guid patientId,
CancellationToken ct)
{
// Runtime check: user must have access to this specific patient
if (!await permissionChecker.IsGrantedAsync("Patients.Records.ReadSensitive"))
{
return "Access denied: insufficient permissions for sensitive records.";
}
var history = await reader.GetHistoryAsync(patientId, ct);
return JsonSerializer.Serialize(history);
}

Tool responses pass through a sanitization pipeline before reaching the AI agent. This is wired into the SDK’s AddCallToolFilter — not a parallel abstraction.

Mark sensitive properties on tool response DTOs:

public sealed class PatientResponse
{
public Guid Id { get; init; }
public string DisplayName { get; init; }
[McpRedact(Strategy = RedactionStrategy.Omit)]
public string Email { get; init; }
[McpRedact(Strategy = RedactionStrategy.Omit)]
public string PhoneNumber { get; init; }
[McpRedact(Strategy = RedactionStrategy.Hash)]
public string NationalId { get; init; }
}
StrategyBehaviorWhen to use
Omit (default)Remove property entirelyPII fields: email, phone, address
HashReplace with stable SHA-256Correlation IDs: national ID, SSN
MaskReplace with j***@***.comUI-facing tools only (not recommended for LLM)
SanitizerRegistered byBehavior
PropertyRedactionSanitizerGranit.McpRedacts well-known sensitive JSON properties (password, secret, apiKey, token, credential, ssn, connectionString) using Omit or Hash strategies
ResponseSizeLimitSanitizerGranit.McpTruncates responses > 50 KB (configurable via MaxResponseSizeBytes)
ErrorSanitizerGranit.Mcp.ServerStrips stack traces, connection strings, API keys (Bearer ey..., sk-...), internal paths, Vault tokens, and K8s hostnames from error responses

The PropertyRedactionSanitizer applies to all tool responses (not just errors). It scans JSON content blocks for property names matching known sensitive patterns and applies the appropriate RedactionStrategy. This works in addition to the [McpRedact] attribute — even if a developer forgets to annotate a DTO, well-known sensitive field names are caught.

Implement IMcpOutputSanitizer and register via DI:

internal sealed class AuditTrailSanitizer : IMcpOutputSanitizer
{
public ValueTask<CallToolResult> SanitizeAsync(
CallToolResult result,
IServiceProvider services,
CancellationToken ct)
{
// Custom sanitization logic
return ValueTask.FromResult(result);
}
}
// Registration:
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IMcpOutputSanitizer, AuditTrailSanitizer>());

Sanitizers compose in registration order — each receives the result from the previous sanitizer.

Granit.Authentication.DPoP validates Demonstration of Proof-of-Possession tokens via standard ASP.NET Core middleware. Since MapGranitMcpServer() applies .RequireAuthorization(), DPoP proofs are validated automatically on every MCP request — no MCP-specific configuration needed.

The MCP SDK supports server.ElicitAsync() for requesting user input directly from the end user, bypassing the AI agent. Use this for:

  • Destructive operations requiring confirmation
  • OAuth authorization flows
  • Sensitive data input (API keys, credentials)
[McpServerTool, Description("Permanently deletes all patient records for a tenant.")]
[Authorize(Policy = "Patients.Records.Manage")]
[McpToolOptions(Destructive = true)]
public async Task<string> PurgePatientRecordsAsync(
McpServer server,
ICurrentTenant tenant,
CancellationToken ct)
{
ElicitResult result = await server.ElicitAsync(new ElicitRequestParams
{
Message = $"Confirm permanent deletion of all patient records for tenant {tenant.Id}?",
RequestedSchema = new ElicitRequestParams.RequestSchema
{
Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition>
{
["confirm"] = new ElicitRequestParams.BooleanSchema
{
Description = "Type true to confirm irreversible deletion",
},
},
},
}, ct);
if (result.Action != ElicitResult.UserAction.Accept)
{
return "Operation cancelled by user.";
}
// Proceed with deletion...
return "Records purged successfully.";
}

Every tool invocation is recorded via the SDK’s AddCallToolFilter:

  • Metric: granit.mcp.tools.invoked with tenant_id, tool_name, status
  • Duration: granit.mcp.request.duration histogram
  • Activity: spans under Granit.Mcp activity source

For ISO 27001 compliance, integrate with Granit.Auditing by adding a custom IMcpOutputSanitizer that emits McpToolInvokedEvent via ILocalEventBus.