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
144 changed files with 7887 additions and 12886 deletions
|
|
@ -1,464 +0,0 @@
|
|||
# AGENTS.md - C# SDK for MCP Interceptors
|
||||
|
||||
This document provides guidance for AI coding agents working on the MCP Interceptors C# SDK implementation.
|
||||
|
||||
> **Note:** See [TODO.md](./TODO.md) for a list of code style alignment tasks with the MCP C# SDK.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This SDK implements **SEP-1763: Interceptors for Model Context Protocol** - a standardized framework for intercepting, validating, and transforming MCP messages.
|
||||
|
||||
**Specification Reference:** https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1763
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
csharp-sdk/
|
||||
├── src/ModelContextProtocol.Interceptors/
|
||||
│ ├── Protocol/ # Protocol types (Interceptor, Events, Results, etc.)
|
||||
│ │ ├── InterceptorResult.cs # Abstract base class for all results
|
||||
│ │ ├── ValidationInterceptorResult.cs
|
||||
│ │ ├── MutationInterceptorResult.cs
|
||||
│ │ ├── ObservabilityInterceptorResult.cs
|
||||
│ │ ├── InterceptorChainResult.cs # Chain execution result
|
||||
│ │ └── McpInterceptorValidationException.cs # Validation failure exception
|
||||
│ ├── Server/ # Server-side implementation
|
||||
│ │ ├── McpServerInterceptorAttribute.cs
|
||||
│ │ ├── McpServerInterceptorTypeAttribute.cs
|
||||
│ │ ├── McpServerInterceptor.cs # Abstract base class
|
||||
│ │ ├── ReflectionMcpServerInterceptor.cs
|
||||
│ │ ├── InterceptorServerHandlers.cs
|
||||
│ │ └── InterceptorServerFilters.cs
|
||||
│ ├── Client/ # Client-side implementation
|
||||
│ │ ├── McpClientInterceptorAttribute.cs
|
||||
│ │ ├── McpClientInterceptorTypeAttribute.cs
|
||||
│ │ ├── McpClientInterceptor.cs # Abstract base class
|
||||
│ │ ├── ReflectionMcpClientInterceptor.cs
|
||||
│ │ ├── ClientInterceptorContext.cs
|
||||
│ │ ├── McpClientInterceptorCreateOptions.cs
|
||||
│ │ ├── InterceptorClientHandlers.cs
|
||||
│ │ ├── InterceptorClientFilters.cs
|
||||
│ │ ├── McpClientInterceptorExtensions.cs
|
||||
│ │ ├── InterceptorChainExecutor.cs # Chain execution per SEP-1763
|
||||
│ │ ├── InterceptingMcpClient.cs # McpClient wrapper with interceptors
|
||||
│ │ ├── InterceptingMcpClientOptions.cs # Configuration for InterceptingMcpClient
|
||||
│ │ ├── InterceptingMcpClientExtensions.cs # Extension methods
|
||||
│ │ └── PayloadConverter.cs # JSON conversion utilities
|
||||
│ └── McpServerInterceptorBuilderExtensions.cs # DI extensions
|
||||
├── samples/
|
||||
│ ├── InterceptorServiceSample/ # Server-side validation interceptor example
|
||||
│ └── InterceptorClientSample/ # Client-side interceptor integration example
|
||||
└── ModelContextProtocol.Interceptors.sln
|
||||
```
|
||||
|
||||
## Key Concepts from SEP-1763
|
||||
|
||||
### Interceptor Types
|
||||
|
||||
1. **Validation** - Validates requests/responses, returns pass/fail with severity levels
|
||||
2. **Mutation** - Transforms payloads before they continue through the pipeline
|
||||
3. **Observability** - Fire-and-forget logging/metrics collection, never blocks
|
||||
|
||||
### Phases
|
||||
|
||||
- `Request` - Intercept incoming requests
|
||||
- `Response` - Intercept outgoing responses
|
||||
- `Both` - Intercept in both directions
|
||||
|
||||
### Events
|
||||
|
||||
Interceptors subscribe to specific MCP events:
|
||||
|
||||
- Server Features: `tools/list`, `tools/call`, `prompts/list`, `prompts/get`, `resources/list`, `resources/read`, `resources/subscribe`
|
||||
- Client Features: `sampling/createMessage`, `elicitation/create`, `roots/list`
|
||||
- LLM Interactions: `llm/completion`
|
||||
- Wildcards: `*/request`, `*/response`, `*`
|
||||
|
||||
### Execution Order
|
||||
|
||||
**Sending data (across trust boundary):**
|
||||
```
|
||||
Mutate (sequential by priority) → Validate & Observe (parallel) → Send
|
||||
```
|
||||
|
||||
**Receiving data (from trust boundary):**
|
||||
```
|
||||
Receive → Validate & Observe (parallel) → Mutate (sequential by priority)
|
||||
```
|
||||
|
||||
### Priority Ordering
|
||||
|
||||
- Mutations execute sequentially by `priorityHint` (lower values first)
|
||||
- Ties broken alphabetically by interceptor name
|
||||
- Validations and observability run in parallel (priority ignored)
|
||||
- Recommended ranges: security (-2B to -1M), sanitization (-999K to -10K), normalization (-9999 to -1), default (0), enrichment (1-9999), observability (10K+)
|
||||
|
||||
## Implementation Patterns
|
||||
|
||||
### Creating a Server-Side Interceptor
|
||||
|
||||
```csharp
|
||||
[McpServerInterceptorType]
|
||||
public class MyServerInterceptor
|
||||
{
|
||||
[McpServerInterceptor(
|
||||
Name = "my-interceptor",
|
||||
Description = "Description of what it does",
|
||||
Events = new[] { InterceptorEvents.ToolsCall },
|
||||
Phase = InterceptorPhase.Request,
|
||||
PriorityHint = 0)]
|
||||
public ValidationInterceptorResult Validate(JsonNode? payload)
|
||||
{
|
||||
// Implementation
|
||||
return new ValidationInterceptorResult { Valid = true };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Creating a Client-Side Interceptor (Attribute-Based)
|
||||
|
||||
```csharp
|
||||
[McpClientInterceptorType]
|
||||
public class MyClientInterceptors
|
||||
{
|
||||
[McpClientInterceptor(
|
||||
Name = "pii-validator",
|
||||
Description = "Validates tool arguments for PII leakage",
|
||||
Events = new[] { InterceptorEvents.ToolsCall },
|
||||
Phase = InterceptorPhase.Request,
|
||||
PriorityHint = -1000)] // Security interceptors run early
|
||||
public ValidationInterceptorResult ValidatePii(JsonNode? payload)
|
||||
{
|
||||
// Validate payload before sending to server
|
||||
if (ContainsSsn(payload))
|
||||
return ValidationInterceptorResult.Error("SSN detected in arguments");
|
||||
return ValidationInterceptorResult.Success();
|
||||
}
|
||||
|
||||
[McpClientInterceptor(
|
||||
Name = "response-redactor",
|
||||
Type = InterceptorType.Mutation,
|
||||
Events = new[] { InterceptorEvents.ToolsCall },
|
||||
Phase = InterceptorPhase.Response,
|
||||
PriorityHint = 50)]
|
||||
public MutationInterceptorResult RedactResponse(JsonNode? payload)
|
||||
{
|
||||
// Transform response received from server
|
||||
var redacted = RedactSensitiveData(payload);
|
||||
return MutationInterceptorResult.Mutated(redacted);
|
||||
}
|
||||
|
||||
[McpClientInterceptor(
|
||||
Name = "request-logger",
|
||||
Type = InterceptorType.Observability,
|
||||
Events = new[] { InterceptorEvents.ToolsCall },
|
||||
Phase = InterceptorPhase.Request)]
|
||||
public ObservabilityInterceptorResult LogRequest(JsonNode? payload, string @event)
|
||||
{
|
||||
Console.WriteLine($"Tool call: {@event}");
|
||||
return ObservabilityInterceptorResult.Success();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using InterceptingMcpClient (Full Integration)
|
||||
|
||||
The `InterceptingMcpClient` wraps `McpClient` and automatically executes interceptor chains for tool operations.
|
||||
|
||||
```csharp
|
||||
// Create MCP client normally
|
||||
await using var client = await McpClient.CreateAsync(transport);
|
||||
|
||||
// Collect interceptors from attributed classes
|
||||
var interceptors = new List<McpClientInterceptor>();
|
||||
interceptors.AddRange(McpClientInterceptorExtensions.WithInterceptors<MyClientInterceptors>());
|
||||
|
||||
// Wrap with interceptors using extension method
|
||||
var interceptedClient = client.WithInterceptors(new InterceptingMcpClientOptions
|
||||
{
|
||||
Interceptors = interceptors,
|
||||
DefaultTimeoutMs = 5000,
|
||||
ThrowOnValidationError = true, // Throw McpInterceptorValidationException on errors
|
||||
InterceptResponses = true // Also intercept responses
|
||||
});
|
||||
|
||||
// Use intercepted client - interceptors run automatically
|
||||
try
|
||||
{
|
||||
var result = await interceptedClient.CallToolAsync("my-tool", new Dictionary<string, object?>
|
||||
{
|
||||
["name"] = "John Doe"
|
||||
});
|
||||
Console.WriteLine(result.Content?.FirstOrDefault());
|
||||
}
|
||||
catch (McpInterceptorValidationException ex)
|
||||
{
|
||||
Console.WriteLine($"Blocked by {ex.AbortedAt?.Interceptor}: {ex.AbortedAt?.Reason}");
|
||||
Console.WriteLine(ex.GetDetailedMessage()); // Detailed validation info
|
||||
}
|
||||
|
||||
// List tools (also intercepted)
|
||||
var tools = await interceptedClient.ListToolsAsync();
|
||||
|
||||
// Access inner client for non-intercepted operations
|
||||
var serverInfo = interceptedClient.ServerInfo;
|
||||
```
|
||||
|
||||
### Non-Throwing Mode
|
||||
|
||||
```csharp
|
||||
var interceptedClient = client.WithInterceptors(new InterceptingMcpClientOptions
|
||||
{
|
||||
Interceptors = interceptors,
|
||||
ThrowOnValidationError = false // Return error result instead of throwing
|
||||
});
|
||||
|
||||
var result = await interceptedClient.CallToolAsync("my-tool", args);
|
||||
if (result.IsError)
|
||||
{
|
||||
// Handle validation failure from result
|
||||
Console.WriteLine(result.Content?.FirstOrDefault());
|
||||
}
|
||||
```
|
||||
|
||||
### Registration via DI (Server)
|
||||
|
||||
```csharp
|
||||
builder.Services.AddMcpServer()
|
||||
.WithStdioServerTransport()
|
||||
.WithInterceptors<MyServerInterceptor>();
|
||||
```
|
||||
|
||||
### Client Interceptor Chain Execution (Low-Level)
|
||||
|
||||
For advanced scenarios where you need direct chain execution:
|
||||
|
||||
```csharp
|
||||
// Create interceptors from attributed class
|
||||
var interceptors = McpClientInterceptorExtensions.WithInterceptors<MyClientInterceptor>(services);
|
||||
|
||||
// Execute chain for outgoing requests
|
||||
var executor = new InterceptorChainExecutor(interceptors, services);
|
||||
var result = await executor.ExecuteForSendingAsync(
|
||||
@event: InterceptorEvents.ToolsCall,
|
||||
payload: requestPayload,
|
||||
config: null,
|
||||
timeoutMs: 5000);
|
||||
|
||||
if (result.Status == InterceptorChainStatus.Success)
|
||||
{
|
||||
// Use result.FinalPayload for the request
|
||||
}
|
||||
else if (result.Status == InterceptorChainStatus.ValidationFailed)
|
||||
{
|
||||
// Handle validation failure
|
||||
Console.WriteLine($"Blocked by: {result.AbortedAt?.Interceptor}");
|
||||
}
|
||||
|
||||
// Execute chain for incoming responses
|
||||
var responseResult = await executor.ExecuteForReceivingAsync(
|
||||
@event: InterceptorEvents.ToolsCall,
|
||||
payload: responsePayload);
|
||||
```
|
||||
|
||||
### Validation Results
|
||||
|
||||
Return appropriate severity levels:
|
||||
|
||||
- `ValidationSeverity.Info` - Informational, does not block
|
||||
- `ValidationSeverity.Warn` - Warning, does not block
|
||||
- `ValidationSeverity.Error` - Error, blocks execution
|
||||
|
||||
## InterceptingMcpClient Architecture
|
||||
|
||||
The `InterceptingMcpClient` follows SEP-1763 execution order for tool operations:
|
||||
|
||||
```
|
||||
User Application
|
||||
│
|
||||
▼
|
||||
InterceptingMcpClient.CallToolAsync("my-tool", args)
|
||||
│
|
||||
├─► 1. PayloadConverter.ToCallToolRequestPayload(toolName, args)
|
||||
│
|
||||
├─► 2. _executor.ExecuteForSendingAsync("tools/call", payload)
|
||||
│ │
|
||||
│ ├── Mutations (sequential by priority)
|
||||
│ ├── Validations (parallel)
|
||||
│ └── Observability (fire-and-forget)
|
||||
│
|
||||
├─► 3. If ValidationFailed → throw McpInterceptorValidationException
|
||||
│
|
||||
├─► 4. PayloadConverter.FromCallToolRequestPayload(mutatedPayload)
|
||||
│
|
||||
├─► 5. _inner.CallToolAsync(mutatedParams)
|
||||
│
|
||||
├─► 6. PayloadConverter.ToCallToolResultPayload(result)
|
||||
│
|
||||
├─► 7. _executor.ExecuteForReceivingAsync("tools/call", responsePayload)
|
||||
│
|
||||
└─► 8. Return mutated result
|
||||
```
|
||||
|
||||
**Important:** When using `ListToolsAsync`, the returned `McpClientTool` instances are associated with the inner `McpClient`, not the `InterceptingMcpClient`. Calling `tool.InvokeAsync()` will bypass interceptors. Use `interceptedClient.CallToolAsync(toolName, args)` directly to ensure interceptors execute.
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### When Adding New Protocol Types
|
||||
|
||||
1. Follow the JSON-RPC patterns from the specification
|
||||
2. Place protocol types in `Protocol/` directory
|
||||
3. Use nullable reference types appropriately
|
||||
4. Add XML documentation comments
|
||||
|
||||
### When Adding Server Features
|
||||
|
||||
1. Add handler delegates in `Server/InterceptorServerHandlers.cs`
|
||||
2. Add filter delegates in `Server/InterceptorServerFilters.cs`
|
||||
3. Add builder extension methods in `McpServerInterceptorBuilderExtensions.cs`
|
||||
4. Ensure proper null checking and argument validation
|
||||
|
||||
### When Adding Client Features
|
||||
|
||||
1. Add handler delegates in `Client/InterceptorClientHandlers.cs`
|
||||
2. Add filter delegates in `Client/InterceptorClientFilters.cs`
|
||||
3. Add extension methods in `Client/McpClientInterceptorExtensions.cs`
|
||||
4. Update `InterceptorChainExecutor` if chain execution logic changes
|
||||
5. Ensure proper null checking and argument validation
|
||||
|
||||
### Testing Considerations
|
||||
|
||||
- Test both valid and invalid payloads
|
||||
- Test severity level propagation
|
||||
- Test priority ordering for mutations
|
||||
- Test parallel execution for validations
|
||||
- Test fire-and-forget behavior for observability
|
||||
- Test `McpInterceptorValidationException` details
|
||||
|
||||
## Current Implementation Status
|
||||
|
||||
### Implemented
|
||||
|
||||
**Protocol Types:**
|
||||
- `InterceptorResult` - Abstract base class with JSON polymorphism support
|
||||
- `ValidationInterceptorResult` - For validation interceptors
|
||||
- `MutationInterceptorResult` - For mutation interceptors
|
||||
- `ObservabilityInterceptorResult` - For observability interceptors
|
||||
- `InterceptorChainResult` - Result of chain execution
|
||||
- `McpInterceptorValidationException` - Exception for validation failures
|
||||
- Core types: `Interceptor`, `InterceptorEvent`, `InterceptorPhase`, `InterceptorType`, `InterceptorPriorityHint`
|
||||
|
||||
**Server-Side:**
|
||||
- Attribute-based interceptor registration (`McpServerInterceptor`, `McpServerInterceptorType`)
|
||||
- `McpServerInterceptor` abstract base class
|
||||
- `ReflectionMcpServerInterceptor` for method-based interceptors
|
||||
- DI builder extensions
|
||||
- Handler and filter delegates
|
||||
|
||||
**Client-Side:**
|
||||
- Attribute-based interceptor registration (`McpClientInterceptor`, `McpClientInterceptorType`)
|
||||
- `McpClientInterceptor` abstract base class
|
||||
- `ReflectionMcpClientInterceptor` for method-based interceptors
|
||||
- `InterceptorChainExecutor` - Executes interceptor chains per SEP-1763 spec
|
||||
- Extension methods for creating interceptors from types/assemblies
|
||||
- Handler and filter delegates
|
||||
- **`InterceptingMcpClient`** - Full McpClient wrapper with automatic interceptor execution
|
||||
- **`InterceptingMcpClientOptions`** - Configuration for the intercepting client
|
||||
- **`InterceptingMcpClientExtensions`** - Extension methods: `client.WithInterceptors(options)`
|
||||
- **`PayloadConverter`** - JSON conversion utilities for request/response payloads
|
||||
|
||||
**Samples:**
|
||||
- `InterceptorServiceSample` - Server-side parameter validation interceptor
|
||||
- `InterceptorClientSample` - Client-side interceptor integration demonstrating:
|
||||
- Validation interceptors (PII detection)
|
||||
- Mutation interceptors (argument normalization, response redaction)
|
||||
- Observability interceptors (request/response logging)
|
||||
- Error handling with `McpInterceptorValidationException`
|
||||
|
||||
### Not Yet Implemented
|
||||
|
||||
Refer to SEP-1763 for full specification. Areas that may need work:
|
||||
|
||||
- `interceptor/executeChain` protocol method
|
||||
- Cryptographic signature verification (future feature)
|
||||
- Unit tests for client-side chain execution
|
||||
- LLM completion interceptors (`llm/completion` event)
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `ModelContextProtocol` SDK (0.6.0-preview.10+)
|
||||
- `Microsoft.Extensions.DependencyInjection`
|
||||
- `Microsoft.Extensions.Hosting`
|
||||
- `System.Text.Json`
|
||||
|
||||
## Target Frameworks
|
||||
|
||||
- .NET 10.0 (primary)
|
||||
- .NET 9.0
|
||||
- .NET 8.0
|
||||
- .NET Standard 2.0 (for broader compatibility)
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding a New Event Type
|
||||
|
||||
1. Add constant to `InterceptorEvents.cs`
|
||||
2. Update any event filtering logic
|
||||
3. Add tests for the new event
|
||||
|
||||
### Adding a New Interceptor Type
|
||||
|
||||
1. Add result type in `Protocol/` (e.g., `MutationInterceptorResult.cs`)
|
||||
2. Update `InterceptorType` enum if needed
|
||||
3. Add handler support in server implementation
|
||||
4. Update builder extensions
|
||||
|
||||
### Adding Support for New MCP Operations in InterceptingMcpClient
|
||||
|
||||
1. Add payload conversion methods in `PayloadConverter.cs`
|
||||
2. Add the intercepted operation method in `InterceptingMcpClient.cs`
|
||||
3. Follow the existing pattern: convert → execute sending chain → call inner → execute receiving chain → return
|
||||
|
||||
### Debugging Tips
|
||||
|
||||
- Check that interceptor methods are properly attributed
|
||||
- Verify events match between registration and invocation
|
||||
- Check priority values for mutation ordering issues
|
||||
- Use logging to trace interceptor chain execution
|
||||
- Inspect `McpInterceptorValidationException.ChainResult` for detailed failure info
|
||||
|
||||
## Coding Style Guidelines
|
||||
|
||||
This project follows the coding style of the official [MCP C# SDK](https://github.com/modelcontextprotocol/csharp-sdk).
|
||||
|
||||
### Key Patterns
|
||||
|
||||
**Null Validation:**
|
||||
```csharp
|
||||
// Use Throw helper instead of manual null checks
|
||||
Throw.IfNull(parameter); // NOT: if (parameter is null) throw new ArgumentNullException(...)
|
||||
```
|
||||
|
||||
**Class Modifiers:**
|
||||
- Use `sealed` on classes not designed for inheritance
|
||||
- Use `internal` for implementation details not part of the public API
|
||||
|
||||
**Async/Await:**
|
||||
- Always use `ConfigureAwait(false)` on awaits in library code
|
||||
- Prefer `ValueTask` for hot paths to reduce allocations
|
||||
- Include `CancellationToken` on all async methods
|
||||
|
||||
**JSON Serialization:**
|
||||
- Use `System.Text.Json` exclusively
|
||||
- Use `JsonPropertyName` attributes for property mapping
|
||||
|
||||
### Common Files
|
||||
|
||||
- `Common/Throw.cs` - Helper class for null/argument validation
|
||||
- `Common/Polyfills/` - Compatibility attributes for netstandard2.0
|
||||
|
||||
### Reference
|
||||
|
||||
For coding style examples, compare with:
|
||||
- `/mnt/d/code/ai/mcp/csharp-sdk/src/ModelContextProtocol.Core/`
|
||||
- `/mnt/d/code/ai/mcp/csharp-sdk/src/Common/Throw.cs`
|
||||
|
|
@ -1,464 +1,98 @@
|
|||
# CLAUDE.md - C# SDK for MCP Interceptors
|
||||
# MCP Interceptors - C# SDK
|
||||
|
||||
This document provides guidance for Claude (and other AI assistants) working on the MCP Interceptors C# SDK implementation.
|
||||
|
||||
> **Note:** See [TODO.md](./TODO.md) for a list of code style alignment tasks with the MCP C# SDK.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This SDK implements **SEP-1763: Interceptors for Model Context Protocol** - a standardized framework for intercepting, validating, and transforming MCP messages.
|
||||
|
||||
**Specification Reference:** https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1763
|
||||
|
||||
## Project Structure
|
||||
## What this is
|
||||
C# implementation of gateway-level interceptors from [SEP-1763](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1763). NuGet package additive to the official [C# MCP SDK](https://github.com/modelcontextprotocol/csharp-sdk) (v1.1.0). Focus is on the protocol-level extension (client → interceptor server → server), NOT in-process middleware.
|
||||
|
||||
## Build & test
|
||||
```
|
||||
csharp-sdk/
|
||||
├── src/ModelContextProtocol.Interceptors/
|
||||
│ ├── Protocol/ # Protocol types (Interceptor, Events, Results, etc.)
|
||||
│ │ ├── InterceptorResult.cs # Abstract base class for all results
|
||||
│ │ ├── ValidationInterceptorResult.cs
|
||||
│ │ ├── MutationInterceptorResult.cs
|
||||
│ │ ├── ObservabilityInterceptorResult.cs
|
||||
│ │ ├── InterceptorChainResult.cs # Chain execution result
|
||||
│ │ └── McpInterceptorValidationException.cs # Validation failure exception
|
||||
│ ├── Server/ # Server-side implementation
|
||||
│ │ ├── McpServerInterceptorAttribute.cs
|
||||
│ │ ├── McpServerInterceptorTypeAttribute.cs
|
||||
│ │ ├── McpServerInterceptor.cs # Abstract base class
|
||||
│ │ ├── ReflectionMcpServerInterceptor.cs
|
||||
│ │ ├── InterceptorServerHandlers.cs
|
||||
│ │ └── InterceptorServerFilters.cs
|
||||
│ ├── Client/ # Client-side implementation
|
||||
│ │ ├── McpClientInterceptorAttribute.cs
|
||||
│ │ ├── McpClientInterceptorTypeAttribute.cs
|
||||
│ │ ├── McpClientInterceptor.cs # Abstract base class
|
||||
│ │ ├── ReflectionMcpClientInterceptor.cs
|
||||
│ │ ├── ClientInterceptorContext.cs
|
||||
│ │ ├── McpClientInterceptorCreateOptions.cs
|
||||
│ │ ├── InterceptorClientHandlers.cs
|
||||
│ │ ├── InterceptorClientFilters.cs
|
||||
│ │ ├── McpClientInterceptorExtensions.cs
|
||||
│ │ ├── InterceptorChainExecutor.cs # Chain execution per SEP-1763
|
||||
│ │ ├── InterceptingMcpClient.cs # McpClient wrapper with interceptors
|
||||
│ │ ├── InterceptingMcpClientOptions.cs # Configuration for InterceptingMcpClient
|
||||
│ │ ├── InterceptingMcpClientExtensions.cs # Extension methods
|
||||
│ │ └── PayloadConverter.cs # JSON conversion utilities
|
||||
│ └── McpServerInterceptorBuilderExtensions.cs # DI extensions
|
||||
├── samples/
|
||||
│ ├── InterceptorServiceSample/ # Server-side validation interceptor example
|
||||
│ └── InterceptorClientSample/ # Client-side interceptor integration example
|
||||
└── ModelContextProtocol.Interceptors.sln
|
||||
dotnet build # from csharp/sdk/
|
||||
dotnet test # 66 tests across the interceptor test project
|
||||
```
|
||||
|
||||
## Key Concepts from SEP-1763
|
||||
## Key architectural constraints
|
||||
|
||||
### Interceptor Types
|
||||
**Why message filter, not handlers**: `McpServerHandlers` and `McpServerImpl` are `internal` in the SDK. We can't register handlers for new JSON-RPC methods from outside. Instead we use `McpServerOptions.Filters.Message.IncomingFilters` to intercept `interceptors/list` and `interceptor/invoke`, handle them, send `JsonRpcResponse` via `context.Server.SendMessageAsync()`, and skip calling `next`. See `InterceptorMessageFilter.cs`. Chain execution is no longer a wire method — it's SDK orchestration in `Client/InterceptorChainOrchestrator.cs`.
|
||||
|
||||
1. **Validation** - Validates requests/responses, returns pass/fail with severity levels
|
||||
2. **Mutation** - Transforms payloads before they continue through the pipeline
|
||||
3. **Observability** - Fire-and-forget logging/metrics collection, never blocks
|
||||
**Why `ServerCapabilities.Extensions`**: The SDK's intended mechanism for protocol extensions. Requires `#pragma warning disable MCPEXP001`. We advertise `InterceptorsCapability { SupportedEvents }` under `Extensions["interceptors"]`.
|
||||
|
||||
### Phases
|
||||
**Client `SendRequestAsync`**: The public overload (`McpSession.Methods.cs:24`) takes `JsonSerializerOptions`. We pass `InterceptorJsonUtilities.DefaultOptions` which chains `McpJsonUtilities.DefaultOptions` + our `InterceptorJsonContext`. The internal overload takes `JsonTypeInfo<T>` — we can't use it.
|
||||
|
||||
- `Request` - Intercept incoming requests
|
||||
- `Response` - Intercept outgoing responses
|
||||
- `Both` - Intercept in both directions
|
||||
**`InterceptingMcpClient` is composition**: `McpClient` has an internal constructor; subclassing is `[Experimental]`. We wrap it as a concrete class exposing `.Inner` for direct access.
|
||||
|
||||
### Events
|
||||
## Chain execution order (SEP-1763)
|
||||
- **Request phase (sending)**: Mutations (sequential by priority ↑) → Validations (parallel) → Sinks (fire-and-forget)
|
||||
- **Response phase (receiving)**: Validations (parallel) → Sinks (fire-and-forget) → Mutations (sequential by priority ↑)
|
||||
- Lower `PriorityHint` executes first; ties broken alphabetically by name
|
||||
|
||||
Interceptors subscribe to specific MCP events:
|
||||
## JSON-RPC methods
|
||||
| Method | Params → Result |
|
||||
|--------|----------------|
|
||||
| `interceptors/list` | `ListInterceptorsRequestParams` → `ListInterceptorsResult` |
|
||||
| `interceptor/invoke` | `InvokeInterceptorRequestParams` → `InterceptorResult` (polymorphic) |
|
||||
|
||||
- Server Features: `tools/list`, `tools/call`, `prompts/list`, `prompts/get`, `resources/list`, `resources/read`, `resources/subscribe`
|
||||
- Client Features: `sampling/createMessage`, `elicitation/create`, `roots/list`
|
||||
- LLM Interactions: `llm/completion`
|
||||
- Wildcards: `*/request`, `*/response`, `*`
|
||||
## `InterceptorResult` polymorphism
|
||||
Uses `[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]` with `"validation"`, `"mutation"`, `"sink"` discriminators. Serialization/deserialization handles this automatically via STJ source-gen in `InterceptorJsonContext`.
|
||||
|
||||
### Execution Order
|
||||
## Parameter binding (ReflectionMcpServerInterceptor)
|
||||
Interceptor methods auto-bind from `InvokeInterceptorRequestParams`:
|
||||
- `JsonNode payload` → `.Payload`
|
||||
- `JsonNode config` → `.Config`
|
||||
- `string event` / `string eventName` → `.Event`
|
||||
- `InterceptorPhase phase` → `.Phase`
|
||||
- `InvokeInterceptorContext` → `.Context`
|
||||
- `CancellationToken`, `McpServer`, `IServiceProvider` → framework
|
||||
- Return `bool` → wrapped as `ValidationInterceptorResult { Valid = result }`
|
||||
|
||||
**Sending data (across trust boundary):**
|
||||
```
|
||||
Mutate (sequential by priority) → Validate & Observe (parallel) → Send
|
||||
```
|
||||
## SDK reference paths (local at /mnt/d/code/ai/mcp/csharp-sdk)
|
||||
- `src/ModelContextProtocol.Core/Server/McpServerTool.cs` — pattern we follow
|
||||
- `src/ModelContextProtocol.Core/Server/McpMessageFilter.cs` — our hook point
|
||||
- `src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs` — Extensions dict
|
||||
- `src/ModelContextProtocol.Core/McpSession.Methods.cs` — public SendRequestAsync
|
||||
- `src/ModelContextProtocol/McpServerBuilderExtensions.cs` — builder pattern
|
||||
- `src/ModelContextProtocol.Core/McpJsonUtilities.cs` — JSON context chaining pattern
|
||||
|
||||
**Receiving data (from trust boundary):**
|
||||
```
|
||||
Receive → Validate & Observe (parallel) → Mutate (sequential by priority)
|
||||
```
|
||||
## Transparent gateway/proxy (`Gateway/`)
|
||||
|
||||
### Priority Ordering
|
||||
**`McpInterceptorGateway`**: Configures an `McpServer` as a transparent proxy. Reads backend `ServerCapabilities`, registers handler delegates (`CallToolHandler`, `ListToolsHandler`, etc.) that route through interceptor chains before forwarding to the backend. By default the gateway is transparent-only; SEP passthrough is opt-in via `ExposeInterceptorProtocol = true`. To connecting clients, the proxy appears to be the backend server.
|
||||
|
||||
- Mutations execute sequentially by `priorityHint` (lower values first)
|
||||
- Ties broken alphabetically by interceptor name
|
||||
- Validations and observability run in parallel (priority ignored)
|
||||
- Recommended ranges: security (-2B to -1M), sanitization (-999K to -10K), normalization (-9999 to -1), default (0), enrichment (1-9999), observability (10K+)
|
||||
**`InterceptorChainRunner`** (internal): Shared interception logic used by both `InterceptingMcpClient` and `McpInterceptorGateway`. Supports multiple interceptor clients executed sequentially — each client's `ExecuteChainAsync` (SDK-level orchestration via list + invoke) receives the mutated payload from the previous one.
|
||||
|
||||
## Implementation Patterns
|
||||
**Gateway split**: `GatewayProxyConfigurator` owns transparent MCP proxy wiring, `GatewayInterceptorProtocolBridge` owns optional SEP passthrough wiring, and `GatewayConnectionForwardingRegistrar` owns connection-bound notification forwarding registration.
|
||||
|
||||
### Creating a Server-Side Interceptor
|
||||
**`McpInterceptorGatewayOptions`**: Configuration — `BackendClient`, optional preconnected `InterceptorClients`, optional external `InterceptorServerConnections`, optional dynamic `InterceptorServerConnectionResolver`, `Events` filter, `TimeoutMs`, `DefaultContext`, optional `ServerInfo` override, and `ExposeInterceptorProtocol`.
|
||||
|
||||
```csharp
|
||||
[McpServerInterceptorType]
|
||||
public class MyServerInterceptor
|
||||
{
|
||||
[McpServerInterceptor(
|
||||
Name = "my-interceptor",
|
||||
Description = "Description of what it does",
|
||||
Events = new[] { InterceptorEvents.ToolsCall },
|
||||
Phase = InterceptorPhase.Request,
|
||||
PriorityHint = 0)]
|
||||
public ValidationInterceptorResult Validate(JsonNode? payload)
|
||||
{
|
||||
// Implementation
|
||||
return new ValidationInterceptorResult { Valid = true };
|
||||
}
|
||||
}
|
||||
```
|
||||
**External interceptor connections**: `McpInterceptorGateway.CreateAsync(...)` can create interceptor clients from standard SDK transports (`IClientTransport`) via `McpInterceptorServerConnectionOptions`. Dynamic per-request transparent resolution is supported via `InterceptorServerConnectionResolver` using the SDK's `MessageContext` plus SEP event name.
|
||||
|
||||
### Creating a Client-Side Interceptor (Attribute-Based)
|
||||
**Builder extension**: `IMcpServerBuilder.WithInterceptorGateway(options)` and `WithInterceptorGateway(Func<IServiceProvider, McpInterceptorGatewayOptions>)` for DI/builder scenarios. Builder-based wiring still expects already-connected interceptor clients; async external transport setup should use `McpInterceptorGateway.CreateAsync(...)`.
|
||||
|
||||
```csharp
|
||||
[McpClientInterceptorType]
|
||||
public class MyClientInterceptors
|
||||
{
|
||||
[McpClientInterceptor(
|
||||
Name = "pii-validator",
|
||||
Description = "Validates tool arguments for PII leakage",
|
||||
Events = new[] { InterceptorEvents.ToolsCall },
|
||||
Phase = InterceptorPhase.Request,
|
||||
PriorityHint = -1000)] // Security interceptors run early
|
||||
public ValidationInterceptorResult ValidatePii(JsonNode? payload)
|
||||
{
|
||||
// Validate payload before sending to server
|
||||
if (ContainsSsn(payload))
|
||||
return ValidationInterceptorResult.Error("SSN detected in arguments");
|
||||
return ValidationInterceptorResult.Success();
|
||||
}
|
||||
|
||||
[McpClientInterceptor(
|
||||
Name = "response-redactor",
|
||||
Type = InterceptorType.Mutation,
|
||||
Events = new[] { InterceptorEvents.ToolsCall },
|
||||
Phase = InterceptorPhase.Response,
|
||||
PriorityHint = 50)]
|
||||
public MutationInterceptorResult RedactResponse(JsonNode? payload)
|
||||
{
|
||||
// Transform response received from server
|
||||
var redacted = RedactSensitiveData(payload);
|
||||
return MutationInterceptorResult.Mutated(redacted);
|
||||
}
|
||||
|
||||
[McpClientInterceptor(
|
||||
Name = "request-logger",
|
||||
Type = InterceptorType.Observability,
|
||||
Events = new[] { InterceptorEvents.ToolsCall },
|
||||
Phase = InterceptorPhase.Request)]
|
||||
public ObservabilityInterceptorResult LogRequest(JsonNode? payload, string @event)
|
||||
{
|
||||
Console.WriteLine($"Tool call: {@event}");
|
||||
return ObservabilityInterceptorResult.Success();
|
||||
}
|
||||
}
|
||||
```
|
||||
**Notification forwarding**: `gateway.RegisterNotificationForwarding(proxyServer)` subscribes to backend `tools/list_changed`, `prompts/list_changed`, `resources/list_changed` and re-sends through the proxy.
|
||||
|
||||
### Using InterceptingMcpClient (Full Integration)
|
||||
**Why handler delegates (not message filters) for the proxy**: The SDK's `With*Handler` methods automatically set `ServerCapabilities`, are type-safe, and are the intended extension point. Message filters are still used for interceptor protocol passthrough (`interceptors/list`, `interceptor/invoke`).
|
||||
|
||||
The `InterceptingMcpClient` wraps `McpClient` and automatically executes interceptor chains for tool operations.
|
||||
**Tool call error handling note**: When an interceptor validation aborts a `tools/call`, the gateway throws `McpInterceptorValidationException`. The SDK catches this and returns `CallToolResult { IsError = true }` (not a JSON-RPC error), since tool execution errors are returned as results by design.
|
||||
|
||||
```csharp
|
||||
// Create MCP client normally
|
||||
await using var client = await McpClient.CreateAsync(transport);
|
||||
## `InterceptingMcpClient` wrapped methods
|
||||
- `CallToolAsync` — `tools/call`
|
||||
- `ListToolsAsync` — `tools/list`
|
||||
- `ListPromptsAsync` — `prompts/list`
|
||||
- `GetPromptAsync` — `prompts/get`
|
||||
- `ListResourcesAsync` — `resources/list`
|
||||
- `ReadResourceAsync` — `resources/read`
|
||||
- `SubscribeToResourceAsync` — `resources/subscribe`
|
||||
- `ListInterceptorsAsync` — direct passthrough to interceptor client
|
||||
|
||||
// Collect interceptors from attributed classes
|
||||
var interceptors = new List<McpClientInterceptor>();
|
||||
interceptors.AddRange(McpClientInterceptorExtensions.WithInterceptors<MyClientInterceptors>());
|
||||
## LLM completion payloads (`Protocol/LlmCompletionPayload.cs`)
|
||||
- `LlmCompletionRequestPayload` — model, messages, maxTokens, temperature, metadata
|
||||
- `LlmCompletionResponsePayload` — model, message, stopReason, usage, metadata
|
||||
- `LlmMessage` — role + content
|
||||
- `LlmUsage` — inputTokens + outputTokens
|
||||
- Registered in `InterceptorJsonContext` for source-gen serialization
|
||||
- Not wired into `InterceptingMcpClient` — these are for custom gateway use
|
||||
|
||||
// Wrap with interceptors using extension method
|
||||
var interceptedClient = client.WithInterceptors(new InterceptingMcpClientOptions
|
||||
{
|
||||
Interceptors = interceptors,
|
||||
DefaultTimeoutMs = 5000,
|
||||
ThrowOnValidationError = true, // Throw McpInterceptorValidationException on errors
|
||||
InterceptResponses = true // Also intercept responses
|
||||
});
|
||||
|
||||
// Use intercepted client - interceptors run automatically
|
||||
try
|
||||
{
|
||||
var result = await interceptedClient.CallToolAsync("my-tool", new Dictionary<string, object?>
|
||||
{
|
||||
["name"] = "John Doe"
|
||||
});
|
||||
Console.WriteLine(result.Content?.FirstOrDefault());
|
||||
}
|
||||
catch (McpInterceptorValidationException ex)
|
||||
{
|
||||
Console.WriteLine($"Blocked by {ex.AbortedAt?.Interceptor}: {ex.AbortedAt?.Reason}");
|
||||
Console.WriteLine(ex.GetDetailedMessage()); // Detailed validation info
|
||||
}
|
||||
|
||||
// List tools (also intercepted)
|
||||
var tools = await interceptedClient.ListToolsAsync();
|
||||
|
||||
// Access inner client for non-intercepted operations
|
||||
var serverInfo = interceptedClient.ServerInfo;
|
||||
```
|
||||
|
||||
### Non-Throwing Mode
|
||||
|
||||
```csharp
|
||||
var interceptedClient = client.WithInterceptors(new InterceptingMcpClientOptions
|
||||
{
|
||||
Interceptors = interceptors,
|
||||
ThrowOnValidationError = false // Return error result instead of throwing
|
||||
});
|
||||
|
||||
var result = await interceptedClient.CallToolAsync("my-tool", args);
|
||||
if (result.IsError)
|
||||
{
|
||||
// Handle validation failure from result
|
||||
Console.WriteLine(result.Content?.FirstOrDefault());
|
||||
}
|
||||
```
|
||||
|
||||
### Registration via DI (Server)
|
||||
|
||||
```csharp
|
||||
builder.Services.AddMcpServer()
|
||||
.WithStdioServerTransport()
|
||||
.WithInterceptors<MyServerInterceptor>();
|
||||
```
|
||||
|
||||
### Client Interceptor Chain Execution (Low-Level)
|
||||
|
||||
For advanced scenarios where you need direct chain execution:
|
||||
|
||||
```csharp
|
||||
// Create interceptors from attributed class
|
||||
var interceptors = McpClientInterceptorExtensions.WithInterceptors<MyClientInterceptor>(services);
|
||||
|
||||
// Execute chain for outgoing requests
|
||||
var executor = new InterceptorChainExecutor(interceptors, services);
|
||||
var result = await executor.ExecuteForSendingAsync(
|
||||
@event: InterceptorEvents.ToolsCall,
|
||||
payload: requestPayload,
|
||||
config: null,
|
||||
timeoutMs: 5000);
|
||||
|
||||
if (result.Status == InterceptorChainStatus.Success)
|
||||
{
|
||||
// Use result.FinalPayload for the request
|
||||
}
|
||||
else if (result.Status == InterceptorChainStatus.ValidationFailed)
|
||||
{
|
||||
// Handle validation failure
|
||||
Console.WriteLine($"Blocked by: {result.AbortedAt?.Interceptor}");
|
||||
}
|
||||
|
||||
// Execute chain for incoming responses
|
||||
var responseResult = await executor.ExecuteForReceivingAsync(
|
||||
@event: InterceptorEvents.ToolsCall,
|
||||
payload: responsePayload);
|
||||
```
|
||||
|
||||
### Validation Results
|
||||
|
||||
Return appropriate severity levels:
|
||||
|
||||
- `ValidationSeverity.Info` - Informational, does not block
|
||||
- `ValidationSeverity.Warn` - Warning, does not block
|
||||
- `ValidationSeverity.Error` - Error, blocks execution
|
||||
|
||||
## InterceptingMcpClient Architecture
|
||||
|
||||
The `InterceptingMcpClient` follows SEP-1763 execution order for tool operations:
|
||||
|
||||
```
|
||||
User Application
|
||||
│
|
||||
▼
|
||||
InterceptingMcpClient.CallToolAsync("my-tool", args)
|
||||
│
|
||||
├─► 1. PayloadConverter.ToCallToolRequestPayload(toolName, args)
|
||||
│
|
||||
├─► 2. _executor.ExecuteForSendingAsync("tools/call", payload)
|
||||
│ │
|
||||
│ ├── Mutations (sequential by priority)
|
||||
│ ├── Validations (parallel)
|
||||
│ └── Observability (fire-and-forget)
|
||||
│
|
||||
├─► 3. If ValidationFailed → throw McpInterceptorValidationException
|
||||
│
|
||||
├─► 4. PayloadConverter.FromCallToolRequestPayload(mutatedPayload)
|
||||
│
|
||||
├─► 5. _inner.CallToolAsync(mutatedParams)
|
||||
│
|
||||
├─► 6. PayloadConverter.ToCallToolResultPayload(result)
|
||||
│
|
||||
├─► 7. _executor.ExecuteForReceivingAsync("tools/call", responsePayload)
|
||||
│
|
||||
└─► 8. Return mutated result
|
||||
```
|
||||
|
||||
**Important:** When using `ListToolsAsync`, the returned `McpClientTool` instances are associated with the inner `McpClient`, not the `InterceptingMcpClient`. Calling `tool.InvokeAsync()` will bypass interceptors. Use `interceptedClient.CallToolAsync(toolName, args)` directly to ensure interceptors execute.
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### When Adding New Protocol Types
|
||||
|
||||
1. Follow the JSON-RPC patterns from the specification
|
||||
2. Place protocol types in `Protocol/` directory
|
||||
3. Use nullable reference types appropriately
|
||||
4. Add XML documentation comments
|
||||
|
||||
### When Adding Server Features
|
||||
|
||||
1. Add handler delegates in `Server/InterceptorServerHandlers.cs`
|
||||
2. Add filter delegates in `Server/InterceptorServerFilters.cs`
|
||||
3. Add builder extension methods in `McpServerInterceptorBuilderExtensions.cs`
|
||||
4. Ensure proper null checking and argument validation
|
||||
|
||||
### When Adding Client Features
|
||||
|
||||
1. Add handler delegates in `Client/InterceptorClientHandlers.cs`
|
||||
2. Add filter delegates in `Client/InterceptorClientFilters.cs`
|
||||
3. Add extension methods in `Client/McpClientInterceptorExtensions.cs`
|
||||
4. Update `InterceptorChainExecutor` if chain execution logic changes
|
||||
5. Ensure proper null checking and argument validation
|
||||
|
||||
### Testing Considerations
|
||||
|
||||
- Test both valid and invalid payloads
|
||||
- Test severity level propagation
|
||||
- Test priority ordering for mutations
|
||||
- Test parallel execution for validations
|
||||
- Test fire-and-forget behavior for observability
|
||||
- Test `McpInterceptorValidationException` details
|
||||
|
||||
## Current Implementation Status
|
||||
|
||||
### Implemented
|
||||
|
||||
**Protocol Types:**
|
||||
- `InterceptorResult` - Abstract base class with JSON polymorphism support
|
||||
- `ValidationInterceptorResult` - For validation interceptors
|
||||
- `MutationInterceptorResult` - For mutation interceptors
|
||||
- `ObservabilityInterceptorResult` - For observability interceptors
|
||||
- `InterceptorChainResult` - Result of chain execution
|
||||
- `McpInterceptorValidationException` - Exception for validation failures
|
||||
- Core types: `Interceptor`, `InterceptorEvent`, `InterceptorPhase`, `InterceptorType`, `InterceptorPriorityHint`
|
||||
|
||||
**Server-Side:**
|
||||
- Attribute-based interceptor registration (`McpServerInterceptor`, `McpServerInterceptorType`)
|
||||
- `McpServerInterceptor` abstract base class
|
||||
- `ReflectionMcpServerInterceptor` for method-based interceptors
|
||||
- DI builder extensions
|
||||
- Handler and filter delegates
|
||||
|
||||
**Client-Side:**
|
||||
- Attribute-based interceptor registration (`McpClientInterceptor`, `McpClientInterceptorType`)
|
||||
- `McpClientInterceptor` abstract base class
|
||||
- `ReflectionMcpClientInterceptor` for method-based interceptors
|
||||
- `InterceptorChainExecutor` - Executes interceptor chains per SEP-1763 spec
|
||||
- Extension methods for creating interceptors from types/assemblies
|
||||
- Handler and filter delegates
|
||||
- **`InterceptingMcpClient`** - Full McpClient wrapper with automatic interceptor execution
|
||||
- **`InterceptingMcpClientOptions`** - Configuration for the intercepting client
|
||||
- **`InterceptingMcpClientExtensions`** - Extension methods: `client.WithInterceptors(options)`
|
||||
- **`PayloadConverter`** - JSON conversion utilities for request/response payloads
|
||||
|
||||
**Samples:**
|
||||
- `InterceptorServiceSample` - Server-side parameter validation interceptor
|
||||
- `InterceptorClientSample` - Client-side interceptor integration demonstrating:
|
||||
- Validation interceptors (PII detection)
|
||||
- Mutation interceptors (argument normalization, response redaction)
|
||||
- Observability interceptors (request/response logging)
|
||||
- Error handling with `McpInterceptorValidationException`
|
||||
|
||||
### Not Yet Implemented
|
||||
|
||||
Refer to SEP-1763 for full specification. Areas that may need work:
|
||||
|
||||
- `interceptor/executeChain` protocol method
|
||||
- Cryptographic signature verification (future feature)
|
||||
- Unit tests for client-side chain execution
|
||||
- LLM completion interceptors (`llm/completion` event)
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `ModelContextProtocol` SDK (0.6.0-preview.10+)
|
||||
- `Microsoft.Extensions.DependencyInjection`
|
||||
- `Microsoft.Extensions.Hosting`
|
||||
- `System.Text.Json`
|
||||
|
||||
## Target Frameworks
|
||||
|
||||
- .NET 10.0 (primary)
|
||||
- .NET 9.0
|
||||
- .NET 8.0
|
||||
- .NET Standard 2.0 (for broader compatibility)
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding a New Event Type
|
||||
|
||||
1. Add constant to `InterceptorEvents.cs`
|
||||
2. Update any event filtering logic
|
||||
3. Add tests for the new event
|
||||
|
||||
### Adding a New Interceptor Type
|
||||
|
||||
1. Add result type in `Protocol/` (e.g., `MutationInterceptorResult.cs`)
|
||||
2. Update `InterceptorType` enum if needed
|
||||
3. Add handler support in server implementation
|
||||
4. Update builder extensions
|
||||
|
||||
### Adding Support for New MCP Operations in InterceptingMcpClient
|
||||
|
||||
1. Add payload conversion methods in `PayloadConverter.cs`
|
||||
2. Add the intercepted operation method in `InterceptingMcpClient.cs`
|
||||
3. Follow the existing pattern: convert → execute sending chain → call inner → execute receiving chain → return
|
||||
|
||||
### Debugging Tips
|
||||
|
||||
- Check that interceptor methods are properly attributed
|
||||
- Verify events match between registration and invocation
|
||||
- Check priority values for mutation ordering issues
|
||||
- Use logging to trace interceptor chain execution
|
||||
- Inspect `McpInterceptorValidationException.ChainResult` for detailed failure info
|
||||
|
||||
## Coding Style Guidelines
|
||||
|
||||
This project follows the coding style of the official [MCP C# SDK](https://github.com/modelcontextprotocol/csharp-sdk).
|
||||
|
||||
### Key Patterns
|
||||
|
||||
**Null Validation:**
|
||||
```csharp
|
||||
// Use Throw helper instead of manual null checks
|
||||
Throw.IfNull(parameter); // NOT: if (parameter is null) throw new ArgumentNullException(...)
|
||||
```
|
||||
|
||||
**Class Modifiers:**
|
||||
- Use `sealed` on classes not designed for inheritance
|
||||
- Use `internal` for implementation details not part of the public API
|
||||
|
||||
**Async/Await:**
|
||||
- Always use `ConfigureAwait(false)` on awaits in library code
|
||||
- Prefer `ValueTask` for hot paths to reduce allocations
|
||||
- Include `CancellationToken` on all async methods
|
||||
|
||||
**JSON Serialization:**
|
||||
- Use `System.Text.Json` exclusively
|
||||
- Use `JsonPropertyName` attributes for property mapping
|
||||
|
||||
### Common Files
|
||||
|
||||
- `Common/Throw.cs` - Helper class for null/argument validation
|
||||
- `Common/Polyfills/` - Compatibility attributes for netstandard2.0
|
||||
|
||||
### Reference
|
||||
|
||||
For coding style examples, compare with:
|
||||
- `/mnt/d/code/ai/mcp/csharp-sdk/src/ModelContextProtocol.Core/`
|
||||
- `/mnt/d/code/ai/mcp/csharp-sdk/src/Common/Throw.cs`
|
||||
## Samples
|
||||
- `InterceptorServerSample` — stdio server hosting 3 interceptors
|
||||
- `GatewaySample` — single gateway: client → interceptor → everything server
|
||||
- `InterceptorClientSample` — client API: list, invoke, execute chain directly
|
||||
- `GatewayChainSample` — chained gateways: security layer → logging layer → server
|
||||
- `TransparentProxySample` — stdio proxy server: clients connect to it as if it were the backend, all requests routed through interceptors
|
||||
- `ConfigDrivenGatewaySample` — sample-only config-driven host composed from the gateway primitives; demonstrates static and dynamic external interceptor resolution with transport-configured backend/interceptor clients
|
||||
|
|
|
|||
|
|
@ -3,23 +3,25 @@ Microsoft Visual Studio Solution File, Format Version 12.00
|
|||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModelContextProtocol.Interceptors", "src\ModelContextProtocol.Interceptors\ModelContextProtocol.Interceptors.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModelContextProtocol.Interceptors", "src\ModelContextProtocol.Interceptors\ModelContextProtocol.Interceptors.csproj", "{354D4988-07B6-4DEC-80D8-F558D9347EF0}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModelContextProtocol.Interceptors.Tests", "tests\ModelContextProtocol.Interceptors.Tests\ModelContextProtocol.Interceptors.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InterceptorServerSample", "samples\InterceptorServerSample\InterceptorServerSample.csproj", "{C3D4E5F6-A7B8-9012-CDEF-123456789012}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GatewaySample", "samples\GatewaySample\GatewaySample.csproj", "{D4E5F6A7-B8C9-0123-DEFA-234567890123}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InterceptorServiceSample", "samples\InterceptorServiceSample\InterceptorServiceSample.csproj", "{45C578FE-FD43-4141-A0E1-68D9E359093F}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InterceptorClientSample", "samples\InterceptorClientSample\InterceptorClientSample.csproj", "{40A103BD-6815-4EE8-B611-AF2E74D72F4D}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InterceptorServerSample", "samples\InterceptorServerSample\InterceptorServerSample.csproj", "{FBBE9908-ED1F-47BD-B36A-8D809CD98DDE}"
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InterceptorClientSample", "samples\InterceptorClientSample\InterceptorClientSample.csproj", "{0D5C2853-1AC5-4C17-A283-DBD0A9DFC841}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GatewayChainSample", "samples\GatewayChainSample\GatewayChainSample.csproj", "{398EFDAC-1286-4BEE-865F-BDB304B0694F}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InterceptorLlmSample", "samples\InterceptorLlmSample\InterceptorLlmSample.csproj", "{1A4B3F17-53C3-4C15-9AC3-99AC01028D58}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TransparentProxySample", "samples\TransparentProxySample\TransparentProxySample.csproj", "{FFF8A540-0AEF-4DF0-A7A1-EB75CDE277B5}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModelContextProtocol.Interceptors.Tests", "tests\ModelContextProtocol.Interceptors.Tests\ModelContextProtocol.Interceptors.Tests.csproj", "{B7548F9A-DF87-433F-B2BB-C3DFCA4F7B7C}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AvatarMoodInterceptorSample", "samples\AvatarMoodInterceptorSample\AvatarMoodInterceptorSample.csproj", "{A70E2A55-6F3C-4C86-9D42-1C8A7F6B3E2A}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
|
|
@ -31,88 +33,110 @@ Global
|
|||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{354D4988-07B6-4DEC-80D8-F558D9347EF0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{354D4988-07B6-4DEC-80D8-F558D9347EF0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{354D4988-07B6-4DEC-80D8-F558D9347EF0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{354D4988-07B6-4DEC-80D8-F558D9347EF0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{354D4988-07B6-4DEC-80D8-F558D9347EF0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{354D4988-07B6-4DEC-80D8-F558D9347EF0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{354D4988-07B6-4DEC-80D8-F558D9347EF0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{354D4988-07B6-4DEC-80D8-F558D9347EF0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{354D4988-07B6-4DEC-80D8-F558D9347EF0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{354D4988-07B6-4DEC-80D8-F558D9347EF0}.Release|x64.Build.0 = Release|Any CPU
|
||||
{354D4988-07B6-4DEC-80D8-F558D9347EF0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{354D4988-07B6-4DEC-80D8-F558D9347EF0}.Release|x86.Build.0 = Release|Any CPU
|
||||
{45C578FE-FD43-4141-A0E1-68D9E359093F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{45C578FE-FD43-4141-A0E1-68D9E359093F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{45C578FE-FD43-4141-A0E1-68D9E359093F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{45C578FE-FD43-4141-A0E1-68D9E359093F}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{45C578FE-FD43-4141-A0E1-68D9E359093F}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{45C578FE-FD43-4141-A0E1-68D9E359093F}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{45C578FE-FD43-4141-A0E1-68D9E359093F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{45C578FE-FD43-4141-A0E1-68D9E359093F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{45C578FE-FD43-4141-A0E1-68D9E359093F}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{45C578FE-FD43-4141-A0E1-68D9E359093F}.Release|x64.Build.0 = Release|Any CPU
|
||||
{45C578FE-FD43-4141-A0E1-68D9E359093F}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{45C578FE-FD43-4141-A0E1-68D9E359093F}.Release|x86.Build.0 = Release|Any CPU
|
||||
{FBBE9908-ED1F-47BD-B36A-8D809CD98DDE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{FBBE9908-ED1F-47BD-B36A-8D809CD98DDE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{FBBE9908-ED1F-47BD-B36A-8D809CD98DDE}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{FBBE9908-ED1F-47BD-B36A-8D809CD98DDE}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{FBBE9908-ED1F-47BD-B36A-8D809CD98DDE}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{FBBE9908-ED1F-47BD-B36A-8D809CD98DDE}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{FBBE9908-ED1F-47BD-B36A-8D809CD98DDE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{FBBE9908-ED1F-47BD-B36A-8D809CD98DDE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{FBBE9908-ED1F-47BD-B36A-8D809CD98DDE}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{FBBE9908-ED1F-47BD-B36A-8D809CD98DDE}.Release|x64.Build.0 = Release|Any CPU
|
||||
{FBBE9908-ED1F-47BD-B36A-8D809CD98DDE}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{FBBE9908-ED1F-47BD-B36A-8D809CD98DDE}.Release|x86.Build.0 = Release|Any CPU
|
||||
{0D5C2853-1AC5-4C17-A283-DBD0A9DFC841}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0D5C2853-1AC5-4C17-A283-DBD0A9DFC841}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0D5C2853-1AC5-4C17-A283-DBD0A9DFC841}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{0D5C2853-1AC5-4C17-A283-DBD0A9DFC841}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{0D5C2853-1AC5-4C17-A283-DBD0A9DFC841}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{0D5C2853-1AC5-4C17-A283-DBD0A9DFC841}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{0D5C2853-1AC5-4C17-A283-DBD0A9DFC841}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0D5C2853-1AC5-4C17-A283-DBD0A9DFC841}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{0D5C2853-1AC5-4C17-A283-DBD0A9DFC841}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{0D5C2853-1AC5-4C17-A283-DBD0A9DFC841}.Release|x64.Build.0 = Release|Any CPU
|
||||
{0D5C2853-1AC5-4C17-A283-DBD0A9DFC841}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{0D5C2853-1AC5-4C17-A283-DBD0A9DFC841}.Release|x86.Build.0 = Release|Any CPU
|
||||
{1A4B3F17-53C3-4C15-9AC3-99AC01028D58}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1A4B3F17-53C3-4C15-9AC3-99AC01028D58}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1A4B3F17-53C3-4C15-9AC3-99AC01028D58}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{1A4B3F17-53C3-4C15-9AC3-99AC01028D58}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{1A4B3F17-53C3-4C15-9AC3-99AC01028D58}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{1A4B3F17-53C3-4C15-9AC3-99AC01028D58}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{1A4B3F17-53C3-4C15-9AC3-99AC01028D58}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1A4B3F17-53C3-4C15-9AC3-99AC01028D58}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1A4B3F17-53C3-4C15-9AC3-99AC01028D58}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{1A4B3F17-53C3-4C15-9AC3-99AC01028D58}.Release|x64.Build.0 = Release|Any CPU
|
||||
{1A4B3F17-53C3-4C15-9AC3-99AC01028D58}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{1A4B3F17-53C3-4C15-9AC3-99AC01028D58}.Release|x86.Build.0 = Release|Any CPU
|
||||
{B7548F9A-DF87-433F-B2BB-C3DFCA4F7B7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B7548F9A-DF87-433F-B2BB-C3DFCA4F7B7C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B7548F9A-DF87-433F-B2BB-C3DFCA4F7B7C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{B7548F9A-DF87-433F-B2BB-C3DFCA4F7B7C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{B7548F9A-DF87-433F-B2BB-C3DFCA4F7B7C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{B7548F9A-DF87-433F-B2BB-C3DFCA4F7B7C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{B7548F9A-DF87-433F-B2BB-C3DFCA4F7B7C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B7548F9A-DF87-433F-B2BB-C3DFCA4F7B7C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B7548F9A-DF87-433F-B2BB-C3DFCA4F7B7C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{B7548F9A-DF87-433F-B2BB-C3DFCA4F7B7C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{B7548F9A-DF87-433F-B2BB-C3DFCA4F7B7C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{B7548F9A-DF87-433F-B2BB-C3DFCA4F7B7C}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.Build.0 = Release|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x64.Build.0 = Release|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x86.Build.0 = Release|Any CPU
|
||||
{D4E5F6A7-B8C9-0123-DEFA-234567890123}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D4E5F6A7-B8C9-0123-DEFA-234567890123}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D4E5F6A7-B8C9-0123-DEFA-234567890123}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{D4E5F6A7-B8C9-0123-DEFA-234567890123}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{D4E5F6A7-B8C9-0123-DEFA-234567890123}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{D4E5F6A7-B8C9-0123-DEFA-234567890123}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{D4E5F6A7-B8C9-0123-DEFA-234567890123}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D4E5F6A7-B8C9-0123-DEFA-234567890123}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D4E5F6A7-B8C9-0123-DEFA-234567890123}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{D4E5F6A7-B8C9-0123-DEFA-234567890123}.Release|x64.Build.0 = Release|Any CPU
|
||||
{D4E5F6A7-B8C9-0123-DEFA-234567890123}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{D4E5F6A7-B8C9-0123-DEFA-234567890123}.Release|x86.Build.0 = Release|Any CPU
|
||||
{40A103BD-6815-4EE8-B611-AF2E74D72F4D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{40A103BD-6815-4EE8-B611-AF2E74D72F4D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{40A103BD-6815-4EE8-B611-AF2E74D72F4D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{40A103BD-6815-4EE8-B611-AF2E74D72F4D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{40A103BD-6815-4EE8-B611-AF2E74D72F4D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{40A103BD-6815-4EE8-B611-AF2E74D72F4D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{40A103BD-6815-4EE8-B611-AF2E74D72F4D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{40A103BD-6815-4EE8-B611-AF2E74D72F4D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{40A103BD-6815-4EE8-B611-AF2E74D72F4D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{40A103BD-6815-4EE8-B611-AF2E74D72F4D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{40A103BD-6815-4EE8-B611-AF2E74D72F4D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{40A103BD-6815-4EE8-B611-AF2E74D72F4D}.Release|x86.Build.0 = Release|Any CPU
|
||||
{398EFDAC-1286-4BEE-865F-BDB304B0694F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{398EFDAC-1286-4BEE-865F-BDB304B0694F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{398EFDAC-1286-4BEE-865F-BDB304B0694F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{398EFDAC-1286-4BEE-865F-BDB304B0694F}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{398EFDAC-1286-4BEE-865F-BDB304B0694F}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{398EFDAC-1286-4BEE-865F-BDB304B0694F}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{398EFDAC-1286-4BEE-865F-BDB304B0694F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{398EFDAC-1286-4BEE-865F-BDB304B0694F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{398EFDAC-1286-4BEE-865F-BDB304B0694F}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{398EFDAC-1286-4BEE-865F-BDB304B0694F}.Release|x64.Build.0 = Release|Any CPU
|
||||
{398EFDAC-1286-4BEE-865F-BDB304B0694F}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{398EFDAC-1286-4BEE-865F-BDB304B0694F}.Release|x86.Build.0 = Release|Any CPU
|
||||
{FFF8A540-0AEF-4DF0-A7A1-EB75CDE277B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{FFF8A540-0AEF-4DF0-A7A1-EB75CDE277B5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{FFF8A540-0AEF-4DF0-A7A1-EB75CDE277B5}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{FFF8A540-0AEF-4DF0-A7A1-EB75CDE277B5}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{FFF8A540-0AEF-4DF0-A7A1-EB75CDE277B5}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{FFF8A540-0AEF-4DF0-A7A1-EB75CDE277B5}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{FFF8A540-0AEF-4DF0-A7A1-EB75CDE277B5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{FFF8A540-0AEF-4DF0-A7A1-EB75CDE277B5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{FFF8A540-0AEF-4DF0-A7A1-EB75CDE277B5}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{FFF8A540-0AEF-4DF0-A7A1-EB75CDE277B5}.Release|x64.Build.0 = Release|Any CPU
|
||||
{FFF8A540-0AEF-4DF0-A7A1-EB75CDE277B5}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{FFF8A540-0AEF-4DF0-A7A1-EB75CDE277B5}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A70E2A55-6F3C-4C86-9D42-1C8A7F6B3E2A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A70E2A55-6F3C-4C86-9D42-1C8A7F6B3E2A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A70E2A55-6F3C-4C86-9D42-1C8A7F6B3E2A}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A70E2A55-6F3C-4C86-9D42-1C8A7F6B3E2A}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A70E2A55-6F3C-4C86-9D42-1C8A7F6B3E2A}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A70E2A55-6F3C-4C86-9D42-1C8A7F6B3E2A}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A70E2A55-6F3C-4C86-9D42-1C8A7F6B3E2A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A70E2A55-6F3C-4C86-9D42-1C8A7F6B3E2A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A70E2A55-6F3C-4C86-9D42-1C8A7F6B3E2A}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A70E2A55-6F3C-4C86-9D42-1C8A7F6B3E2A}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A70E2A55-6F3C-4C86-9D42-1C8A7F6B3E2A}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A70E2A55-6F3C-4C86-9D42-1C8A7F6B3E2A}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{354D4988-07B6-4DEC-80D8-F558D9347EF0} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{45C578FE-FD43-4141-A0E1-68D9E359093F} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
|
||||
{FBBE9908-ED1F-47BD-B36A-8D809CD98DDE} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
|
||||
{0D5C2853-1AC5-4C17-A283-DBD0A9DFC841} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
|
||||
{1A4B3F17-53C3-4C15-9AC3-99AC01028D58} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
|
||||
{B7548F9A-DF87-433F-B2BB-C3DFCA4F7B7C} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{40A103BD-6815-4EE8-B611-AF2E74D72F4D} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
|
||||
{398EFDAC-1286-4BEE-865F-BDB304B0694F} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
|
||||
{FFF8A540-0AEF-4DF0-A7A1-EB75CDE277B5} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
|
||||
{A70E2A55-6F3C-4C86-9D42-1C8A7F6B3E2A} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
|
|
|||
|
|
@ -1,115 +1,272 @@
|
|||
# MCP Interceptors C# SDK
|
||||
# ModelContextProtocol.Interceptors
|
||||
|
||||
This library provides interceptor support for the Model Context Protocol (MCP) .NET SDK. Interceptors enable validation, mutation, and observation of MCP messages without modifying the original server or client implementations.
|
||||
C# implementation of the [MCP Interceptors Extension (SEP-1763)](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1763) — gateway-level interceptors for the Model Context Protocol.
|
||||
|
||||
Architecture work is tracked in [`docs/ARCHITECTURE_PHASES.md`](docs/ARCHITECTURE_PHASES.md).
|
||||
SEP follow-up notes from the implementation are captured in [`docs/SEP_PROPOSAL_NOTES.md`](docs/SEP_PROPOSAL_NOTES.md).
|
||||
|
||||
## Overview
|
||||
|
||||
MCP Interceptors (SEP-1763) allow you to:
|
||||
This package enables creating interceptor servers that sit between MCP clients and servers, providing validation, mutation, and sink (non-blocking observational) capabilities without modifying either the client or server.
|
||||
|
||||
- **Validate** incoming requests before they reach handlers
|
||||
- **Mutate** requests or responses to transform data
|
||||
- **Observe** message flow for logging, metrics, or auditing
|
||||
|
||||
Interceptors can be deployed as:
|
||||
- Sidecars alongside MCP servers
|
||||
- Gateway services that proxy MCP traffic
|
||||
- Embedded validators within applications
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
dotnet add package ModelContextProtocol.Interceptors
|
||||
```
|
||||
Client ──▶ Interceptor Server ──▶ Server
|
||||
◀── (validates/mutates) ◀── (tools)
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```csharp
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using ModelContextProtocol.Interceptors;
|
||||
### Creating an Interceptor Server
|
||||
|
||||
```csharp
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
builder.Services.AddMcpServer()
|
||||
.WithStdioServerTransport()
|
||||
.WithInterceptors<ParameterValidator>();
|
||||
.WithInterceptors<MyInterceptors>();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
```
|
||||
|
||||
## Creating an Interceptor
|
||||
|
||||
```csharp
|
||||
using ModelContextProtocol.Interceptors;
|
||||
using ModelContextProtocol.Interceptors.Server;
|
||||
using System.Text.Json.Nodes;
|
||||
var app = builder.Build();
|
||||
await app.RunAsync();
|
||||
|
||||
[McpServerInterceptorType]
|
||||
public class ParameterValidator
|
||||
public class MyInterceptors
|
||||
{
|
||||
[McpServerInterceptor(
|
||||
Name = "parameter-validator",
|
||||
Description = "Validates tool call parameters",
|
||||
Events = new[] { InterceptorEvents.ToolsCall },
|
||||
Phase = InterceptorPhase.Request)]
|
||||
public ValidationInterceptorResult ValidateToolCall(JsonNode? payload)
|
||||
[McpServerInterceptor(Name = "pii-validator", Type = InterceptorType.Validation,
|
||||
Events = [InterceptionEvents.ToolsCall], Phase = InterceptorPhase.Request)]
|
||||
public static ValidationInterceptorResult ValidatePii(JsonNode payload)
|
||||
{
|
||||
if (payload is null)
|
||||
// Check for PII patterns
|
||||
return ValidationInterceptorResult.Success();
|
||||
}
|
||||
|
||||
[McpServerInterceptor(Name = "email-redactor", Type = InterceptorType.Mutation,
|
||||
Events = [InterceptionEvents.ToolsCall], PriorityHint = -1000)]
|
||||
public static MutationInterceptorResult RedactEmails(JsonNode payload)
|
||||
{
|
||||
// Modify the payload
|
||||
return new MutationInterceptorResult { Modified = true, Payload = modifiedPayload };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Consuming Interceptors from a Client
|
||||
|
||||
```csharp
|
||||
// Connect to the interceptor server
|
||||
var interceptorClient = await McpClient.CreateAsync(interceptorTransport);
|
||||
|
||||
// List available interceptors
|
||||
var interceptors = await interceptorClient.ListInterceptorsAsync();
|
||||
|
||||
// Invoke a single interceptor
|
||||
var result = await interceptorClient.InvokeInterceptorAsync(new InvokeInterceptorRequestParams
|
||||
{
|
||||
Name = "pii-validator",
|
||||
Event = InterceptionEvents.ToolsCall,
|
||||
Phase = InterceptorPhase.Request,
|
||||
Payload = JsonNode.Parse("""{"name":"call-tool","arguments":{"query":"test"}}""")!,
|
||||
});
|
||||
|
||||
// Execute a full chain (SDK-level orchestration: discovers via `interceptors/list`,
|
||||
// then dispatches each applicable interceptor via `interceptor/invoke`)
|
||||
var chainResult = await interceptorClient.ExecuteChainAsync(new ExecuteChainRequestParams
|
||||
{
|
||||
Event = InterceptionEvents.ToolsCall,
|
||||
Phase = InterceptorPhase.Request,
|
||||
Payload = myPayload,
|
||||
});
|
||||
```
|
||||
|
||||
### Gateway Pattern (Client-Side)
|
||||
|
||||
Use `InterceptingMcpClient` when your code is the caller and you want to route operations through interceptors before they reach the server:
|
||||
|
||||
```csharp
|
||||
// Connect to both the interceptor server and the actual MCP server
|
||||
var interceptorClient = await McpClient.CreateAsync(interceptorTransport);
|
||||
var mcpClient = await McpClient.CreateAsync(mcpTransport);
|
||||
|
||||
// Create the gateway wrapper
|
||||
var gateway = new InterceptingMcpClient(mcpClient, new InterceptingMcpClientOptions
|
||||
{
|
||||
InterceptorClient = interceptorClient,
|
||||
Events = [InterceptionEvents.ToolsCall],
|
||||
});
|
||||
|
||||
// All tool calls now flow through interceptors automatically
|
||||
var result = await gateway.CallToolAsync("my-tool", new Dictionary<string, object?> { ["query"] = "test" });
|
||||
```
|
||||
|
||||
### Transparent Proxy (Server-Side)
|
||||
|
||||
Use `McpInterceptorGateway` to create an MCP server that transparently proxies requests through interceptors to a backend server. Connecting clients see the proxy as the backend itself — no client-side changes needed.
|
||||
|
||||
By default, the gateway is transparent-only: it does not advertise or expose the SEP interceptor protocol to connecting clients. If you want the gateway to also expose `interceptors/list` and `interceptor/invoke`, enable that explicitly with `ExposeInterceptorProtocol = true`.
|
||||
|
||||
```
|
||||
Client ──▶ Proxy Server ──▶ Interceptor Server ──▶ Backend Server
|
||||
◀── (transparent) ◀── (validates/mutates) ◀── (tools, etc.)
|
||||
```
|
||||
|
||||
```csharp
|
||||
// Connect to the backend and interceptor servers
|
||||
await using var backend = await McpClient.CreateAsync(backendTransport);
|
||||
await using var interceptors = await McpClient.CreateAsync(interceptorTransport);
|
||||
|
||||
// Create the gateway
|
||||
await using var gateway = new McpInterceptorGateway(new McpInterceptorGatewayOptions
|
||||
{
|
||||
BackendClient = backend,
|
||||
InterceptorClients = [interceptors],
|
||||
Events = [InterceptionEvents.ToolsCall], // null = intercept all events
|
||||
ExposeInterceptorProtocol = false,
|
||||
});
|
||||
|
||||
// Configure and start the proxy server on stdio
|
||||
var serverOptions = new McpServerOptions();
|
||||
gateway.ConfigureServerOptions(serverOptions);
|
||||
|
||||
await using var server = McpServer.Create(
|
||||
new StdioServerTransport("my-proxy"), serverOptions);
|
||||
gateway.RegisterNotificationForwarding(server);
|
||||
await server.RunAsync();
|
||||
```
|
||||
|
||||
The proxy mirrors the backend's advertised capability graph and forwards `*_list_changed` notifications for the supported list surfaces. Multiple interceptor clients can be chained — they execute in order, each receiving the previous client's mutated payload.
|
||||
|
||||
If you want the gateway to connect to external SEP-exposing interceptor servers itself, use the async factory and provide standard MCP client transports:
|
||||
|
||||
```csharp
|
||||
await using var gateway = await McpInterceptorGateway.CreateAsync(new McpInterceptorGatewayOptions
|
||||
{
|
||||
BackendClient = backend,
|
||||
InterceptorServerConnections =
|
||||
[
|
||||
new McpInterceptorServerConnectionOptions
|
||||
{
|
||||
return new ValidationInterceptorResult
|
||||
Transport = new StdioClientTransport(new StdioClientTransportOptions
|
||||
{
|
||||
Valid = false,
|
||||
Severity = ValidationSeverity.Error,
|
||||
Messages = [new() { Message = "Payload is required" }]
|
||||
};
|
||||
Command = "dotnet",
|
||||
Arguments = ["run", "--project", "path/to/interceptor-server"],
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
This follows the same transport-driven pattern as `McpClient.CreateAsync(...)`: you supply `IClientTransport` instances such as `StdioClientTransport`, `HttpClientTransport`, or `StreamClientTransport`, along with optional `McpClientOptions` and logging via `McpInterceptorServerConnectionOptions`.
|
||||
|
||||
For dynamic transparent middleware scenarios, the gateway can also resolve external interceptor connections per request:
|
||||
|
||||
```csharp
|
||||
await using var gateway = new McpInterceptorGateway(new McpInterceptorGatewayOptions
|
||||
{
|
||||
BackendClient = backend,
|
||||
InterceptorClients = [staticSecurityLayer], // optional: resolver-only mode is also supported
|
||||
InterceptorServerConnectionResolver = (context, @event, ct) =>
|
||||
{
|
||||
if (@event == InterceptionEvents.ToolsCall && context.User?.Identity?.Name == "alice")
|
||||
{
|
||||
return ValueTask.FromResult<IReadOnlyList<McpInterceptorServerConnectionOptions>>(
|
||||
[
|
||||
new McpInterceptorServerConnectionOptions
|
||||
{
|
||||
ConnectionId = "alice-logging",
|
||||
Transport = new StdioClientTransport(new StdioClientTransportOptions
|
||||
{
|
||||
Command = "dotnet",
|
||||
Arguments = ["run", "--project", "path/to/alice-interceptor-server"],
|
||||
}),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
return new ValidationInterceptorResult { Valid = true };
|
||||
return ValueTask.FromResult<IReadOnlyList<McpInterceptorServerConnectionOptions>>([]);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The resolver receives the SDK's existing `MessageContext` plus the SEP event name. This lets you base resolution on existing request data such as `context.User`, `context.Items`, scoped services, or transport-specific information without introducing a separate gateway-specific identity abstraction.
|
||||
|
||||
In this first version, dynamic resolution is supported for the transparent proxy path only. SEP passthrough (`ExposeInterceptorProtocol = true`) still requires statically configured interceptor clients.
|
||||
|
||||
**With DI / builder pattern:**
|
||||
|
||||
```csharp
|
||||
builder.Services.AddMcpServer()
|
||||
.WithInterceptorGateway(new McpInterceptorGatewayOptions
|
||||
{
|
||||
BackendClient = backend,
|
||||
InterceptorClients = [interceptors],
|
||||
});
|
||||
```
|
||||
|
||||
The builder extension handles notification forwarding automatically, registering once per session for multi-connection transports (HTTP) and once for single-connection transports (stdio).
|
||||
|
||||
You can also configure the gateway from DI using a service-provider-based overload:
|
||||
|
||||
```csharp
|
||||
builder.Services.AddMcpServer()
|
||||
.WithInterceptorGateway(sp => new McpInterceptorGatewayOptions
|
||||
{
|
||||
BackendClient = sp.GetRequiredService<BackendMcpClientHolder>().Client,
|
||||
InterceptorClients = [sp.GetRequiredService<InterceptorMcpClientHolder>().Client],
|
||||
});
|
||||
```
|
||||
|
||||
Internally, the builder path wires notification forwarding once per logical connection. Current transports may expose a session identifier, but that is treated as an implementation detail rather than a public architectural concept.
|
||||
|
||||
The builder-based gateway configuration currently expects already-connected interceptor clients. External interceptor server connections that need async transport setup should use `McpInterceptorGateway.CreateAsync(...)`.
|
||||
|
||||
If you want to see how these primitives can be composed into a config-driven host without the library defining a config format, see `samples/ConfigDrivenGatewaySample`. The sample shows transport-agnostic outbound connectivity for backend/interceptor servers (including Streamable HTTP). Its JSON schema is sample-only.
|
||||
|
||||
If you want to host the gateway itself over Streamable HTTP instead of stdio, compose `McpInterceptorGateway` with the core SDK's ASP.NET transport support (`AddMcpServer().WithHttpTransport()` and `MapMcp()`).
|
||||
|
||||
To expose the SEP interceptor protocol through the gateway as an advanced mode, set:
|
||||
|
||||
```csharp
|
||||
ExposeInterceptorProtocol = true
|
||||
```
|
||||
|
||||
**Claude Desktop integration** — point it at a proxy binary:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-server-with-interceptors": {
|
||||
"command": "dotnet",
|
||||
"args": ["run", "--project", "path/to/TransparentProxySample"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Interceptor Types
|
||||
|
||||
### Validation Interceptors
|
||||
Validate requests/responses and return pass/fail results with optional error messages.
|
||||
| Type | Execution | Purpose |
|
||||
|------|-----------|---------|
|
||||
| **Validation** | Parallel | Validates payloads. Error severity aborts the chain. |
|
||||
| **Mutation** | Sequential (by priority) | Transforms payloads. Output chains to next mutation. |
|
||||
| **Sink** | Parallel (fire-and-forget) | Non-blocking, non-mutating reactions to context (logging, telemetry, avatar/voice triggers). Failures are swallowed. |
|
||||
|
||||
### Mutation Interceptors
|
||||
Transform request or response payloads before they continue through the pipeline.
|
||||
## Chain Execution Order
|
||||
|
||||
### Observability Interceptors
|
||||
Observe message flow for logging, metrics collection, or auditing without modifying data.
|
||||
**Request phase (sending):** Mutations → Validations → Sinks → send
|
||||
**Response phase (receiving):** Validations → Sinks → Mutations → process
|
||||
|
||||
## Configuration Options
|
||||
## Parameter Binding
|
||||
|
||||
### Phase
|
||||
- `InterceptorPhase.Request` - Intercept incoming requests
|
||||
- `InterceptorPhase.Response` - Intercept outgoing responses
|
||||
- `InterceptorPhase.Both` - Intercept both directions
|
||||
Interceptor methods support automatic parameter binding:
|
||||
|
||||
### Events
|
||||
Interceptors can target specific MCP events:
|
||||
- `InterceptorEvents.ToolsCall` - Tool invocation requests
|
||||
- `InterceptorEvents.PromptGet` - Prompt retrieval
|
||||
- `InterceptorEvents.ResourceRead` - Resource access
|
||||
- And more...
|
||||
| Parameter Type | Bound From |
|
||||
|---------------|------------|
|
||||
| `JsonNode payload` | `InvokeInterceptorRequestParams.Payload` |
|
||||
| `JsonNode config` | `InvokeInterceptorRequestParams.Config` |
|
||||
| `string event` | `InvokeInterceptorRequestParams.Event` |
|
||||
| `InterceptorPhase phase` | `InvokeInterceptorRequestParams.Phase` |
|
||||
| `InvokeInterceptorContext` | `InvokeInterceptorRequestParams.Context` |
|
||||
| `CancellationToken` | Framework cancellation token |
|
||||
| `McpServer` | Current server instance |
|
||||
| `IServiceProvider` | Request-scoped DI container |
|
||||
|
||||
### Priority
|
||||
Use `PriorityHint` to control interceptor execution order (lower values run first).
|
||||
|
||||
## Sample Projects
|
||||
|
||||
See the `samples/InterceptorServiceSample` directory for a complete example of a security-focused validation interceptor.
|
||||
|
||||
## Requirements
|
||||
|
||||
- .NET 8.0 or later (or .NET Standard 2.0 compatible runtime)
|
||||
- ModelContextProtocol SDK 0.1.0-preview.10 or later
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see LICENSE file for details.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please see the FSIG CONTRIBUTING.md for guidelines.
|
||||
Methods can return `InterceptorResult` (or any subclass), `bool` (wrapped as `ValidationInterceptorResult`), or `Task<T>`/`ValueTask<T>` variants of these.
|
||||
|
|
|
|||
|
|
@ -1,90 +0,0 @@
|
|||
# TODO - Code Style Alignment with MCP C# SDK
|
||||
|
||||
This document tracks remaining refactoring items to align the SEP-1763 C# extension code with the official MCP C# SDK coding style and patterns.
|
||||
|
||||
## Completed
|
||||
|
||||
- [x] **Add `Throw` helper class** - Use `Throw.IfNull()` for consistent null validation
|
||||
- Added `Common/Throw.cs` with `IfNull`, `IfNullOrWhiteSpace`, and `IfNegative` methods
|
||||
- Added `CallerArgumentExpressionAttribute` polyfill for netstandard2.0
|
||||
- Added `NullableAttributes` polyfill for netstandard2.0
|
||||
- Updated all files to use the new `Throw.IfNull()` pattern
|
||||
|
||||
## High Priority
|
||||
|
||||
- [ ] **Mark `InterceptorChainExecutor` as `sealed`**
|
||||
- File: `Client/InterceptorChainExecutor.cs`
|
||||
- Change `public class InterceptorChainExecutor` to `public sealed class InterceptorChainExecutor`
|
||||
- Same for `Server/ServerInterceptorChainExecutor.cs`
|
||||
|
||||
## Medium Priority
|
||||
|
||||
- [ ] **Review access modifiers for internal helper types**
|
||||
- `PayloadConverter` - Consider making `internal static class` if only used internally
|
||||
- `ReflectionMcpClientInterceptor` - Already `internal sealed`, verify this is appropriate
|
||||
- `ReflectionMcpServerInterceptor` - Already `internal sealed`, verify this is appropriate
|
||||
|
||||
- [ ] **Add `DebuggerDisplay` to more result types**
|
||||
- Files to update:
|
||||
- `Protocol/InterceptorChainResult.cs`
|
||||
- `Protocol/ValidationInterceptorResult.cs`
|
||||
- `Protocol/MutationInterceptorResult.cs`
|
||||
- `Protocol/ObservabilityInterceptorResult.cs`
|
||||
- Example pattern:
|
||||
```csharp
|
||||
[DebuggerDisplay("{DebuggerDisplay,nq}")]
|
||||
public sealed class InterceptorChainResult
|
||||
{
|
||||
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
|
||||
private string DebuggerDisplay => $"Status = {Status}, Results = {Results.Count}";
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Add `DebuggerDisplay` to `McpClientInterceptor`/`McpServerInterceptor`**
|
||||
- Show interceptor name and type in debugger
|
||||
|
||||
## Low Priority
|
||||
|
||||
- [ ] **Consider source-generated logging**
|
||||
- If logging is added, use `[LoggerMessage]` attributes for high-performance logging
|
||||
- Example:
|
||||
```csharp
|
||||
[LoggerMessage(Level = LogLevel.Debug, Message = "Executing interceptor '{InterceptorName}' for event '{Event}'")]
|
||||
private partial void LogInterceptorExecution(string interceptorName, string @event);
|
||||
```
|
||||
|
||||
- [ ] **Evaluate `ValueTask` vs `Task` for hot paths**
|
||||
- `InterceptorChainExecutor.ExecuteForSendingAsync` currently returns `Task<InterceptorChainResult>`
|
||||
- Consider changing to `ValueTask<InterceptorChainResult>` for reduced allocations
|
||||
- Same for `ExecuteForReceivingAsync`
|
||||
|
||||
- [ ] **Consider static field naming convention (`s_` prefix)**
|
||||
- SDK uses `s_` prefix for static private fields
|
||||
- Review codebase for any static fields that should follow this convention
|
||||
|
||||
- [ ] **Add `[EditorBrowsable(EditorBrowsableState.Never)]` for internal APIs**
|
||||
- Hide implementation details from IntelliSense
|
||||
- Example methods to consider:
|
||||
- Factory methods that are technically public but not intended for typical use
|
||||
|
||||
## Notes
|
||||
|
||||
### SDK Patterns Already Followed
|
||||
|
||||
The extension code already follows many SDK patterns:
|
||||
- File-scoped namespaces
|
||||
- Nullable reference types
|
||||
- `ConfigureAwait(false)` on all awaits
|
||||
- `sealed` on appropriate classes (e.g., `InterceptingMcpClient`)
|
||||
- Factory methods via static `Create()` methods
|
||||
- Comprehensive XML documentation
|
||||
- `DebuggerDisplay` on `Interceptor` class
|
||||
- `JsonPropertyName` attributes
|
||||
- `ValueTask` for `InvokeAsync` methods
|
||||
- Using declarations (`using var`)
|
||||
|
||||
### Reference
|
||||
|
||||
For comparison with the SDK, see:
|
||||
- `/mnt/d/code/ai/mcp/csharp-sdk/src/ModelContextProtocol.Core/`
|
||||
- `/mnt/d/code/ai/mcp/csharp-sdk/src/Common/Throw.cs`
|
||||
167
csharp/sdk/docs/ARCHITECTURE_PHASES.md
Normal file
167
csharp/sdk/docs/ARCHITECTURE_PHASES.md
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
# Architecture Phases
|
||||
|
||||
This document captures the phased structural changes planned for the C# MCP interceptors SDK so we can reference them during implementation.
|
||||
|
||||
## Product Rule
|
||||
|
||||
The package should expose two distinct surfaces:
|
||||
|
||||
1. `WithInterceptors(...)` / interceptor server: pure SEP surface
|
||||
2. `McpInterceptorGateway`: transparent proxy by default
|
||||
3. Gateway SEP passthrough: opt-in advanced mode
|
||||
4. Shared chain execution/runtime: reused by both surfaces
|
||||
|
||||
This lets the package support both:
|
||||
|
||||
1. SEP-aware clients that explicitly use `interceptors/list` and `interceptor/invoke` (chain execution is SDK-side orchestration over those two)
|
||||
2. Transparent infrastructure deployments where clients do not need any code changes
|
||||
|
||||
## Phase 1: Split Gateway Modes
|
||||
|
||||
Goal: make the gateway's public behavior explicit.
|
||||
|
||||
Changes:
|
||||
|
||||
1. Add `ExposeInterceptorProtocol` to `McpInterceptorGatewayOptions`, defaulting to `false`
|
||||
2. In transparent mode, do not advertise interceptor extension capability
|
||||
3. In transparent mode, do not expose `interceptors/list` or `interceptor/invoke`
|
||||
4. In opt-in SEP mode, preserve the current passthrough behavior
|
||||
|
||||
Expected result:
|
||||
|
||||
1. Default gateway looks like a backend proxy, not a backend-plus-admin endpoint
|
||||
2. Advanced users can still enable interceptor protocol passthrough intentionally
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
1. Transparent mode does not advertise interceptor capability
|
||||
2. Transparent mode does not handle `interceptor/*` requests
|
||||
3. SEP mode preserves current passthrough behavior and tests
|
||||
|
||||
## Phase 2: Make Capability Mirroring Truly Transparent
|
||||
|
||||
Goal: align the gateway's advertised contract with the backend server more faithfully.
|
||||
|
||||
Changes:
|
||||
|
||||
1. Extract capability composition into a dedicated internal component or helper
|
||||
2. Start from the backend `ServerCapabilities` rather than hand-mirroring only selected properties
|
||||
3. Mirror standard and experimental capability surfaces, including `Experimental`, `Extensions`, and `Tasks` where present
|
||||
4. Overlay interceptor capability only when `ExposeInterceptorProtocol = true`
|
||||
|
||||
Expected result:
|
||||
|
||||
1. The gateway remains aligned with the core SDK as new capabilities are added
|
||||
2. The gateway can honestly describe itself as transparent by default
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
1. Transparent mode mirrors backend capabilities broadly
|
||||
2. Interceptor capability is only present in SEP mode
|
||||
3. Existing proxy behavior remains intact for supported primitives
|
||||
|
||||
## Phase 3: Separate Proxy Core from SEP Bridge
|
||||
|
||||
Goal: separate transparent proxy concerns from SEP passthrough concerns.
|
||||
|
||||
Changes:
|
||||
|
||||
1. Extract the built-in MCP proxying logic into a dedicated internal component
|
||||
2. Extract interceptor protocol passthrough into a dedicated internal bridge component
|
||||
3. Keep `InterceptorChainRunner` and related chain semantics shared
|
||||
4. Centralize interceptor extension capability key handling behind one internal constant/provider
|
||||
|
||||
Current implementation note:
|
||||
|
||||
1. Transparent proxy wiring lives in `GatewayProxyConfigurator`
|
||||
2. SEP bridge wiring lives in `GatewayInterceptorProtocolBridge`
|
||||
3. The extension capability key is centralized in `InterceptorProtocolConstants`
|
||||
|
||||
Expected result:
|
||||
|
||||
1. Transparent proxy and SEP bridge can evolve independently
|
||||
2. The gateway type becomes a coordinator rather than a monolith
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
1. Proxy wiring can be enabled without SEP bridge wiring
|
||||
2. SEP bridge can be enabled or disabled explicitly
|
||||
3. Internal tests can target the components independently
|
||||
|
||||
## Phase 4: Improve DI and Runtime Lifecycle Shape
|
||||
|
||||
Goal: align the builder path more closely with the core C# SDK's composition model.
|
||||
|
||||
Changes:
|
||||
|
||||
1. Keep the current `WithInterceptorGateway(options)` overload for convenience
|
||||
2. Add a DI-native overload based on a factory or service-provider callback
|
||||
3. Refactor notification forwarding into an internal connection-bound forwarder abstraction
|
||||
4. Treat transport session identifiers as an implementation detail, not a public concept
|
||||
|
||||
Expected result:
|
||||
|
||||
1. The builder path stays declarative and SDK-native
|
||||
2. Runtime ownership of backend/interceptor connections becomes easier to evolve
|
||||
3. The implementation remains compatible with current session-bearing transports without depending on sessions semantically
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
1. Existing convenience builder API continues to work
|
||||
2. A service-provider-based configuration path exists
|
||||
3. Notification forwarding logic is isolated from proxy handler registration
|
||||
|
||||
Current implementation note:
|
||||
|
||||
1. Builder DI now supports both direct options and `Func<IServiceProvider, McpInterceptorGatewayOptions>`
|
||||
2. Connection-bound forwarding registration lives in `GatewayConnectionForwardingRegistrar`
|
||||
3. Transport session identifiers are only used as an internal deduplication mechanism where available
|
||||
|
||||
## Phase 5: Follow-Up Cleanup
|
||||
|
||||
Goal: tighten API roles after the bigger structural changes are done.
|
||||
|
||||
Changes:
|
||||
|
||||
1. Keep extension methods as the primary low-level SEP client API
|
||||
2. Keep `InterceptingMcpClient` as a convenience/orchestration layer rather than the canonical abstraction
|
||||
3. Consider clarifying or making explicit wrapped-client disposal ownership
|
||||
4. Consolidate any duplicated transparent interception flow that remains after Phases 1-4
|
||||
|
||||
Expected result:
|
||||
|
||||
1. Public API roles are clearer
|
||||
2. Future maintenance burden is lower
|
||||
|
||||
This phase is intentionally lower priority than Phases 1-4.
|
||||
|
||||
## Future Feature: External SEP-Exposing Servers
|
||||
|
||||
This comes after the phase work above.
|
||||
|
||||
Goal: allow transparent gateways to source interceptors from external SEP servers rather than only from in-process/directly-provided clients.
|
||||
|
||||
Possible outcomes:
|
||||
|
||||
1. Configuration-driven gateways (for example `mcp-interceptors.json`)
|
||||
2. Auth-aware or per-user interceptor selection
|
||||
3. Custom MCP middleware built on top of the extension's primitives
|
||||
|
||||
Likely direction:
|
||||
|
||||
1. Introduce an interceptor client/provider abstraction in addition to direct `InterceptorClients`
|
||||
2. Support static, per-request, or per-identity interceptor resolution
|
||||
3. Keep chain semantics unchanged regardless of interceptor source
|
||||
|
||||
Current implementation note:
|
||||
|
||||
1. `McpInterceptorGateway.CreateAsync(...)` now supports static external interceptor connections via `McpInterceptorServerConnectionOptions`
|
||||
2. Those connections use the same transport-driven pattern as `McpClient.CreateAsync(...)`
|
||||
3. Dynamic per-request interceptor resolution is now supported for the transparent proxy path via `InterceptorServerConnectionResolver`
|
||||
4. SEP passthrough still requires statically configured interceptor clients
|
||||
|
||||
## Open Design Notes
|
||||
|
||||
1. The interceptor extension capability key should stay aligned with the SEP for now
|
||||
2. If the SEP changes to a reverse-domain extension identifier, we should centralize the key and support a transition path
|
||||
3. Transparent mode should be the default because it matches the expected operational use of the gateway
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
106
csharp/sdk/samples/AvatarMoodInterceptorSample/AvatarRenderer.cs
Normal file
106
csharp/sdk/samples/AvatarMoodInterceptorSample/AvatarRenderer.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
130
csharp/sdk/samples/AvatarMoodInterceptorSample/MoodClassifier.cs
Normal file
130
csharp/sdk/samples/AvatarMoodInterceptorSample/MoodClassifier.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
145
csharp/sdk/samples/AvatarMoodInterceptorSample/Program.cs
Normal file
145
csharp/sdk/samples/AvatarMoodInterceptorSample/Program.cs
Normal 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)!;
|
||||
}
|
||||
|
|
@ -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>
|
||||
124
csharp/sdk/samples/ConfigDrivenGatewaySample/Program.cs
Normal file
124
csharp/sdk/samples/ConfigDrivenGatewaySample/Program.cs
Normal 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; }
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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>
|
||||
177
csharp/sdk/samples/GatewayChainSample/Program.cs
Normal file
177
csharp/sdk/samples/GatewayChainSample/Program.cs
Normal 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();
|
||||
16
csharp/sdk/samples/GatewaySample/GatewaySample.csproj
Normal file
16
csharp/sdk/samples/GatewaySample/GatewaySample.csproj
Normal 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>
|
||||
133
csharp/sdk/samples/GatewaySample/Program.cs
Normal file
133
csharp/sdk/samples/GatewaySample/Program.cs
Normal 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();
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue