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.

[SensitiveData] — unified data protection attribute

Section titled “[SensitiveData] — unified data protection attribute”

Granit uses a single cross-cutting attribute [SensitiveData] from Granit.DataProtection (in the Granit base package) to mark sensitive properties on any entity or DTO. The same annotation is consumed by auditing, MCP output sanitization, logging, and GDPR exports.

using Granit.DataProtection;
public sealed class PatientResponse
{
public Guid Id { get; init; }
public string DisplayName { get; init; }
[SensitiveData(Level = Sensitivity.Confidential)]
public string Email { get; init; }
[SensitiveData(Level = Sensitivity.Confidential)]
public string PhoneNumber { get; init; }
[SensitiveData(Level = Sensitivity.Restricted, Mode = SensitiveDataMode.Hash)]
public string NationalId { get; init; }
}
LevelExamplesMCP behavior
Internal (default)First name, last name, usernameNot redacted in MCP (below threshold)
ConfidentialEmail, phone, IP address, DOBRedactedMask by default
RestrictedPassword, token, SSN, health dataRedacted — use Omit or Hash
ModeBehaviorWhen to use
Mask (default)Replace with "***"PII fields: email, phone, address
OmitRemove property entirelySecrets: passwords, tokens, keys
HashReplace with stable SHA-256Correlation IDs: national ID, SSN
SanitizerRegistered byBehavior
PropertyRedactionSanitizerGranit.McpReads SensitivePropertyRegistry (auto-discovered from [SensitiveData] annotations at startup, threshold: Confidential+) plus well-known secret property names (password, secret, apiKey, token, credential, ssn, connectionString)
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). At startup, the SensitivePropertyRegistry scans all loaded Granit assemblies for [SensitiveData] properties and builds an O(1) lookup map. This means annotating an entity property in one module (e.g., UserCacheEntry.Email) automatically protects it in every MCP tool response — even if a developer forgets to design the DTO carefully.

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.