experimental-ext-interceptors/csharp/sdk/samples/InterceptorServerSample/ServerMutationInterceptors.cs
Peder Holdgaard Pedersen 45f9e0ef27
C# Implementation (#2)
C# implementation of SEP-1763

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 23:53:44 +00:00

394 lines
13 KiB
C#

using ModelContextProtocol.Interceptors;
using ModelContextProtocol.Interceptors.Server;
using ModelContextProtocol.Protocol;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
/// <summary>
/// Server-side mutation interceptors for MCP operations.
/// These interceptors transform requests and responses as they pass through
/// the interceptor service, enabling centralized data transformation.
/// </summary>
[McpServerInterceptorType]
public partial class ServerMutationInterceptors
{
// Patterns for sensitive data redaction
private static readonly Regex ApiKeyPattern = new(@"\b(sk_live_|sk_test_|api_key[=:]\s*|apikey[=:]\s*)[a-zA-Z0-9_-]+\b", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex PasswordPattern = new(@"(password|passwd|pwd|secret|token)[=:]\s*[^\s,;]+", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex BearerTokenPattern = new(@"Bearer\s+[a-zA-Z0-9._-]+", RegexOptions.Compiled);
private static readonly Regex SsnPattern = new(@"\b\d{3}-\d{2}-\d{4}\b", RegexOptions.Compiled);
private static readonly Regex CreditCardPattern = new(@"\b(?:\d{4}[-\s]?){3}\d{4}\b", RegexOptions.Compiled);
/// <summary>
/// Normalizes tool call arguments by trimming whitespace.
/// </summary>
[McpServerInterceptor(
Name = "argument-normalizer",
Description = "Normalizes tool call arguments by trimming whitespace",
Events = [InterceptorEvents.ToolsCall],
Phase = InterceptorPhase.Request,
PriorityHint = -100)] // Run early
public MutationInterceptorResult NormalizeArguments(JsonNode? payload)
{
if (payload is null)
{
return MutationInterceptorResult.Unchanged(payload);
}
CallToolRequestParams? toolCall;
try
{
toolCall = payload.Deserialize<CallToolRequestParams>();
}
catch (JsonException)
{
return MutationInterceptorResult.Unchanged(payload);
}
if (toolCall?.Arguments is null || toolCall.Arguments.Count == 0)
{
return MutationInterceptorResult.Unchanged(payload);
}
var modified = false;
var normalizedArgs = new Dictionary<string, object?>();
foreach (var arg in toolCall.Arguments)
{
var value = arg.Value;
if (value is JsonElement element && element.ValueKind == JsonValueKind.String)
{
var strValue = element.GetString();
var trimmed = strValue?.Trim();
if (trimmed != strValue)
{
normalizedArgs[arg.Key] = trimmed;
modified = true;
}
else
{
normalizedArgs[arg.Key] = value;
}
}
else
{
normalizedArgs[arg.Key] = value;
}
}
if (modified)
{
var mutatedPayload = new JsonObject
{
["name"] = toolCall.Name,
["arguments"] = JsonSerializer.SerializeToNode(normalizedArgs)
};
var result = MutationInterceptorResult.Mutated(mutatedPayload);
result.Info = new JsonObject { ["action"] = "trimmed whitespace from string arguments" };
return result;
}
return MutationInterceptorResult.Unchanged(payload);
}
/// <summary>
/// Redacts sensitive information from tool responses.
/// </summary>
[McpServerInterceptor(
Name = "response-redactor",
Description = "Redacts sensitive information from tool responses",
Events = [InterceptorEvents.ToolsCall],
Phase = InterceptorPhase.Response,
PriorityHint = 100)] // Run late
public MutationInterceptorResult RedactResponse(JsonNode? payload)
{
if (payload is null)
{
return MutationInterceptorResult.Unchanged(payload);
}
CallToolResult? result;
try
{
result = payload.Deserialize<CallToolResult>();
}
catch (JsonException)
{
return MutationInterceptorResult.Unchanged(payload);
}
if (result?.Content is null || result.Content.Count == 0)
{
return MutationInterceptorResult.Unchanged(payload);
}
var modified = false;
var redactions = new List<string>();
var newContent = new List<ContentBlock>();
foreach (var content in result.Content)
{
if (content is TextContentBlock textBlock)
{
var text = textBlock.Text ?? string.Empty;
var originalText = text;
// Redact API keys
if (ApiKeyPattern.IsMatch(text))
{
text = ApiKeyPattern.Replace(text, "[REDACTED_API_KEY]");
redactions.Add("api_key");
}
// Redact passwords/secrets
if (PasswordPattern.IsMatch(text))
{
text = PasswordPattern.Replace(text, "[REDACTED_SECRET]");
redactions.Add("password");
}
// Redact bearer tokens
if (BearerTokenPattern.IsMatch(text))
{
text = BearerTokenPattern.Replace(text, "Bearer [REDACTED_TOKEN]");
redactions.Add("bearer_token");
}
// Redact SSNs
if (SsnPattern.IsMatch(text))
{
text = SsnPattern.Replace(text, "XXX-XX-XXXX");
redactions.Add("ssn");
}
// Redact credit cards
if (CreditCardPattern.IsMatch(text))
{
text = CreditCardPattern.Replace(text, "XXXX-XXXX-XXXX-XXXX");
redactions.Add("credit_card");
}
if (text != originalText)
{
modified = true;
newContent.Add(new TextContentBlock { Text = text });
}
else
{
newContent.Add(content);
}
}
else
{
newContent.Add(content);
}
}
if (modified)
{
var mutatedResult = new CallToolResult
{
Content = newContent,
IsError = result.IsError
};
var mutatedPayload = JsonSerializer.SerializeToNode(mutatedResult);
var mutationResult = MutationInterceptorResult.Mutated(mutatedPayload);
mutationResult.Info = new JsonObject
{
["action"] = "redacted sensitive data",
["types"] = JsonNode.Parse($"[\"{string.Join("\", \"", redactions.Distinct())}\"]")
};
return mutationResult;
}
return MutationInterceptorResult.Unchanged(payload);
}
/// <summary>
/// Adds tracking metadata to tool requests.
/// </summary>
[McpServerInterceptor(
Name = "request-metadata-injector",
Description = "Adds tracking metadata to tool requests",
Events = [InterceptorEvents.ToolsCall],
Phase = InterceptorPhase.Request,
PriorityHint = 100)] // Run late
public MutationInterceptorResult InjectRequestMetadata(JsonNode? payload)
{
if (payload is not JsonObject obj)
{
return MutationInterceptorResult.Unchanged(payload);
}
// Clone the payload and add metadata
var mutatedPayload = obj.DeepClone() as JsonObject ?? new JsonObject();
// Add or update _meta object
if (mutatedPayload["_meta"] is not JsonObject meta)
{
meta = new JsonObject();
mutatedPayload["_meta"] = meta;
}
meta["interceptor_processed"] = true;
meta["interceptor_timestamp"] = DateTimeOffset.UtcNow.ToString("O");
meta["interceptor_id"] = Guid.NewGuid().ToString("N")[..8];
var result = MutationInterceptorResult.Mutated(mutatedPayload);
result.Info = new JsonObject { ["action"] = "added tracking metadata" };
return result;
}
/// <summary>
/// Sanitizes resource content by removing potentially dangerous HTML/script tags.
/// </summary>
[McpServerInterceptor(
Name = "content-sanitizer",
Description = "Sanitizes resource content by removing dangerous tags",
Events = [InterceptorEvents.ResourcesRead],
Phase = InterceptorPhase.Response,
PriorityHint = 50)]
public MutationInterceptorResult SanitizeContent(JsonNode? payload)
{
if (payload is null)
{
return MutationInterceptorResult.Unchanged(payload);
}
ReadResourceResult? result;
try
{
result = payload.Deserialize<ReadResourceResult>();
}
catch (JsonException)
{
return MutationInterceptorResult.Unchanged(payload);
}
if (result?.Contents is null || result.Contents.Count == 0)
{
return MutationInterceptorResult.Unchanged(payload);
}
var modified = false;
var sanitizations = new List<string>();
var newContents = new List<ResourceContents>();
foreach (var content in result.Contents)
{
if (content is TextResourceContents textContent)
{
var text = textContent.Text ?? string.Empty;
var originalText = text;
// Remove script tags
var scriptPattern = new Regex(@"<script[^>]*>[\s\S]*?</script>", RegexOptions.IgnoreCase);
if (scriptPattern.IsMatch(text))
{
text = scriptPattern.Replace(text, "[REMOVED_SCRIPT]");
sanitizations.Add("script_tags");
}
// Remove onclick/onerror handlers
var eventHandlerPattern = new Regex(@"\s+on\w+\s*=\s*[""'][^""']*[""']", RegexOptions.IgnoreCase);
if (eventHandlerPattern.IsMatch(text))
{
text = eventHandlerPattern.Replace(text, "");
sanitizations.Add("event_handlers");
}
// Remove javascript: URLs
var jsUrlPattern = new Regex(@"javascript\s*:", RegexOptions.IgnoreCase);
if (jsUrlPattern.IsMatch(text))
{
text = jsUrlPattern.Replace(text, "[REMOVED_JS_URL]");
sanitizations.Add("javascript_urls");
}
if (text != originalText)
{
modified = true;
newContents.Add(new TextResourceContents
{
Uri = textContent.Uri,
MimeType = textContent.MimeType,
Text = text
});
}
else
{
newContents.Add(content);
}
}
else
{
newContents.Add(content);
}
}
if (modified)
{
var mutatedResult = new ReadResourceResult
{
Contents = newContents
};
var mutatedPayload = JsonSerializer.SerializeToNode(mutatedResult);
var mutationResult = MutationInterceptorResult.Mutated(mutatedPayload);
mutationResult.Info = new JsonObject
{
["action"] = "sanitized content",
["removed"] = JsonNode.Parse($"[\"{string.Join("\", \"", sanitizations.Distinct())}\"]")
};
return mutationResult;
}
return MutationInterceptorResult.Unchanged(payload);
}
/// <summary>
/// Transforms tool names to enforce naming conventions.
/// </summary>
[McpServerInterceptor(
Name = "tool-name-normalizer",
Description = "Normalizes tool names to enforce naming conventions",
Events = [InterceptorEvents.ToolsCall],
Phase = InterceptorPhase.Request,
PriorityHint = -50)]
public MutationInterceptorResult NormalizeToolName(JsonNode? payload)
{
if (payload is not JsonObject obj)
{
return MutationInterceptorResult.Unchanged(payload);
}
var name = obj["name"]?.GetValue<string>();
if (string.IsNullOrEmpty(name))
{
return MutationInterceptorResult.Unchanged(payload);
}
// Normalize: lowercase, replace spaces with underscores
var normalizedName = name.ToLowerInvariant().Replace(" ", "_").Replace("-", "_");
if (normalizedName != name)
{
var mutatedPayload = obj.DeepClone() as JsonObject ?? new JsonObject();
mutatedPayload["name"] = normalizedName;
var result = MutationInterceptorResult.Mutated(mutatedPayload);
result.Info = new JsonObject
{
["action"] = "normalized tool name",
["original"] = name,
["normalized"] = normalizedName
};
return result;
}
return MutationInterceptorResult.Unchanged(payload);
}
}