C# Re-implementation (#5)
Re-implementation of C# interceptors SDK with clearer focus on server-side / gateway interceptors. Fixes a lot of issues with the existing implementation. Still more boilerplate than I'm happy with, but this is better than what was there. Will refine further in later PRs.
This commit is contained in:
parent
5bd7ab4d2a
commit
d01a626b76
5 changed files with 7887 additions and 12886 deletions
|
|
@ -1,225 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
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")
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,19 +1,16 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.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>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ModelContextProtocol" Version="1.1.0" />
|
||||
<ProjectReference Include="..\..\src\ModelContextProtocol.Interceptors\ModelContextProtocol.Interceptors.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -1,225 +1,164 @@
|
|||
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.Runtime.CompilerServices;
|
||||
using System.Text.Json.Nodes;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Interceptors.Client;
|
||||
using ModelContextProtocol.Interceptors.Protocol;
|
||||
|
||||
// =============================================================================
|
||||
// MCP Interceptors Client Sample
|
||||
// =============================================================================
|
||||
// This sample demonstrates how to use the InterceptingMcpClient to automatically
|
||||
// execute interceptor chains for MCP tool operations.
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Interceptor Client Sample
|
||||
//
|
||||
// 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
|
||||
// =============================================================================
|
||||
// Demonstrates the client-side interceptor API:
|
||||
// 1. Listing available interceptors
|
||||
// 2. Invoking a single interceptor directly
|
||||
// 3. Executing a full interceptor chain
|
||||
//
|
||||
// Uses the InterceptorServerSample as the backend.
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
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("=== MCP Interceptors Client Sample ===");
|
||||
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?>
|
||||
// 1. Connect to the interceptor server
|
||||
Console.WriteLine("[setup] Starting interceptor server...");
|
||||
var interceptorServerPath = Path.Combine(GetSourceDir(), "..", "InterceptorServerSample");
|
||||
await using var client = await McpClient.CreateAsync(
|
||||
new StdioClientTransport(new()
|
||||
{
|
||||
["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();
|
||||
Name = "InterceptorServer",
|
||||
Command = "dotnet",
|
||||
Arguments = ["run", "--project", interceptorServerPath],
|
||||
}));
|
||||
|
||||
// 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("[setup] Connected!");
|
||||
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)
|
||||
// ── Demo 1: List all interceptors ────────────────────────────────────
|
||||
Console.WriteLine("── Demo 1: List interceptors ──");
|
||||
var listResult = await client.ListInterceptorsAsync();
|
||||
foreach (var interceptor in listResult.Interceptors)
|
||||
{
|
||||
Console.WriteLine($" IsError: {result.IsError}");
|
||||
if (result.Content is not null)
|
||||
var hookSummary = string.Join("; ", interceptor.Hooks.Select(h => $"{h.Phase}:[{string.Join(",", h.Events)}]"));
|
||||
Console.WriteLine($" {interceptor.Name,-20} type={interceptor.Type,-15} hooks={hookSummary}");
|
||||
if (interceptor.Description is not null)
|
||||
{
|
||||
foreach (var content in result.Content)
|
||||
{
|
||||
if (content is TextContentBlock textBlock)
|
||||
{
|
||||
Console.WriteLine($" Content: {textBlock.Text}");
|
||||
}
|
||||
}
|
||||
Console.WriteLine($" {"",20} {interceptor.Description}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
// ── Demo 2: Invoke a single interceptor ──────────────────────────────
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("── Demo 2: Invoke email-redactor directly ──");
|
||||
|
||||
var cleanPayload = JsonNode.Parse("""{"name":"echo","arguments":{"message":"Contact alice@example.com"}}""")!;
|
||||
var invokeResult = await client.InvokeInterceptorAsync(new InvokeInterceptorRequestParams
|
||||
{
|
||||
Name = "email-redactor",
|
||||
Event = InterceptionEvents.ToolsCall,
|
||||
Phase = InterceptorPhase.Request,
|
||||
Payload = cleanPayload,
|
||||
});
|
||||
|
||||
if (invokeResult is MutationInterceptorResult mutation)
|
||||
{
|
||||
Console.WriteLine($" Modified: {mutation.Modified}");
|
||||
Console.WriteLine($" Payload: {mutation.Payload}");
|
||||
}
|
||||
|
||||
// ── Demo 3: Invoke pii-validator with clean data ─────────────────────
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("── Demo 3: Invoke pii-validator (clean data) ──");
|
||||
|
||||
var safePayload = JsonNode.Parse("""{"name":"echo","arguments":{"message":"Hello world"}}""")!;
|
||||
var validationResult = await client.InvokeInterceptorAsync(new InvokeInterceptorRequestParams
|
||||
{
|
||||
Name = "pii-validator",
|
||||
Event = InterceptionEvents.ToolsCall,
|
||||
Phase = InterceptorPhase.Request,
|
||||
Payload = safePayload,
|
||||
});
|
||||
|
||||
if (validationResult is ValidationInterceptorResult validation)
|
||||
{
|
||||
Console.WriteLine($" Valid: {validation.Valid}");
|
||||
}
|
||||
|
||||
// ── Demo 4: Invoke pii-validator with PII ────────────────────────────
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("── Demo 4: Invoke pii-validator (with PII) ──");
|
||||
|
||||
var piiPayload = JsonNode.Parse("""{"name":"echo","arguments":{"message":"My SSN is 123-45-6789"}}""")!;
|
||||
var piiResult = await client.InvokeInterceptorAsync(new InvokeInterceptorRequestParams
|
||||
{
|
||||
Name = "pii-validator",
|
||||
Event = InterceptionEvents.ToolsCall,
|
||||
Phase = InterceptorPhase.Request,
|
||||
Payload = piiPayload,
|
||||
});
|
||||
|
||||
if (piiResult is ValidationInterceptorResult piiValidation)
|
||||
{
|
||||
Console.WriteLine($" Valid: {piiValidation.Valid}");
|
||||
Console.WriteLine($" Severity: {piiValidation.Severity}");
|
||||
foreach (var msg in piiValidation.Messages ?? [])
|
||||
{
|
||||
Console.WriteLine($" Message: [{msg.Severity}] {msg.Message} (path: {msg.Path})");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Demo 5: Execute full chain ───────────────────────────────────────
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("── Demo 5: Execute chain (email + PII check) ──");
|
||||
|
||||
var chainPayload = JsonNode.Parse("""{"name":"echo","arguments":{"message":"Email bob@corp.com about SSN"}}""")!;
|
||||
var chainResult = await client.ExecuteChainAsync(new ExecuteChainRequestParams
|
||||
{
|
||||
Event = InterceptionEvents.ToolsCall,
|
||||
Phase = InterceptorPhase.Request,
|
||||
Payload = chainPayload,
|
||||
Context = new InvokeInterceptorContext
|
||||
{
|
||||
TraceId = Guid.NewGuid().ToString("N"),
|
||||
},
|
||||
});
|
||||
|
||||
Console.WriteLine($" Status: {chainResult.Status}");
|
||||
Console.WriteLine($" Duration: {chainResult.TotalDurationMs:F1}ms");
|
||||
Console.WriteLine($" Results: {chainResult.Results?.Count ?? 0} interceptor(s) ran");
|
||||
|
||||
if (chainResult.AbortedAt is { } abort)
|
||||
{
|
||||
Console.WriteLine($" Aborted at: {abort.Interceptor} ({abort.Reason})");
|
||||
}
|
||||
|
||||
foreach (var r in chainResult.Results ?? [])
|
||||
{
|
||||
var typeName = r switch
|
||||
{
|
||||
ValidationInterceptorResult => "validation",
|
||||
MutationInterceptorResult => "mutation",
|
||||
SinkInterceptorResult => "sink",
|
||||
_ => "unknown",
|
||||
};
|
||||
Console.WriteLine($" {r.InterceptorName,-20} type={typeName,-15} duration={r.DurationMs:F1}ms");
|
||||
}
|
||||
|
||||
// ── Demo 6: Execute chain with clean data ────────────────────────────
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("── Demo 6: Execute chain (clean data, should pass) ──");
|
||||
|
||||
var cleanChainPayload = JsonNode.Parse("""{"name":"echo","arguments":{"message":"Contact bob@corp.com please"}}""")!;
|
||||
var cleanChainResult = await client.ExecuteChainAsync(new ExecuteChainRequestParams
|
||||
{
|
||||
Event = InterceptionEvents.ToolsCall,
|
||||
Phase = InterceptorPhase.Request,
|
||||
Payload = cleanChainPayload,
|
||||
});
|
||||
|
||||
Console.WriteLine($" Status: {cleanChainResult.Status}");
|
||||
Console.WriteLine($" Final payload: {cleanChainResult.FinalPayload}");
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("=== Done ===");
|
||||
|
||||
static string GetSourceDir([CallerFilePath] string? path = null) =>
|
||||
Path.GetDirectoryName(path) ?? throw new InvalidOperationException();
|
||||
|
|
|
|||
Loading…
Reference in a new issue