C# Implementation (#2)

C# implementation of SEP-1763

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Peder Holdgaard Pedersen 2026-01-28 00:53:44 +01:00 • committed by GitHub
commit 45f9e0ef27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 13862 additions and 21 deletions

View file

@ -0,0 +1,225 @@
using ModelContextProtocol.Interceptors;
using ModelContextProtocol.Interceptors.Client;
using ModelContextProtocol.Protocol;
using System.Text.Json;
using System.Text.Json.Nodes;
/// <summary>
/// Sample mutation interceptors for MCP client operations.
/// These interceptors demonstrate argument transformation and response filtering.
/// </summary>
[McpClientInterceptorType]
public class ClientMutationInterceptors
{
/// <summary>
/// Normalizes tool arguments by trimming whitespace and converting empty strings to null.
/// </summary>
[McpClientInterceptor(
Name = "argument-normalizer",
Description = "Normalizes tool arguments (trim whitespace, handle empty strings)",
Type = InterceptorType.Mutation,
Events = [InterceptorEvents.ToolsCall],
Phase = InterceptorPhase.Request,
PriorityHint = -100)] // Normalization runs before validation
public MutationInterceptorResult NormalizeArguments(JsonNode? payload)
{
if (payload is null)
{
return MutationInterceptorResult.Unchanged(payload);
}
// Parse the tool call
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);
}
bool modified = false;
var normalizedArgs = new Dictionary<string, JsonElement>();
foreach (var arg in toolCall.Arguments)
{
var value = arg.Value;
// Trim string values
if (value.ValueKind == JsonValueKind.String)
{
var stringValue = value.GetString();
if (stringValue is not null)
{
var trimmed = stringValue.Trim();
if (trimmed != stringValue)
{
modified = true;
// Convert empty strings to null
if (string.IsNullOrEmpty(trimmed))
{
normalizedArgs[arg.Key] = JsonDocument.Parse("null").RootElement;
}
else
{
normalizedArgs[arg.Key] = JsonDocument.Parse($"\"{EscapeJsonString(trimmed)}\"").RootElement;
}
continue;
}
}
}
normalizedArgs[arg.Key] = value;
}
if (!modified)
{
return MutationInterceptorResult.Unchanged(payload);
}
// Rebuild the payload with normalized arguments
var mutatedPayload = new JsonObject
{
["name"] = toolCall.Name,
["arguments"] = JsonSerializer.SerializeToNode(normalizedArgs)
};
if (toolCall.Meta is not null)
{
mutatedPayload["_meta"] = JsonSerializer.SerializeToNode(toolCall.Meta);
}
return MutationInterceptorResult.Mutated(mutatedPayload);
}
/// <summary>
/// Adds a timestamp to all outgoing tool call requests for audit purposes.
/// </summary>
[McpClientInterceptor(
Name = "request-timestamp",
Description = "Adds timestamp metadata to tool call requests",
Type = InterceptorType.Mutation,
Events = [InterceptorEvents.ToolsCall],
Phase = InterceptorPhase.Request,
PriorityHint = 100)] // Runs after normalization/validation
public MutationInterceptorResult AddTimestamp(JsonNode? payload)
{
if (payload is not JsonObject obj)
{
return MutationInterceptorResult.Unchanged(payload);
}
// Create or update _meta with timestamp
var meta = obj["_meta"]?.AsObject() ?? new JsonObject();
meta["clientTimestamp"] = DateTimeOffset.UtcNow.ToString("o");
meta["clientVersion"] = "1.0.0";
obj["_meta"] = meta;
return MutationInterceptorResult.Mutated(obj);
}
/// <summary>
/// Redacts sensitive information from tool responses before returning to caller.
/// </summary>
[McpClientInterceptor(
Name = "response-redactor",
Description = "Redacts sensitive patterns from tool responses",
Type = InterceptorType.Mutation,
Events = [InterceptorEvents.ToolsCall],
Phase = InterceptorPhase.Response,
PriorityHint = 50)]
public MutationInterceptorResult RedactResponse(JsonNode? payload)
{
if (payload is null)
{
return MutationInterceptorResult.Unchanged(payload);
}
// Parse the result
CallToolResult? result;
try
{
result = payload.Deserialize<CallToolResult>();
}
catch (JsonException)
{
return MutationInterceptorResult.Unchanged(payload);
}
if (result?.Content is null)
{
return MutationInterceptorResult.Unchanged(payload);
}
bool modified = false;
var newContent = new List<ContentBlock>();
foreach (var content in result.Content)
{
if (content is TextContentBlock textBlock)
{
var redactedText = RedactSensitivePatterns(textBlock.Text);
if (redactedText != textBlock.Text)
{
modified = true;
newContent.Add(new TextContentBlock { Text = redactedText });
continue;
}
}
newContent.Add(content);
}
if (!modified)
{
return MutationInterceptorResult.Unchanged(payload);
}
// Rebuild result with redacted content
var mutatedResult = new JsonObject
{
["content"] = JsonSerializer.SerializeToNode(newContent),
["isError"] = result.IsError
};
return MutationInterceptorResult.Mutated(mutatedResult);
}
private static string RedactSensitivePatterns(string? text)
{
if (string.IsNullOrEmpty(text))
{
return text ?? string.Empty;
}
// Redact API keys (common patterns)
text = System.Text.RegularExpressions.Regex.Replace(
text,
@"(?i)(api[_-]?key|apikey|secret|password|token)[""']?\s*[:=]\s*[""']?[\w\-\.]+[""']?",
"$1=***REDACTED***");
// Redact bearer tokens
text = System.Text.RegularExpressions.Regex.Replace(
text,
@"(?i)bearer\s+[\w\-\.]+",
"Bearer ***REDACTED***");
return text;
}
private static string EscapeJsonString(string value)
{
return value
.Replace("\\", "\\\\")
.Replace("\"", "\\\"")
.Replace("\n", "\\n")
.Replace("\r", "\\r")
.Replace("\t", "\\t");
}
}

View file

@ -0,0 +1,155 @@
using ModelContextProtocol.Interceptors;
using ModelContextProtocol.Interceptors.Client;
using ModelContextProtocol.Protocol;
using System.Text.Json;
using System.Text.Json.Nodes;
/// <summary>
/// Sample observability interceptors for MCP client operations.
/// These interceptors demonstrate logging, metrics collection, and tracing.
/// </summary>
[McpClientInterceptorType]
public class ClientObservabilityInterceptors
{
/// <summary>
/// Logs all outgoing tool call requests for audit purposes.
/// </summary>
[McpClientInterceptor(
Name = "request-logger",
Description = "Logs outgoing tool call requests",
Type = InterceptorType.Observability,
Events = [InterceptorEvents.ToolsCall],
Phase = InterceptorPhase.Request)]
public ObservabilityInterceptorResult LogRequest(JsonNode? payload, string @event)
{
if (payload is null)
{
Console.WriteLine($"[Observability] Request for '{@event}' - no payload");
return ObservabilityInterceptorResult.Success();
}
// Extract tool name for logging
string? toolName = null;
try
{
var toolCall = payload.Deserialize<CallToolRequestParams>();
toolName = toolCall?.Name;
}
catch
{
// Ignore deserialization errors
}
var timestamp = DateTimeOffset.UtcNow.ToString("HH:mm:ss.fff");
Console.WriteLine($"[{timestamp}] [REQUEST] Tool: {toolName ?? "unknown"} | Event: {@event}");
// Log argument keys (not values for privacy)
if (payload["arguments"] is JsonObject args)
{
var argKeys = string.Join(", ", args.Select(a => a.Key));
Console.WriteLine($" Arguments: [{argKeys}]");
}
return new ObservabilityInterceptorResult
{
Observed = true,
Info = new JsonObject
{
["loggedAt"] = DateTimeOffset.UtcNow.ToString("o"),
["toolName"] = toolName
}
};
}
/// <summary>
/// Logs all incoming tool call responses for audit purposes.
/// </summary>
[McpClientInterceptor(
Name = "response-logger",
Description = "Logs incoming tool call responses",
Type = InterceptorType.Observability,
Events = [InterceptorEvents.ToolsCall],
Phase = InterceptorPhase.Response)]
public ObservabilityInterceptorResult LogResponse(JsonNode? payload, string @event)
{
var timestamp = DateTimeOffset.UtcNow.ToString("HH:mm:ss.fff");
if (payload is null)
{
Console.WriteLine($"[{timestamp}] [RESPONSE] Event: {@event} - no payload");
return ObservabilityInterceptorResult.Success();
}
// Extract result info for logging
bool? isError = null;
int contentCount = 0;
try
{
var result = payload.Deserialize<CallToolResult>();
isError = result?.IsError;
contentCount = result?.Content?.Count ?? 0;
}
catch
{
// Ignore deserialization errors
}
var status = isError == true ? "ERROR" : "SUCCESS";
Console.WriteLine($"[{timestamp}] [RESPONSE] Status: {status} | Content blocks: {contentCount}");
return new ObservabilityInterceptorResult
{
Observed = true,
Info = new JsonObject
{
["loggedAt"] = DateTimeOffset.UtcNow.ToString("o"),
["isError"] = isError,
["contentCount"] = contentCount
}
};
}
/// <summary>
/// Collects metrics about tool list operations.
/// </summary>
[McpClientInterceptor(
Name = "tools-list-metrics",
Description = "Collects metrics about tools list operations",
Type = InterceptorType.Observability,
Events = [InterceptorEvents.ToolsList],
Phase = InterceptorPhase.Response)]
public ObservabilityInterceptorResult CollectToolsMetrics(JsonNode? payload)
{
if (payload is null)
{
return ObservabilityInterceptorResult.Success();
}
// Count tools in response
int toolCount = 0;
try
{
if (payload["tools"] is JsonArray tools)
{
toolCount = tools.Count;
}
}
catch
{
// Ignore errors
}
var timestamp = DateTimeOffset.UtcNow.ToString("HH:mm:ss.fff");
Console.WriteLine($"[{timestamp}] [METRICS] Tools discovered: {toolCount}");
return new ObservabilityInterceptorResult
{
Observed = true,
Info = new JsonObject
{
["toolCount"] = toolCount,
["collectedAt"] = DateTimeOffset.UtcNow.ToString("o")
}
};
}
}

View file

@ -0,0 +1,166 @@
using ModelContextProtocol.Interceptors;
using ModelContextProtocol.Interceptors.Client;
using ModelContextProtocol.Protocol;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
/// <summary>
/// Sample validation interceptors for MCP client operations.
/// These interceptors demonstrate PII detection and request validation.
/// </summary>
[McpClientInterceptorType]
public partial class ClientValidationInterceptors
{
// Common PII patterns
private static readonly Regex SsnPattern = SsnRegex();
private static readonly Regex EmailPattern = EmailRegex();
private static readonly Regex CreditCardPattern = CreditCardRegex();
/// <summary>
/// Validates outgoing tool call arguments for PII (Personally Identifiable Information).
/// Blocks requests containing SSN, email addresses, or credit card numbers.
/// </summary>
[McpClientInterceptor(
Name = "pii-validator",
Description = "Validates tool call arguments for PII leakage",
Events = [InterceptorEvents.ToolsCall],
Phase = InterceptorPhase.Request,
PriorityHint = -1000)] // Security interceptors run early
public ValidationInterceptorResult ValidatePii(JsonNode? payload)
{
if (payload is null)
{
return ValidationInterceptorResult.Success();
}
var messages = new List<ValidationMessage>();
// Parse as tool call request to inspect arguments
CallToolRequestParams? toolCall;
try
{
toolCall = payload.Deserialize<CallToolRequestParams>();
}
catch (JsonException)
{
return ValidationInterceptorResult.Success(); // Can't parse, let other validators handle
}
if (toolCall?.Arguments is null)
{
return ValidationInterceptorResult.Success();
}
// Check each argument for PII
foreach (var arg in toolCall.Arguments)
{
var value = arg.Value.ToString() ?? string.Empty;
if (SsnPattern.IsMatch(value))
{
messages.Add(new ValidationMessage
{
Path = $"arguments.{arg.Key}",
Message = "Social Security Number detected - PII not allowed in tool arguments",
Severity = ValidationSeverity.Error
});
}
if (CreditCardPattern.IsMatch(value))
{
messages.Add(new ValidationMessage
{
Path = $"arguments.{arg.Key}",
Message = "Credit card number detected - PII not allowed in tool arguments",
Severity = ValidationSeverity.Error
});
}
// Email is a warning, not an error
if (EmailPattern.IsMatch(value))
{
messages.Add(new ValidationMessage
{
Path = $"arguments.{arg.Key}",
Message = "Email address detected - consider if PII is necessary",
Severity = ValidationSeverity.Warn
});
}
}
if (messages.Count > 0)
{
var maxSeverity = messages.Max(m => m.Severity);
return new ValidationInterceptorResult
{
Valid = maxSeverity != ValidationSeverity.Error,
Severity = maxSeverity,
Messages = messages
};
}
return ValidationInterceptorResult.Success();
}
/// <summary>
/// Validates that tool responses don't contain error indicators that should be handled.
/// </summary>
[McpClientInterceptor(
Name = "response-validator",
Description = "Validates tool call responses for errors",
Events = [InterceptorEvents.ToolsCall],
Phase = InterceptorPhase.Response)]
public ValidationInterceptorResult ValidateResponse(JsonNode? payload)
{
if (payload is null)
{
return ValidationInterceptorResult.Success();
}
// Parse as tool call result
CallToolResult? result;
try
{
result = payload.Deserialize<CallToolResult>();
}
catch (JsonException)
{
return ValidationInterceptorResult.Success();
}
if (result?.IsError == true)
{
// Extract error message from content if available
var errorMessage = result.Content?.FirstOrDefault() switch
{
TextContentBlock text => text.Text,
_ => "Tool execution failed"
};
return new ValidationInterceptorResult
{
Valid = false,
Severity = ValidationSeverity.Warn, // Warning so it doesn't block, but is logged
Messages = [new() { Message = $"Tool returned error: {errorMessage}", Severity = ValidationSeverity.Warn }]
};
}
return ValidationInterceptorResult.Success();
}
#if NET
[GeneratedRegex(@"\b\d{3}-\d{2}-\d{4}\b")]
private static partial Regex SsnRegex();
[GeneratedRegex(@"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")]
private static partial Regex EmailRegex();
[GeneratedRegex(@"\b(?:\d{4}[-\s]?){3}\d{4}\b")]
private static partial Regex CreditCardRegex();
#else
private static Regex SsnRegex() => new(@"\b\d{3}-\d{2}-\d{4}\b", RegexOptions.Compiled);
private static Regex EmailRegex() => new(@"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b", RegexOptions.Compiled);
private static Regex CreditCardRegex() => new(@"\b(?:\d{4}[-\s]?){3}\d{4}\b", RegexOptions.Compiled);
#endif
}

View file

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0-preview.*" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ModelContextProtocol.Interceptors\ModelContextProtocol.Interceptors.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,225 @@
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Interceptors;
using ModelContextProtocol.Interceptors.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.IO.Pipelines;
using System.Text.Json.Nodes;
// =============================================================================
// MCP Interceptors Client Sample
// =============================================================================
// This sample demonstrates how to use the InterceptingMcpClient to automatically
// execute interceptor chains for MCP tool operations.
//
// The sample:
// 1. Creates an in-memory MCP server with sample tools
// 2. Creates an MCP client with interceptors using InterceptingMcpClient
// 3. Demonstrates validation, mutation, and observability interceptors
// 4. Shows error handling with McpInterceptorValidationException
// =============================================================================
Console.WriteLine("=== MCP Interceptors Client Sample ===\n");
// Set up in-memory transport using pipes
Pipe clientToServerPipe = new(), serverToClientPipe = new();
// Create server with sample tools
await using McpServer server = McpServer.Create(
new StreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()),
new McpServerOptions
{
ServerInfo = new() { Name = "SampleServer", Version = "1.0.0" },
ToolCollection =
[
// Echo tool - returns what you send
McpServerTool.Create(
(string message) => $"Echo: {message}",
new() { Name = "echo", Description = "Echoes the message back" }),
// Greet tool - creates a greeting
McpServerTool.Create(
(string name, string? title = null) =>
title is not null ? $"Hello, {title} {name}!" : $"Hello, {name}!",
new() { Name = "greet", Description = "Creates a greeting" }),
// Search tool - simulates a search operation
McpServerTool.Create(
(string query) => $"Search results for: {query}\n- Result 1\n- Result 2\n- Result 3",
new() { Name = "search", Description = "Searches for content" }),
// Sensitive tool - returns data that should be redacted
McpServerTool.Create(
() => "API Response: api_key=sk_live_abc123 and token=Bearer xyz789",
new() { Name = "get_config", Description = "Gets configuration (contains sensitive data)" }),
]
});
// Start server in background
_ = server.RunAsync();
// Create the base MCP client
await using McpClient client = await McpClient.CreateAsync(
new StreamClientTransport(clientToServerPipe.Writer.AsStream(), serverToClientPipe.Reader.AsStream()));
Console.WriteLine($"Connected to server: {client.ServerInfo.Name} v{client.ServerInfo.Version}\n");
// =============================================================================
// Create interceptors using both attribute-based classes and inline delegates
// =============================================================================
// Collect interceptors from attributed classes
var attributeBasedInterceptors = new List<McpClientInterceptor>();
attributeBasedInterceptors.AddRange(McpClientInterceptorExtensions.WithInterceptors<ClientValidationInterceptors>());
attributeBasedInterceptors.AddRange(McpClientInterceptorExtensions.WithInterceptors<ClientMutationInterceptors>());
attributeBasedInterceptors.AddRange(McpClientInterceptorExtensions.WithInterceptors<ClientObservabilityInterceptors>());
Console.WriteLine($"Loaded {attributeBasedInterceptors.Count} attribute-based interceptors:");
foreach (var interceptor in attributeBasedInterceptors)
{
var proto = interceptor.ProtocolInterceptor;
var priority = proto.PriorityHint?.Request ?? proto.PriorityHint?.Response ?? 0;
Console.WriteLine($" - {proto.Name} ({proto.Type}, priority: {priority})");
}
Console.WriteLine();
// =============================================================================
// Wrap the client with interceptors
// =============================================================================
var interceptedClient = client.WithInterceptors(new InterceptingMcpClientOptions
{
Interceptors = attributeBasedInterceptors,
DefaultTimeoutMs = 10000,
ThrowOnValidationError = true,
InterceptResponses = true
});
// =============================================================================
// Demo 1: List tools (shows observability interceptor metrics)
// =============================================================================
Console.WriteLine("--- Demo 1: List Tools ---");
var tools = await interceptedClient.ListToolsAsync();
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}\n");
// =============================================================================
// Demo 2: Normal tool call (shows request/response logging)
// =============================================================================
Console.WriteLine("--- Demo 2: Normal Tool Call ---");
var echoResult = await interceptedClient.CallToolAsync("echo", new Dictionary<string, object?>
{
["message"] = "Hello from intercepted client!"
});
PrintResult(echoResult);
// =============================================================================
// Demo 3: Tool call with argument normalization (mutation)
// =============================================================================
Console.WriteLine("--- Demo 3: Argument Normalization (Mutation) ---");
var greetResult = await interceptedClient.CallToolAsync("greet", new Dictionary<string, object?>
{
["name"] = " John Doe ", // Will be trimmed
["title"] = " " // Will be converted to null (empty after trim)
});
PrintResult(greetResult);
// =============================================================================
// Demo 4: Response redaction (mutation on response)
// =============================================================================
Console.WriteLine("--- Demo 4: Response Redaction ---");
var configResult = await interceptedClient.CallToolAsync("get_config", new Dictionary<string, object?>());
PrintResult(configResult);
// =============================================================================
// Demo 5: PII Detection - Email Warning (validation with warning)
// =============================================================================
Console.WriteLine("--- Demo 5: PII Warning (Email) ---");
var searchWithEmail = await interceptedClient.CallToolAsync("search", new Dictionary<string, object?>
{
["query"] = "contact john@example.com for details"
});
PrintResult(searchWithEmail);
// =============================================================================
// Demo 6: PII Detection - SSN Error (validation blocks request)
// =============================================================================
Console.WriteLine("--- Demo 6: PII Error (SSN - Blocked) ---");
try
{
var searchWithSsn = await interceptedClient.CallToolAsync("search", new Dictionary<string, object?>
{
["query"] = "user SSN is 123-45-6789"
});
PrintResult(searchWithSsn);
}
catch (McpInterceptorValidationException ex)
{
Console.WriteLine($" REQUEST BLOCKED!");
Console.WriteLine($" Blocked by: {ex.AbortedAt?.Interceptor}");
Console.WriteLine($" Reason: {ex.AbortedAt?.Reason}");
Console.WriteLine($" Chain status: {ex.ChainResult.Status}");
Console.WriteLine();
// Show detailed validation messages
Console.WriteLine(" Validation details:");
Console.WriteLine(ex.GetDetailedMessage());
}
// =============================================================================
// Demo 7: Using non-throwing mode
// =============================================================================
Console.WriteLine("--- Demo 7: Non-Throwing Mode ---");
var nonThrowingClient = client.WithInterceptors(new InterceptingMcpClientOptions
{
Interceptors = attributeBasedInterceptors,
ThrowOnValidationError = false // Don't throw, return error result instead
});
var blockedResult = await nonThrowingClient.CallToolAsync("search", new Dictionary<string, object?>
{
["query"] = "credit card 4111-1111-1111-1111"
});
Console.WriteLine($" IsError: {blockedResult.IsError}");
if (blockedResult.Content?.FirstOrDefault() is TextContentBlock text)
{
Console.WriteLine($" Message: {text.Text}");
}
Console.WriteLine();
// =============================================================================
// Demo 8: Accessing inner client for non-intercepted operations
// =============================================================================
Console.WriteLine("--- Demo 8: Accessing Inner Client ---");
Console.WriteLine($" Server capabilities via inner client:");
Console.WriteLine($" Tools: {interceptedClient.ServerCapabilities.Tools is not null}");
Console.WriteLine($" Server info: {interceptedClient.ServerInfo.Name} v{interceptedClient.ServerInfo.Version}");
Console.WriteLine($" Inner client type: {interceptedClient.Inner.GetType().Name}");
Console.WriteLine();
Console.WriteLine("=== Sample Complete ===");
// Helper to print results
static void PrintResult(CallToolResult result)
{
Console.WriteLine($" IsError: {result.IsError}");
if (result.Content is not null)
{
foreach (var content in result.Content)
{
if (content is TextContentBlock textBlock)
{
Console.WriteLine($" Content: {textBlock.Text}");
}
}
}
Console.WriteLine();
}