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:
Peder Holdgaard Pedersen 2026-05-14 15:11:41 +02:00 • committed by GitHub
commit d01a626b76
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 7887 additions and 12886 deletions

View file

@ -0,0 +1,52 @@
using System.Text.Json.Nodes;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Interceptors.Protocol;
using ModelContextProtocol.Interceptors.Server;
namespace AvatarMoodInterceptorSample;
/// <summary>
/// A sink interceptor that classifies conversational mood from each
/// <c>llm/completion</c> response using a smaller, secondary model (Haiku) and
/// updates a console avatar. Demonstrates that sink interceptors can do
/// real work — driving downstream state — without gating the main conversation.
/// </summary>
[McpServerInterceptorType]
public sealed class AvatarInterceptors
{
private readonly IChatClient _haiku;
private readonly string _haikuModelId;
private readonly AvatarState _state;
public AvatarInterceptors(IChatClient haiku, string haikuModelId, AvatarState state)
{
_haiku = haiku;
_haikuModelId = haikuModelId;
_state = state;
}
[McpServerInterceptor(
Name = "avatar-mood",
Description = "Classifies conversation mood via a secondary model and updates the avatar state.",
Type = InterceptorType.Sink,
Events = [InterceptionEvents.LlmCompletion],
Phase = InterceptorPhase.Response)]
public SinkInterceptorResult OnLlmCompletion(
JsonNode payload,
string @event,
InterceptorPhase phase,
InvokeInterceptorContext? context,
CancellationToken cancellationToken)
{
// Sink contract: parallel, fire-and-forget, failures swallowed.
// We return immediately so the main conversation never waits on Haiku.
_ = Task.Run(async () =>
{
var verdict = await MoodClassifier.ClassifyAsync(_haiku, payload, cancellationToken);
_state.Update(verdict.Label, verdict.Confidence, _haikuModelId);
AvatarRenderer.Render(_state);
}, cancellationToken);
return new SinkInterceptorResult { Recorded = true };
}
}

View file

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<UserSecretsId>avatar-mood-interceptor-sample</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Anthropic" Version="12.8.0" />
<PackageReference Include="Microsoft.Extensions.AI" Version="10.3.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0" />
<PackageReference Include="ModelContextProtocol" Version="1.1.0" />
<ProjectReference Include="..\..\src\ModelContextProtocol.Interceptors\ModelContextProtocol.Interceptors.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,106 @@
namespace AvatarMoodInterceptorSample;
/// <summary>
/// Renders the avatar pinned to the top of the terminal while the REPL
/// scrolls underneath. Uses ANSI escape sequences (DECSTBM scroll region
/// + cursor save/restore) so avatar updates redraw in place without
/// disturbing the conversation below.
/// </summary>
public static class AvatarRenderer
{
// Rows reserved at the top of the screen for the avatar block.
// 4 face lines + 1 status line + 1 separator = 6.
public const int ReservedRows = 6;
private static readonly object ConsoleLock = new();
private static string FaceFor(Mood mood) => mood switch
{
Mood.Happy => "^_^",
Mood.Curious => "o_O",
Mood.Focused => "-_-",
Mood.Confused => "?_?",
Mood.Frustrated => ">_<",
_ => "._.",
};
// ANSI 256-color foreground codes per mood.
private static string ColorFor(Mood mood) => mood switch
{
Mood.Happy => "\x1b[92m", // bright green
Mood.Curious => "\x1b[96m", // bright cyan
Mood.Focused => "\x1b[94m", // bright blue
Mood.Confused => "\x1b[93m", // bright yellow
Mood.Frustrated => "\x1b[91m", // bright red
_ => "\x1b[90m", // bright black (gray)
};
/// <summary>
/// Clears the screen, reserves the top rows for the avatar, and parks
/// the cursor below so normal Console writes scroll in the lower region.
/// </summary>
public static void Initialize()
{
lock (ConsoleLock)
{
var bottom = Math.Max(Console.WindowHeight, ReservedRows + 4);
// Clear screen + home cursor.
Console.Out.Write("\x1b[2J\x1b[H");
// DECSTBM: restrict scrolling to rows (ReservedRows+1)..bottom.
Console.Out.Write($"\x1b[{ReservedRows + 1};{bottom}r");
// Move cursor to the first row of the scrolling region.
Console.Out.Write($"\x1b[{ReservedRows + 1};1H");
Console.Out.Flush();
}
}
/// <summary>
/// Restores the terminal to a normal full-screen scroll region.
/// Call before exiting.
/// </summary>
public static void Reset()
{
lock (ConsoleLock)
{
// Reset scroll region to full screen.
Console.Out.Write("\x1b[r");
// Park cursor at bottom.
Console.Out.Write($"\x1b[{Console.WindowHeight};1H");
Console.Out.Flush();
}
}
public static void Render(AvatarState state)
{
var mood = state.Current;
var label = mood.ToString().ToLowerInvariant();
var face = FaceFor(mood);
var color = ColorFor(mood);
var reset = "\x1b[0m";
var model = state.ClassifierModel ?? "(pending)";
lock (ConsoleLock)
{
// Save cursor (scroll region + bottom-half cursor both preserved).
Console.Out.Write("\x1b[s");
// Redraw each avatar row in place. \x1b[2K clears the whole line.
WriteRow(1, $"{color} ___{reset}");
WriteRow(2, $"{color} / \\{reset}");
WriteRow(3, $"{color} | {face} |{reset} mood: {label,-11} confidence: {state.Confidence:F2} via: {model}");
WriteRow(4, $"{color} \\___/{reset}");
WriteRow(5, string.Empty);
WriteRow(6, new string('─', Math.Max(40, Console.WindowWidth - 1)));
// Restore cursor to the REPL's position below.
Console.Out.Write("\x1b[u");
Console.Out.Flush();
}
}
private static void WriteRow(int row, string content)
{
Console.Out.Write($"\x1b[{row};1H\x1b[2K{content}");
}
}

View file

@ -0,0 +1,36 @@
namespace AvatarMoodInterceptorSample;
public enum Mood
{
Neutral,
Happy,
Curious,
Focused,
Confused,
Frustrated,
}
public sealed class AvatarState
{
private readonly object _lock = new();
private readonly List<Mood> _history = [];
public Mood Current { get; private set; } = Mood.Neutral;
public float Confidence { get; private set; }
public string? ClassifierModel { get; private set; }
public IReadOnlyList<Mood> History
{
get { lock (_lock) { return _history.ToArray(); } }
}
public void Update(Mood mood, float confidence, string? classifierModel)
{
lock (_lock)
{
Current = mood;
Confidence = confidence;
ClassifierModel = classifierModel;
_history.Add(mood);
}
}
}

View file

@ -0,0 +1,130 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Interceptors.Protocol;
namespace AvatarMoodInterceptorSample;
public sealed record MoodVerdict(Mood Label, float Confidence);
public static class MoodClassifier
{
private const string SystemPrompt =
"You classify the dominant mood the USER is expressing in a short conversation. " +
"Respond with ONLY a single JSON object and nothing else, matching this shape: " +
"{\"mood\":\"neutral|happy|curious|focused|confused|frustrated\",\"confidence\":0.0-1.0}. " +
"Pick exactly one mood. No prose, no code fences.";
public static async Task<MoodVerdict> ClassifyAsync(
IChatClient haiku,
JsonNode payload,
CancellationToken cancellationToken)
{
var transcript = BuildTranscript(payload);
if (string.IsNullOrWhiteSpace(transcript))
{
return new MoodVerdict(Mood.Neutral, 0f);
}
var messages = new List<ChatMessage>
{
new(ChatRole.System, SystemPrompt),
new(ChatRole.User, transcript),
};
var options = new ChatOptions
{
MaxOutputTokens = 64,
Temperature = 0f,
};
ChatResponse response;
try
{
response = await haiku.GetResponseAsync(messages, options, cancellationToken);
}
catch
{
return new MoodVerdict(Mood.Neutral, 0f);
}
return Parse(response.Text);
}
private static string BuildTranscript(JsonNode payload)
{
// Response-phase payload has `message` (assistant reply) plus `metadata`.
// We also accept the companion request payload's `messages` array when metadata carries it.
// Keep the classifier input tight: last user turn + assistant reply is usually enough.
var node = payload.AsObject();
string? assistantText = null;
if (node.TryGetPropertyValue("message", out var msgNode) && msgNode is JsonObject msgObj)
{
assistantText = msgObj["content"]?.GetValue<string>();
}
var lines = new List<string>();
if (node.TryGetPropertyValue("metadata", out var metaNode)
&& metaNode is JsonObject meta
&& meta.TryGetPropertyValue("recentMessages", out var recent)
&& recent is JsonArray recentArr)
{
foreach (var item in recentArr)
{
if (item is JsonObject m)
{
var role = m["role"]?.GetValue<string>() ?? "user";
var content = m["content"]?.GetValue<string>() ?? "";
if (!string.IsNullOrWhiteSpace(content))
{
lines.Add($"{role}: {content}");
}
}
}
}
if (assistantText is not null)
{
lines.Add($"assistant: {assistantText}");
}
return string.Join('\n', lines);
}
private static MoodVerdict Parse(string? text)
{
if (string.IsNullOrWhiteSpace(text)) return new MoodVerdict(Mood.Neutral, 0f);
var start = text.IndexOf('{');
var end = text.LastIndexOf('}');
if (start < 0 || end <= start) return new MoodVerdict(Mood.Neutral, 0f);
var json = text.Substring(start, end - start + 1);
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var mood = root.TryGetProperty("mood", out var m) ? m.GetString() : null;
var confidence = root.TryGetProperty("confidence", out var c) && c.ValueKind == JsonValueKind.Number
? (float)c.GetDouble()
: 0f;
return new MoodVerdict(ParseMood(mood), Math.Clamp(confidence, 0f, 1f));
}
catch
{
return new MoodVerdict(Mood.Neutral, 0f);
}
}
private static Mood ParseMood(string? s) => s?.ToLowerInvariant() switch
{
"happy" => Mood.Happy,
"curious" => Mood.Curious,
"focused" => Mood.Focused,
"confused" => Mood.Confused,
"frustrated" => Mood.Frustrated,
_ => Mood.Neutral,
};
}

View file

@ -0,0 +1,145 @@
// ──────────────────────────────────────────────────────────────────────
// Avatar Mood Interceptor Sample
//
// Demonstrates sink interceptors as PARALLEL CONTEXT-ANALYSIS
// PIPELINES — not the usual "guardrail" framing (PII, validation, audit).
//
// The primary conversation runs against Claude Sonnet. After each reply,
// we fire an `llm/completion` response-phase event into the avatar-mood
// sink interceptor. The interceptor calls a smaller, faster
// model (Haiku) in the background to classify the user's mood, then
// updates a console avatar. The main conversation is never blocked:
// the sink contract is fire-and-forget with exceptions swallowed.
//
// For clarity, this sample runs the driver and the interceptor in the
// same process, invoking the interceptor method directly. In a real
// deployment the interceptor would be hosted by an MCP interceptor
// server (see InterceptorServerSample) and dispatched by a gateway
// (see TransparentProxySample / ConfigDrivenGatewaySample) — the
// sink semantics are identical either way. We skip that
// plumbing here to keep the pattern center-stage.
// ──────────────────────────────────────────────────────────────────────
using System.Text.Json;
using System.Text.Json.Nodes;
using Anthropic;
using AvatarMoodInterceptorSample;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
using ModelContextProtocol.Interceptors.Protocol;
const string SonnetModelId = "claude-sonnet-4-6";
const string HaikuModelId = "claude-haiku-4-5-20251001";
var config = new ConfigurationBuilder()
.AddEnvironmentVariables()
.AddUserSecrets<Program>(optional: true)
.Build();
var apiKey = config["ANTHROPIC_API_KEY"];
if (string.IsNullOrWhiteSpace(apiKey))
{
Console.Error.WriteLine("error: set ANTHROPIC_API_KEY (env var or user secret).");
return 1;
}
using var sonnet = new AnthropicClient(new() { ApiKey = apiKey })
.AsIChatClient(SonnetModelId)
.AsBuilder()
.Build();
using var haiku = new AnthropicClient(new() { ApiKey = apiKey })
.AsIChatClient(HaikuModelId)
.AsBuilder()
.Build();
var avatarState = new AvatarState();
var interceptors = new AvatarInterceptors(haiku, HaikuModelId, avatarState);
AvatarRenderer.Initialize();
Console.CancelKeyPress += (_, e) =>
{
AvatarRenderer.Reset();
// let default handler terminate the process
};
AppDomain.CurrentDomain.ProcessExit += (_, _) => AvatarRenderer.Reset();
AvatarRenderer.Render(avatarState);
Console.WriteLine("=== Avatar Mood Interceptor Sample ===");
Console.WriteLine($"primary: {SonnetModelId} mood classifier: {HaikuModelId}");
Console.WriteLine("type a message and press enter. 'exit' to quit.");
Console.WriteLine();
var history = new List<ChatMessage>();
var sonnetOptions = new ChatOptions { MaxOutputTokens = 1024 };
while (true)
{
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write("> ");
Console.ResetColor();
var input = Console.ReadLine();
if (input is null) break;
if (string.IsNullOrWhiteSpace(input)) continue;
if (string.Equals(input.Trim(), "exit", StringComparison.OrdinalIgnoreCase)) break;
history.Add(new ChatMessage(ChatRole.User, input));
ChatResponse response;
try
{
response = await sonnet.GetResponseAsync(history, sonnetOptions);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[sonnet] error: {ex.Message}");
history.RemoveAt(history.Count - 1);
continue;
}
var replyText = response.Text ?? string.Empty;
history.Add(new ChatMessage(ChatRole.Assistant, replyText));
Console.WriteLine(replyText);
// Synthesize an llm/completion response-phase payload and dispatch it
// to the sink interceptor. The method returns immediately;
// the Haiku call and avatar update happen on a background task.
var payload = BuildCompletionPayload(SonnetModelId, input, replyText, response);
_ = interceptors.OnLlmCompletion(
payload,
InterceptionEvents.LlmCompletion,
InterceptorPhase.Response,
new InvokeInterceptorContext { TraceId = Guid.NewGuid().ToString("N") },
cancellationToken: default);
}
AvatarRenderer.Reset();
return 0;
static JsonNode BuildCompletionPayload(string modelId, string userInput, string assistantReply, ChatResponse response)
{
var recent = new JsonArray
{
new JsonObject { ["role"] = "user", ["content"] = userInput },
};
var payload = new LlmCompletionResponsePayload
{
Model = modelId,
Message = new LlmMessage { Role = "assistant", Content = assistantReply },
StopReason = response.FinishReason?.ToString(),
Usage = response.Usage is { } u
? new LlmUsage
{
InputTokens = (int?)u.InputTokenCount,
OutputTokens = (int?)u.OutputTokenCount,
}
: null,
Metadata = new JsonObject { ["recentMessages"] = recent },
};
return JsonSerializer.SerializeToNode(payload)!;
}

View file

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ModelContextProtocol" Version="1.1.0" />
<ProjectReference Include="..\..\src\ModelContextProtocol.Interceptors\ModelContextProtocol.Interceptors.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="mcp-interceptors.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View file

@ -0,0 +1,124 @@
using System.Runtime.CompilerServices;
using System.Text.Json;
using ModelContextProtocol.Client;
using ModelContextProtocol.Interceptors.Gateway;
using ModelContextProtocol.Interceptors.Protocol;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
// ──────────────────────────────────────────────────────────────────────
// Config-Driven Gateway Sample
//
// This sample shows one way to build a transparent MCP gateway host from the
// interceptor SDK primitives. The JSON file format used here is sample-only.
// The core library does not prescribe a config schema.
//
// Features demonstrated:
// 1. Backend client created from transport config (stdio or streamable-http)
// 2. Static external interceptor servers via CreateAsync(...)
// 3. Dynamic per-request interceptor resolution via MessageContext
// 4. Transparent proxy surface for downstream MCP clients (stdio in this sample)
//
// To host the gateway itself over Streamable HTTP, compose the same gateway
// primitives with the core SDK's ASP.NET transport support
// (AddMcpServer().WithHttpTransport(), then MapMcp()).
// ──────────────────────────────────────────────────────────────────────
var configPath = Path.Combine(GetSourceDir(), "mcp-interceptors.json");
var config = JsonSerializer.Deserialize<SampleGatewayConfig>(await File.ReadAllTextAsync(configPath))
?? throw new InvalidOperationException("Failed to load gateway config.");
await using var backendClient = await McpClient.CreateAsync(CreateTransport(config.Backend));
var staticConnections = config.StaticInterceptors.Select(CreateConnectionOptions).ToArray();
var dynamicConnections = config.DynamicInterceptors.ToDictionary(
kvp => kvp.Key,
kvp => (IReadOnlyList<McpInterceptorServerConnectionOptions>)kvp.Value.Select(CreateConnectionOptions).ToArray(),
StringComparer.OrdinalIgnoreCase);
var gatewayOptions = new McpInterceptorGatewayOptions
{
BackendClient = backendClient,
InterceptorServerConnections = staticConnections,
Events = config.InterceptedEvents,
ServerInfo = config.ServerInfo,
InterceptorServerConnectionResolver = (context, @event, ct) =>
{
if (@event != InterceptionEvents.ToolsCall)
{
return ValueTask.FromResult<IReadOnlyList<McpInterceptorServerConnectionOptions>>([]);
}
var userName = context.User?.Identity?.Name;
if (userName is not null && dynamicConnections.TryGetValue(userName, out var connections))
{
return ValueTask.FromResult(connections);
}
return ValueTask.FromResult<IReadOnlyList<McpInterceptorServerConnectionOptions>>([]);
},
};
await using var gateway = await McpInterceptorGateway.CreateAsync(gatewayOptions);
var serverOptions = new McpServerOptions
{
ServerInfo = config.ServerInfo,
};
gateway.ConfigureServerOptions(serverOptions);
await using var server = McpServer.Create(new StdioServerTransport(config.ServerInfo.Name), serverOptions);
gateway.RegisterNotificationForwarding(server);
await server.RunAsync();
static IClientTransport CreateTransport(TransportConfig config)
{
return config.Transport switch
{
"stdio" => new StdioClientTransport(new StdioClientTransportOptions
{
Name = config.Name,
Command = config.Command ?? throw new InvalidOperationException("Command is required for stdio transport."),
Arguments = config.Arguments,
}),
"http" => new HttpClientTransport(new HttpClientTransportOptions
{
Name = config.Name,
Endpoint = config.Endpoint ?? throw new InvalidOperationException("HTTP endpoint is required for streamable-http transport."),
TransportMode = HttpTransportMode.StreamableHttp,
}),
_ => throw new NotSupportedException($"Unsupported transport '{config.Transport}' in sample."),
};
}
static McpInterceptorServerConnectionOptions CreateConnectionOptions(TransportConfig config)
{
return new McpInterceptorServerConnectionOptions
{
ConnectionId = config.ConnectionId,
Transport = CreateTransport(config),
};
}
static string GetSourceDir([CallerFilePath] string? path = null) =>
Path.GetDirectoryName(path) ?? throw new InvalidOperationException();
internal sealed class SampleGatewayConfig
{
public required Implementation ServerInfo { get; set; }
public required TransportConfig Backend { get; set; }
public IList<TransportConfig> StaticInterceptors { get; set; } = [];
public Dictionary<string, IList<TransportConfig>> DynamicInterceptors { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public IList<string>? InterceptedEvents { get; set; }
}
internal sealed class TransportConfig
{
public required string Transport { get; set; }
public required string Name { get; set; }
public string? Command { get; set; }
public IList<string> Arguments { get; set; } = [];
public Uri? Endpoint { get; set; }
public string? ConnectionId { get; set; }
}

View file

@ -0,0 +1,29 @@
{
"serverInfo": {
"name": "config-driven-interceptor-proxy",
"version": "1.0.0"
},
"backend": {
"transport": "http",
"name": "EverythingServer",
"endpoint": "http://localhost:3001/mcp"
},
"staticInterceptors": [
{
"transport": "http",
"name": "SecurityInterceptorServer",
"endpoint": "http://localhost:3002/mcp"
}
],
"dynamicInterceptors": {
"alice": [
{
"connectionId": "alice-sink",
"transport": "http",
"name": "AliceInterceptorServer",
"endpoint": "http://localhost:3003/mcp"
}
]
},
"interceptedEvents": ["tools/call"]
}

View file

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ModelContextProtocol" Version="1.1.0" />
<ProjectReference Include="..\..\src\ModelContextProtocol.Interceptors\ModelContextProtocol.Interceptors.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,177 @@
using System.Runtime.CompilerServices;
using System.Text.Json.Nodes;
using ModelContextProtocol.Client;
using ModelContextProtocol.Interceptors.Client;
using ModelContextProtocol.Interceptors.Protocol;
// ──────────────────────────────────────────────────────────────────────
// Gateway Chain Sample
//
// Demonstrates chaining two interceptor servers in front of an MCP server:
//
// Client ──▶ Security Interceptors ──▶ Logging Interceptors ──▶ Everything Server
//
// This shows how multiple independent interceptor layers compose — the
// security layer (PII + email redaction) runs first, then the logging
// layer observes the already-sanitized payloads.
//
// Both interceptor layers use the same InterceptorServerSample binary;
// in production each layer would be a different server with its own
// interceptor set.
// ──────────────────────────────────────────────────────────────────────
Console.WriteLine("=== MCP Interceptors Gateway Chain Sample ===");
Console.WriteLine();
var interceptorServerPath = Path.Combine(GetSourceDir(), "..", "InterceptorServerSample");
// 1. Launch two interceptor servers — same binary here, but logically distinct layers
Console.WriteLine("[setup] Starting security interceptor server...");
await using var securityInterceptorClient = await McpClient.CreateAsync(
new StdioClientTransport(new()
{
Name = "SecurityInterceptors",
Command = "dotnet",
Arguments = ["run", "--project", interceptorServerPath],
}));
Console.WriteLine("[setup] Starting logging interceptor server...");
await using var loggingInterceptorClient = await McpClient.CreateAsync(
new StdioClientTransport(new()
{
Name = "LoggingInterceptors",
Command = "dotnet",
Arguments = ["run", "--project", interceptorServerPath],
}));
// 2. Connect to the MCP everything server
Console.WriteLine("[setup] Starting everything server...");
await using var everythingClient = await McpClient.CreateAsync(
new StdioClientTransport(new()
{
Name = "EverythingServer",
Command = "npx",
Arguments = ["-y", "@modelcontextprotocol/server-everything"],
}));
// 3. Chain: wrap everything server with logging, then wrap that with security
// Security layer intercepts tools/call (validation + mutation)
// Logging layer intercepts all events (sink only, since that's
// what request-logger is configured for — it won't block anything)
// Inner layer: logging interceptors → everything server
var loggingGateway = new InterceptingMcpClient(everythingClient, new InterceptingMcpClientOptions
{
InterceptorClient = loggingInterceptorClient,
Events = [InterceptionEvents.ToolsCall, InterceptionEvents.ToolsList],
});
// Outer layer: security interceptors → logging gateway
// Note: InterceptingMcpClient wraps McpClient, so to chain we use the
// security interceptors directly via ExecuteChainAsync before the inner gateway.
Console.WriteLine("[setup] Connected! Chain: Security → Logging → Everything Server");
Console.WriteLine();
// 4. List interceptors from both layers
var securityInterceptors = await securityInterceptorClient.ListInterceptorsAsync();
var loggingInterceptors = await loggingInterceptorClient.ListInterceptorsAsync();
Console.WriteLine("[security layer] interceptors:");
foreach (var i in securityInterceptors.Interceptors)
{
var events = string.Join(", ", i.Hooks.SelectMany(h => h.Events).Distinct());
Console.WriteLine($" {i.Name,-20} type={i.Type,-15} events=[{events}]");
}
Console.WriteLine("[logging layer] interceptors:");
foreach (var i in loggingInterceptors.Interceptors)
{
var events = string.Join(", ", i.Hooks.SelectMany(h => h.Events).Distinct());
Console.WriteLine($" {i.Name,-20} type={i.Type,-15} events=[{events}]");
}
// 5. List tools (flows through logging gateway)
Console.WriteLine();
var tools = await loggingGateway.ListToolsAsync();
Console.WriteLine($"[tools] {tools.Count} tools available");
// ── Demo 1: Clean call through both layers ───────────────────────────
Console.WriteLine();
Console.WriteLine("── Demo 1: Clean echo through both layers ──");
{
var payload = JsonNode.Parse("""{"name":"echo","arguments":{"message":"Hello from the chain!"}}""")!;
// Security layer (request phase)
var securityResult = await securityInterceptorClient.ExecuteChainAsync(new ExecuteChainRequestParams
{
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = payload,
});
Console.WriteLine($" Security layer: {securityResult.Status}");
// Logging layer + actual call (uses the sanitized payload)
var result = await loggingGateway.CallToolAsync("echo", new Dictionary<string, object?>
{
["message"] = "Hello from the chain!",
});
Console.WriteLine($" Result: {result.Content.FirstOrDefault()}");
}
// ── Demo 2: Email in payload — security redacts, logging sees redacted ─
Console.WriteLine();
Console.WriteLine("── Demo 2: Email payload through both layers ──");
{
var payload = JsonNode.Parse("""{"name":"echo","arguments":{"message":"Contact alice@secret.com"}}""")!;
// Security layer redacts the email
var securityResult = await securityInterceptorClient.ExecuteChainAsync(new ExecuteChainRequestParams
{
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = payload,
});
Console.WriteLine($" Security layer: {securityResult.Status}");
Console.WriteLine($" Sanitized payload: {securityResult.FinalPayload}");
// Extract the redacted message for the actual call
var sanitizedArgs = securityResult.FinalPayload?["arguments"];
var sanitizedMessage = sanitizedArgs?["message"]?.GetValue<string>() ?? "[redacted]";
// Logging layer + actual call with sanitized payload
var result = await loggingGateway.CallToolAsync("echo", new Dictionary<string, object?>
{
["message"] = sanitizedMessage,
});
Console.WriteLine($" Result: {result.Content.FirstOrDefault()}");
}
// ── Demo 3: PII in payload — security blocks, never reaches logging ──
Console.WriteLine();
Console.WriteLine("── Demo 3: PII payload — blocked by security layer ──");
{
var payload = JsonNode.Parse("""{"name":"echo","arguments":{"message":"SSN: 123-45-6789"}}""")!;
var securityResult = await securityInterceptorClient.ExecuteChainAsync(new ExecuteChainRequestParams
{
Event = InterceptionEvents.ToolsCall,
Phase = InterceptorPhase.Request,
Payload = payload,
});
Console.WriteLine($" Security layer: {securityResult.Status}");
if (securityResult.Status == InterceptorChainStatus.ValidationFailed)
{
Console.WriteLine($" BLOCKED — request never reached logging layer or server");
if (securityResult.AbortedAt is { } abort)
{
Console.WriteLine($" Aborted by: {abort.Interceptor} ({abort.Reason})");
}
}
}
Console.WriteLine();
Console.WriteLine("=== Done ===");
static string GetSourceDir([CallerFilePath] string? path = null) =>
Path.GetDirectoryName(path) ?? throw new InvalidOperationException();

View file

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ModelContextProtocol" Version="1.1.0" />
<ProjectReference Include="..\..\src\ModelContextProtocol.Interceptors\ModelContextProtocol.Interceptors.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,133 @@
using System.Runtime.CompilerServices;
using ModelContextProtocol.Client;
using ModelContextProtocol.Interceptors.Client;
using ModelContextProtocol.Interceptors.Protocol;
// ──────────────────────────────────────────────────────────────────────
// Gateway Sample
//
// Demonstrates a full gateway chain:
// Client ──▶ Interceptor Server (PII validator + email redactor) ──▶ Everything Server
//
// The interceptor server hosts three interceptors:
// 1. pii-validator (validation) – blocks payloads containing SSN-like data
// 2. email-redactor (mutation) – replaces email addresses with [EMAIL_REDACTED]
// 3. request-logger (sink) – logs all events to stderr
// ──────────────────────────────────────────────────────────────────────
Console.WriteLine("=== MCP Interceptors Gateway Sample ===");
Console.WriteLine();
// 1. Connect to the interceptor server (our InterceptorServerSample, launched via dotnet run)
Console.WriteLine("[setup] Starting interceptor server...");
var interceptorServerPath = Path.Combine(GetSourceDir(), "..", "InterceptorServerSample");
await using var interceptorClient = await McpClient.CreateAsync(
new StdioClientTransport(new()
{
Name = "InterceptorServer",
Command = "dotnet",
Arguments = ["run", "--project", interceptorServerPath],
}));
// 2. Connect to the everything server (MCP reference server via npx)
Console.WriteLine("[setup] Starting everything server...");
await using var everythingClient = await McpClient.CreateAsync(
new StdioClientTransport(new()
{
Name = "EverythingServer",
Command = "npx",
Arguments = ["-y", "@modelcontextprotocol/server-everything"],
}));
// 3. Create the gateway wrapper
var gateway = new InterceptingMcpClient(everythingClient, new InterceptingMcpClientOptions
{
InterceptorClient = interceptorClient,
Events = [InterceptionEvents.ToolsCall],
});
// 4. List available interceptors
Console.WriteLine("[setup] Connected! Listing interceptors...");
var interceptors = await gateway.ListInterceptorsAsync();
Console.WriteLine();
foreach (var i in interceptors.Interceptors)
{
var events = string.Join(", ", i.Hooks.SelectMany(h => h.Events).Distinct());
Console.WriteLine($" interceptor: {i.Name,-20} type={i.Type,-15} events=[{events}]");
}
// 5. List tools from the everything server (passes through gateway)
Console.WriteLine();
var tools = await gateway.ListToolsAsync();
Console.WriteLine($"[tools] {tools.Count} tools available: {string.Join(", ", tools.Select(t => t.Name))}");
// ── Demo 1: Normal tool call (passes through cleanly) ──────────────
Console.WriteLine();
Console.WriteLine("── Demo 1: Normal echo (should pass through) ──");
try
{
var result = await gateway.CallToolAsync("echo", new Dictionary<string, object?>
{
["message"] = "Hello from the gateway!"
});
Console.WriteLine($" Result: {result.Content.FirstOrDefault()}");
}
catch (McpInterceptorValidationException ex)
{
Console.WriteLine($" BLOCKED: {ex.Message}");
}
// ── Demo 2: Tool call with email (mutation - email gets redacted) ──
Console.WriteLine();
Console.WriteLine("── Demo 2: Echo with email (should be redacted by email-redactor) ──");
try
{
var result = await gateway.CallToolAsync("echo", new Dictionary<string, object?>
{
["message"] = "Contact me at john.doe@example.com for details"
});
Console.WriteLine($" Result: {result.Content.FirstOrDefault()}");
}
catch (McpInterceptorValidationException ex)
{
Console.WriteLine($" BLOCKED: {ex.Message}");
}
// ── Demo 3: Tool call with PII (validation - gets blocked) ─────────
Console.WriteLine();
Console.WriteLine("── Demo 3: Echo with SSN reference (should be blocked by pii-validator) ──");
try
{
var result = await gateway.CallToolAsync("echo", new Dictionary<string, object?>
{
["message"] = "My SSN is 123-45-6789"
});
Console.WriteLine($" Result: {result.Content.FirstOrDefault()}");
}
catch (McpInterceptorValidationException ex)
{
Console.WriteLine($" BLOCKED: {ex.Message}");
}
// ── Demo 4: Normal add (should pass through) ───────────────────────
Console.WriteLine();
Console.WriteLine("── Demo 4: Add 17 + 25 (should pass through) ──");
try
{
var result = await gateway.CallToolAsync("get-sum", new Dictionary<string, object?>
{
["a"] = 17,
["b"] = 25,
});
Console.WriteLine($" Result: {result.Content.FirstOrDefault()}");
}
catch (McpInterceptorValidationException ex)
{
Console.WriteLine($" BLOCKED: {ex.Message}");
}
Console.WriteLine();
Console.WriteLine("=== Done ===");
static string GetSourceDir([CallerFilePath] string? path = null) =>
Path.GetDirectoryName(path) ?? throw new InvalidOperationException();

View file

@ -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");
}
}

View file

@ -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")
}
};
}
}

View file

@ -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
}

View file

@ -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>

View file

@ -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();

View file

@ -1,19 +0,0 @@
<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

@ -1,175 +0,0 @@
using ModelContextProtocol.Interceptors;
using ModelContextProtocol.Interceptors.Client;
using ModelContextProtocol.Interceptors.Protocol.Llm;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
/// <summary>
/// Mutation interceptors for llm/completion events.
/// These interceptors transform requests and responses.
/// </summary>
[McpClientInterceptorType]
public partial class LlmMutationInterceptors
{
// Patterns for sensitive data redaction
private static readonly Regex ApiKeyPattern = new(@"\b(sk_live_|api_key[=:]\s*)[a-zA-Z0-9_-]+\b", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex PasswordPattern = new(@"\b(password[=:]\s*|secret[=:]\s*)[^\s]+\b", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex BearerTokenPattern = new(@"Bearer\s+[a-zA-Z0-9._-]+", RegexOptions.Compiled);
/// <summary>
/// Normalizes prompt whitespace by trimming message content.
/// </summary>
[McpClientInterceptor(
Name = "prompt-normalizer",
Description = "Normalizes whitespace in prompts",
Type = InterceptorType.Mutation,
Events = [InterceptorEvents.LlmCompletion],
Phase = InterceptorPhase.Request,
PriorityHint = -100)] // Run early
public static MutationInterceptorResult NormalizePrompts(JsonNode? payload)
{
if (payload is null)
{
return MutationInterceptorResult.Unchanged(payload);
}
var request = payload.Deserialize<LlmCompletionRequest>();
if (request is null)
{
return MutationInterceptorResult.Unchanged(payload);
}
var modified = false;
foreach (var message in request.Messages)
{
if (message.Content is not null)
{
var trimmed = message.Content.Trim();
if (trimmed != message.Content)
{
message.Content = trimmed;
modified = true;
}
}
}
if (modified)
{
var mutatedPayload = JsonSerializer.SerializeToNode(request);
return MutationInterceptorResult.Mutated(mutatedPayload);
}
return MutationInterceptorResult.Unchanged(payload);
}
/// <summary>
/// Redacts sensitive information from LLM responses.
/// </summary>
[McpClientInterceptor(
Name = "response-redactor",
Description = "Redacts sensitive information from LLM responses",
Type = InterceptorType.Mutation,
Events = [InterceptorEvents.LlmCompletion],
Phase = InterceptorPhase.Response,
PriorityHint = 100)] // Run late in response chain
public static MutationInterceptorResult RedactResponse(JsonNode? payload)
{
if (payload is null)
{
return MutationInterceptorResult.Unchanged(payload);
}
var response = payload.Deserialize<LlmCompletionResponse>();
if (response is null)
{
return MutationInterceptorResult.Unchanged(payload);
}
var modified = false;
var redactions = new List<string>();
foreach (var choice in response.Choices)
{
if (choice.Message.Content is not null)
{
var content = choice.Message.Content;
// Redact API keys
if (ApiKeyPattern.IsMatch(content))
{
content = ApiKeyPattern.Replace(content, "[REDACTED_API_KEY]");
redactions.Add("api_key");
modified = true;
}
// Redact passwords
if (PasswordPattern.IsMatch(content))
{
content = PasswordPattern.Replace(content, "[REDACTED_SECRET]");
redactions.Add("password");
modified = true;
}
// Redact bearer tokens
if (BearerTokenPattern.IsMatch(content))
{
content = BearerTokenPattern.Replace(content, "Bearer [REDACTED_TOKEN]");
redactions.Add("bearer_token");
modified = true;
}
choice.Message.Content = content;
}
}
if (modified)
{
var mutatedPayload = JsonSerializer.SerializeToNode(response);
var result = MutationInterceptorResult.Mutated(mutatedPayload);
result.Info = new JsonObject
{
["action"] = "redacted sensitive data",
["types"] = JsonNode.Parse($"[\"{string.Join("\", \"", redactions.Distinct())}\"]")
};
return result;
}
return MutationInterceptorResult.Unchanged(payload);
}
/// <summary>
/// Adds metadata to track request origin.
/// </summary>
[McpClientInterceptor(
Name = "metadata-injector",
Description = "Adds tracking metadata to requests",
Type = InterceptorType.Mutation,
Events = [InterceptorEvents.LlmCompletion],
Phase = InterceptorPhase.Request,
PriorityHint = 100)] // Run late
public static MutationInterceptorResult InjectMetadata(JsonNode? payload)
{
if (payload is null)
{
return MutationInterceptorResult.Unchanged(payload);
}
var request = payload.Deserialize<LlmCompletionRequest>();
if (request is null)
{
return MutationInterceptorResult.Unchanged(payload);
}
// Add tracking metadata
request.Meta ??= new JsonObject();
request.Meta["interceptor_processed"] = true;
request.Meta["interceptor_timestamp"] = DateTimeOffset.UtcNow.ToString("O");
request.Meta["interceptor_version"] = "1.0.0";
var mutatedPayload = JsonSerializer.SerializeToNode(request);
var result = MutationInterceptorResult.Mutated(mutatedPayload);
result.Info = new JsonObject { ["action"] = "added tracking metadata" };
return result;
}
}

Some files were not shown because too many files have changed in this diff Show more