Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions appsettings.Development.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@
"OtlpExporter": "http://localhost:4317/"
},
"CasCap": {
"AuditConfig": {
"ApprovalEnabled": false,
"NeverRequireApproval": [ "UnlockHouseDoor", "SetSmartPlugPower", "SwitchOffAllHouseLights" ]
},
"FeatureConfig": {
"EnabledFeatures": "Fronius,Buderus,DoorBird,Knx"
},
Expand Down
9 changes: 9 additions & 0 deletions appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@
"OtlpExporter": "http://opentelemetry-collector.monitoring.svc:4317"
},
"CasCap": {
"AuditConfig": {
"Enabled": true,
"RedisStreamKey": "audit:mcp:calls",
"MaxStreamLength": 10000,
"ApprovalEnabled": true,
"ApprovalTimeoutMs": 60000,
"AlwaysRequireApproval": [],
"NeverRequireApproval": []
},
"CachingConfig": {
"RemoteCacheConnectionString": "redis-master.data.svc:6379,allowAdmin=true,abortConnect=false,connectTimeout=1000",
"DistributedLockingEnabled": true,
Expand Down
3 changes: 3 additions & 0 deletions src/CasCap.App.Server/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@
// SystemMcpQueryService is referenced by all agents — register unconditionally.
builder.Services.AddSystemMcp();

// Register MCP audit middleware and configuration.
builder.Services.AddMcpAudit();

// Register SignalR services unconditionally so IHubContext<> is always resolvable for
// HausHub sinks discovered during assembly scanning (e.g. HausHubSinkBuderusService).
// The hub endpoint mapping and Redis backplane are configured later when SignalRHub is enabled.
Expand Down
16 changes: 16 additions & 0 deletions src/CasCap.SmartHaus/Attributes/RequiresApprovalAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace CasCap.Attributes;

/// <summary>
/// Marks an MCP tool method as requiring explicit human approval before execution.
/// </summary>
/// <remarks>
/// When applied to a method decorated with <see cref="ModelContextProtocol.Server.McpServerToolAttribute"/>,
/// the <see cref="CasCap.Services.McpAuditMiddleware"/> will gate execution behind a Signal poll
/// (or console confirmation in development) before invoking the tool.
/// </remarks>
[AttributeUsage(AttributeTargets.Method)]
public sealed class RequiresApprovalAttribute : Attribute
{
/// <summary>Human-readable reason displayed in the approval prompt.</summary>
public string? Reason { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ namespace Microsoft.Extensions.DependencyInjection;
public static class HausServiceCollectionExtensions
{

/// <summary>
/// Registers the <see cref="AuditConfig"/> and <see cref="McpAuditMiddleware"/> singleton
/// for MCP tool call auditing and human-in-the-loop approval.
/// </summary>
public static void AddMcpAudit(this IServiceCollection services)
{
services.AddCasCapConfiguration<AuditConfig>();
services.TryAddSingleton<McpAuditMiddleware>();
}

/// <summary>
/// Registers <see cref="MediaStreamSinkService"/> and its configuration dependencies
/// (<see cref="SecurityAgentConfig"/>, <see cref="MediaConfig"/>).
Expand Down Expand Up @@ -76,8 +86,14 @@ public static void AddComms(this WebApplicationBuilder builder, bool lite = fals
/// Optional root AI configuration supplying shared <see cref="AIConfig.InstructionsPrefix"/>
/// and <see cref="AIConfig.InstructionsSuffix"/>.
/// </param>
/// <param name="configureAgent">
/// Optional delegate to configure the <see cref="AIAgentBuilder"/> pipeline (e.g. add middleware).
/// When <c>null</c> and the <see cref="McpAuditMiddleware"/> is registered, the audit middleware
/// is wired automatically.
/// </param>
public static (IChatClient chatClient, AIAgent agent, string instructions) CreateAgent(this WebApplicationBuilder builder,
ProviderConfig provider, AgentConfig agentConfig, IServiceProvider serviceProvider, List<AITool>? tools = null, string? otelSourceName = null, AIConfig? aiConfig = null)
ProviderConfig provider, AgentConfig agentConfig, IServiceProvider serviceProvider, List<AITool>? tools = null,
string? otelSourceName = null, AIConfig? aiConfig = null, Action<AIAgentBuilder>? configureAgent = null)
{
// Infrastructure auth (k8s ingress basic auth) is only needed for Ollama
// when running outside the cluster. OpenAI/AzureOpenAI auth is handled separately.
Expand All @@ -102,12 +118,26 @@ public static (IChatClient chatClient, AIAgent agent, string instructions) Creat
: null;

return AgentExtensions.CreateAgent(provider, agentConfig, httpClient, tools,
configureAgent: configureAgent ?? BuildDefaultAgentMiddleware(serviceProvider),
instructionsAssembly: typeof(HausServiceCollectionExtensions).Assembly,
aiConfig: aiConfig,
otelSourceName: otelSourceName,
tokenCredential: tokenCredential);
}

/// <summary>
/// Builds a default agent middleware pipeline that wires <see cref="McpAuditMiddleware"/>
/// when it is registered in the service provider.
/// </summary>
private static Action<AIAgentBuilder>? BuildDefaultAgentMiddleware(IServiceProvider serviceProvider)
{
var auditMiddleware = serviceProvider.GetService<McpAuditMiddleware>();
if (auditMiddleware is null)
return null;

return b => b.Use(auditMiddleware.InvokeAsync);
}

/// <summary>
/// Registers the hub-side <see cref="HubEvent"/> event sinks
/// (<c>Console</c> and <c>Metrics</c>) enabled in <see cref="SignalRHubConfig.Sinks"/>
Expand Down
1 change: 1 addition & 0 deletions src/CasCap.SmartHaus/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
global using Azure;
global using CasCap.Abstractions;
global using CasCap.Attributes;
global using CasCap.Common.Abstractions;
global using CasCap.Common.Exceptions;
global using CasCap.Common.Extensions;
Expand Down
46 changes: 46 additions & 0 deletions src/CasCap.SmartHaus/Models/McpAuditRecord.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
namespace CasCap.Models;

/// <summary>
/// Immutable audit record emitted for every MCP tool invocation.
/// </summary>
/// <remarks>
/// Written to structured logs and optionally to a Redis Stream by <see cref="CasCap.Services.McpAuditMiddleware"/>.
/// </remarks>
public sealed record McpAuditRecord
{
/// <summary>UTC timestamp when the tool invocation started.</summary>
[Description("UTC timestamp of the invocation.")]
public required DateTime TimestampUtc { get; init; }

/// <summary>The tool name as registered in the MCP server (snake_case).</summary>
[Description("MCP tool name.")]
public required string ToolName { get; init; }

/// <summary>Serialised arguments passed to the tool.</summary>
[Description("JSON-encoded arguments dictionary.")]
public required string Arguments { get; init; }

/// <summary>Duration of the tool execution.</summary>
[Description("Execution duration.")]
public required TimeSpan Duration { get; init; }

/// <summary>Whether the invocation completed successfully.</summary>
[Description("True if the tool returned without throwing.")]
public required bool Success { get; init; }

/// <summary>Exception type name if the invocation failed; otherwise <c>null</c>.</summary>
[Description("Exception type if failed.")]
public string? ErrorType { get; init; }

/// <summary>Exception message if the invocation failed; otherwise <c>null</c>.</summary>
[Description("Error message if failed.")]
public string? ErrorMessage { get; init; }

/// <summary>Whether human approval was required for this invocation.</summary>
[Description("True if the tool required human approval.")]
public bool ApprovalRequired { get; init; }

/// <summary>Whether approval was granted (null if not required).</summary>
[Description("True if approved, false if denied, null if not required.")]
public bool? ApprovalGranted { get; init; }
}
56 changes: 56 additions & 0 deletions src/CasCap.SmartHaus/Models/_AuditConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
namespace CasCap.Models;

/// <summary>
/// Configuration for the MCP tool call auditing and human-in-the-loop approval gate.
/// </summary>
/// <remarks>
/// Bound from the <c>CasCap:AuditConfig</c> section in <c>appsettings.json</c>.
/// Controls both the audit logging sink and the approval workflow behaviour.
/// </remarks>
public record AuditConfig : IAppConfig
{
/// <inheritdoc/>
public static string ConfigurationSectionName => $"{nameof(CasCap)}:{nameof(AuditConfig)}";

/// <summary>Whether MCP tool call auditing is enabled.</summary>
/// <remarks>Defaults to <c>true</c>.</remarks>
public bool Enabled { get; init; } = true;

/// <summary>Redis Stream key for durable audit history.</summary>
/// <remarks>Defaults to <c>"audit:mcp:calls"</c>.</remarks>
[Required, MinLength(1)]
public string RedisStreamKey { get; init; } = "audit:mcp:calls";

/// <summary>Maximum number of entries retained in the Redis audit stream (MAXLEN).</summary>
/// <remarks>Defaults to <c>10000</c>. Set to <c>0</c> to disable trimming.</remarks>
[Range(0, int.MaxValue)]
public int MaxStreamLength { get; init; } = 10_000;

/// <summary>Whether the human-in-the-loop approval gate is enabled.</summary>
/// <remarks>Defaults to <c>true</c>.</remarks>
public bool ApprovalEnabled { get; init; } = true;

/// <summary>Timeout in milliseconds for waiting for an approval response.</summary>
/// <remarks>Defaults to <c>60000</c> (60 seconds). Used by <see cref="CasCap.Services.McpAuditMiddleware"/>.</remarks>
[Range(1000, 300_000)]
public int ApprovalTimeoutMs { get; init; } = 60_000;

/// <summary>
/// Tool names that always require approval regardless of the <see cref="CasCap.Attributes.RequiresApprovalAttribute"/>.
/// </summary>
/// <remarks>
/// Matching is case-insensitive — use either PascalCase method names (e.g. <c>UnlockHouseDoor</c>)
/// or snake_case registry names (e.g. <c>unlock_house_door</c>).
/// Config-driven override for operational control without redeployment.
/// </remarks>
public string[] AlwaysRequireApproval { get; init; } = [];

/// <summary>
/// Tool names that are exempt from approval even if decorated with <see cref="CasCap.Attributes.RequiresApprovalAttribute"/>.
/// </summary>
/// <remarks>
/// Matching is case-insensitive — use either PascalCase method names or snake_case registry names.
/// Useful for development environments or trusted automation scenarios.
/// </remarks>
public string[] NeverRequireApproval { get; init; } = [];
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public partial class FrontDoorMcpQueryService(IDoorBirdQueryService doorBirdQuer
/// <inheritdoc cref="IDoorBirdQueryService.UnlockFrontDoor()"/>
[McpServerTool]
[Description("Unlocks the front door by triggering the electric door release.")]
[RequiresApproval(Reason = "Physical security action — unlocks the front door.")]
public Task<bool> UnlockHouseDoor() => doorBirdQuerySvc.UnlockFrontDoor();

/// <inheritdoc cref="IDoorBirdQueryService.LightOn"/>
Expand Down
Loading