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
6 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)!;
}