Skip to content
Merged
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
3 changes: 2 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
<PackageVersion Include="Anthropic" Version="12.22.0" />
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.Extensions" Version="$(MicrosoftAspNetCoreVersion)" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="$(MicrosoftAspNetCoreVersion)" />
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="$(MicrosoftExtensionsAIVersion)" />
Comment thread
codymullins marked this conversation as resolved.
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="$(MicrosoftAspNetCoreVersion)" />
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="$(MicrosoftExtensionsAIVersion)" />
Expand Down Expand Up @@ -79,4 +80,4 @@
<ItemGroup>
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.300" />
</ItemGroup>
</Project>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.Threading.RateLimiting;
using Akka.Actor;
using Akka.Hosting;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.RateLimiting;
using Netclaw.Actors.Hosting;
using Netclaw.Actors.Channels;
Expand Down Expand Up @@ -46,7 +47,7 @@ public static void MapMattermostActionEndpoint(this WebApplication app)
return;
}

app.MapPost("/api/mattermost/actions", async (
app.MapPost("/api/mattermost/actions", async ValueTask<Results<BadRequest<string>, StatusCodeHttpResult, JsonHttpResult<ActionCallbackResponse>>> (
HttpContext httpContext,
IRequiredActor<MattermostGatewayActorKey> gatewayActor,
TimeProvider timeProvider,
Expand All @@ -62,34 +63,34 @@ public static void MapMattermostActionEndpoint(this WebApplication app)
}
catch (JsonException)
{
return Results.BadRequest("Invalid JSON payload.");
return TypedResults.BadRequest("Invalid JSON payload.");
}

if (payload is null)
return Results.BadRequest("Invalid JSON payload.");
return TypedResults.BadRequest("Invalid JSON payload.");

if (payload.RawBodyLength > MaxCallbackBodyBytes)
return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
return TypedResults.StatusCode(StatusCodes.Status413PayloadTooLarge);

if (string.IsNullOrEmpty(payload.UserId)
|| string.IsNullOrEmpty(payload.PostId)
|| string.IsNullOrEmpty(payload.ChannelId))
{
return Results.BadRequest("Missing required fields: user_id, post_id, channel_id.");
return TypedResults.BadRequest("Missing required fields: user_id, post_id, channel_id.");
}

if (payload.Context is null
|| !payload.Context.TryGetValue("action_token", out var actionToken)
|| string.IsNullOrWhiteSpace(actionToken))
{
return Results.BadRequest("Missing required context field: action_token.");
return TypedResults.BadRequest("Missing required context field: action_token.");
}

if (!actionStore.TryConsume(actionToken, out var storedAction)
|| storedAction is null)
{
logger.LogWarning("Rejected Mattermost action callback with invalid, expired, or replayed action token");
return Results.Json(new ActionCallbackResponse
return TypedResults.Json(new ActionCallbackResponse
{
EphemeralText = "That approval button is no longer valid. Please re-issue the request and try again."
}, JsonOptions);
Expand All @@ -98,7 +99,7 @@ public static void MapMattermostActionEndpoint(this WebApplication app)
if (!MattermostAclPolicy.IsAllowedUser(new MattermostUserId(payload.UserId), options))
{
logger.LogWarning("Rejected Mattermost action callback from non-allowed user {UserId}", payload.UserId);
return Results.Json(new ActionCallbackResponse
return TypedResults.Json(new ActionCallbackResponse
{
EphemeralText = "You are not authorized to respond to tool approval prompts."
}, JsonOptions);
Expand All @@ -109,7 +110,7 @@ public static void MapMattermostActionEndpoint(this WebApplication app)
logger.LogWarning(
"Rejected Mattermost action callback with mismatched routing data channel={ChannelId}",
payload.ChannelId);
return Results.BadRequest("Callback routing data did not match the issued action.");
return TypedResults.BadRequest("Callback routing data did not match the issued action.");
}

// Bound the actor-resolution wait so a daemon still mid-startup
Expand All @@ -126,7 +127,7 @@ public static void MapMattermostActionEndpoint(this WebApplication app)
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
logger.LogError("Mattermost callback received before the Mattermost gateway actor was registered.");
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
return TypedResults.StatusCode(StatusCodes.Status503ServiceUnavailable);
}

var interaction = new MattermostGatewayInteraction(
Expand All @@ -148,24 +149,28 @@ public static void MapMattermostActionEndpoint(this WebApplication app)

return reply switch
{
CommandAck => Results.Json(new ActionCallbackResponse
CommandAck => TypedResults.Json(new ActionCallbackResponse
{
EphemeralText = $"You selected: **{ApprovalOptionKeys.LabelFor(storedAction.SelectedKey)}**"
}, JsonOptions),
CommandNack nack => Results.Json(new ActionCallbackResponse
CommandNack nack => TypedResults.Json(new ActionCallbackResponse
{
EphemeralText = MapRejectMessage(nack.Reason)
}, JsonOptions),
_ => Results.StatusCode(StatusCodes.Status500InternalServerError)
_ => TypedResults.StatusCode(StatusCodes.Status500InternalServerError)
};
}
catch (Exception ex)
{
logger.LogError(ex, "Failed routing Mattermost action callback for call {CallId}", storedAction.CallId);
return Results.StatusCode(500);
return TypedResults.StatusCode(500);
}

}).RequireRateLimiting(CallbackRateLimitPolicy).AllowAnonymous();
})
.WithName("MattermostActionCallback")
.WithSummary("Handle a Mattermost interactive approval button callback.")
.WithTags("Mattermost")
.RequireRateLimiting(CallbackRateLimitPolicy).AllowAnonymous();
}

private sealed class ActionCallbackPayload
Expand All @@ -179,9 +184,9 @@ private sealed class ActionCallbackPayload
public int RawBodyLength { get; set; }
}

private sealed class ActionCallbackResponse
private sealed record ActionCallbackResponse
{
public string? EphemeralText { get; set; }
public string? EphemeralText { get; init; }
}

internal static void AddMattermostActionEndpointRateLimiting(this IServiceCollection services)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,41 @@
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Netclaw.Daemon.Services;

namespace Netclaw.Daemon.Lifecycle;

/// <summary>Request to shut down the daemon, sourced from query string.</summary>
public sealed record ShutdownDaemonRequest([FromQuery(Name = "reason")] string? Reason);

/// <summary>Successful shutdown acknowledgement: echoes the reason and reports the daemon PID.</summary>
public sealed record ShutdownDaemonResponse(string Reason, int Pid);

/// <summary>Error payload returned when a lifecycle request is malformed.</summary>
public sealed record LifecycleErrorResponse(string Error);

public static class LifecycleEndpointRouteBuilderExtensions
{
public static IEndpointRouteBuilder MapLifecycleEndpoints(this IEndpointRouteBuilder app)
{
// Daemon lifecycle endpoint — CLI calls this before sending SIGTERM.
// Config-triggered restart coordination happens inside DaemonRestartCoordinator.
app.MapPost("/api/lifecycle/shutdown", (
DaemonLifecycleNotifier notifier,
HttpRequest request) =>
app.MapPost("/api/lifecycle/shutdown", Results<Ok<ShutdownDaemonResponse>, BadRequest<LifecycleErrorResponse>> (
[AsParameters] ShutdownDaemonRequest request,
DaemonLifecycleNotifier notifier) =>
{
var reason = request.Query["reason"].ToString();
if (string.IsNullOrEmpty(reason))
return Results.BadRequest(new { error = "reason query parameter is required" });
if (string.IsNullOrEmpty(request.Reason))
return TypedResults.BadRequest(new LifecycleErrorResponse("reason query parameter is required"));

notifier.NotifyShutdown(reason);
return Results.Ok(new { reason, pid = Environment.ProcessId });
}).RequireAuthorization();
notifier.NotifyShutdown(request.Reason);
return TypedResults.Ok(new ShutdownDaemonResponse(request.Reason, Environment.ProcessId));
})
.WithName("ShutdownDaemon")
.WithSummary("Request a graceful daemon shutdown ahead of SIGTERM.")
.WithTags("Lifecycle")
.RequireAuthorization();

return app;
}
Expand Down
121 changes: 79 additions & 42 deletions src/Netclaw.Daemon/Mcp/McpEndpointRouteBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,115 +4,152 @@
// </copyright>
// -----------------------------------------------------------------------
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Netclaw.Configuration;
using Netclaw.Tools;

namespace Netclaw.Daemon.Mcp;

/// <summary>Query string for the MCP OAuth browser callback.</summary>
public sealed record McpOAuthCallbackQuery(
[FromQuery(Name = "code")] string? Code,
[FromQuery(Name = "state")] string? State);

/// <summary>Authorization URL and opaque state returned when an MCP OAuth flow starts.</summary>
public sealed record McpOAuthStartResponse(string AuthorizationUrl, string State);

/// <summary>Connection status for a single MCP server.</summary>
public sealed record McpServerStatusDto(string State, int ToolCount, string? Error);

/// <summary>OAuth flow status for an MCP server or pending state token.</summary>
public sealed record McpOAuthStatusResponse(string Status);

/// <summary>Error payload returned when an MCP request is malformed or unknown.</summary>
public sealed record McpErrorResponse(string Error);

public static class McpEndpointRouteBuilderExtensions
{
public static IEndpointRouteBuilder MapMcpEndpoints(this IEndpointRouteBuilder app)
{
// MCP OAuth 2.1 endpoints
app.MapPost("/api/mcp/oauth/start/{name}", async (
app.MapPost("/api/mcp/oauth/start/{name}", async ValueTask<Results<Ok<McpOAuthStartResponse>, NotFound<McpErrorResponse>, BadRequest<McpErrorResponse>>> (
string name,
McpOAuthService oauthService,
Dictionary<string, McpServerEntry> mcpServers,
CancellationToken ct) =>
{
if (!mcpServers.TryGetValue(name, out var entry))
return Results.NotFound(new { error = $"MCP server '{name}' not found" });
return TypedResults.NotFound(new McpErrorResponse($"MCP server '{name}' not found"));

if (string.IsNullOrWhiteSpace(entry.Url))
return Results.BadRequest(new { error = $"MCP server '{name}' has no URL (OAuth requires HTTP transport)" });
return TypedResults.BadRequest(new McpErrorResponse($"MCP server '{name}' has no URL (OAuth requires HTTP transport)"));

var (authUrl, state) = await oauthService.StartAuthorizationFlowAsync(new McpServerName(name), entry, ct);
return Results.Ok(new { authorizationUrl = authUrl, state });
}).RequireAuthorization();
return TypedResults.Ok(new McpOAuthStartResponse(authUrl, state));
})
.WithName("StartMcpOAuth")
.WithSummary("Start an OAuth 2.1 authorization flow for an MCP server.")
.WithTags("MCP")
.RequireAuthorization();

app.MapGet("/api/mcp/oauth/callback", async (
HttpContext context,
app.MapGet("/api/mcp/oauth/callback", async ValueTask<ContentHttpResult> (
[AsParameters] McpOAuthCallbackQuery query,
McpOAuthService oauthService,
IMcpReconnectable mcpManager,
ILogger<McpClientManager> reconnectLogger,
CancellationToken ct) =>
{
var code = context.Request.Query["code"].ToString();
var state = context.Request.Query["state"].ToString();

if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
if (string.IsNullOrEmpty(query.Code) || string.IsNullOrEmpty(query.State))
{
context.Response.ContentType = "text/html";
await context.Response.WriteAsync(
"<html><body><h2>Authorization failed</h2><p>Missing code or state parameter.</p></body></html>", ct);
return;
return TypedResults.Content(
"<html><body><h2>Authorization failed</h2><p>Missing code or state parameter.</p></body></html>",
"text/html");
}

try
{
await oauthService.CompleteAuthorizationAsync(code, state, ct);
await oauthService.CompleteAuthorizationAsync(query.Code, query.State, ct);

// Auto-reconnect the MCP server now that we have a valid token.
// Resolved as IMcpReconnectable so the callback is not coupled to the
// concrete McpClientManager type, which is heavyweight and hard to stub in tests.
var serverName = oauthService.GetServerNameForState(state);
var serverName = oauthService.GetServerNameForState(query.State);
if (serverName is not null)
{
var mcpManager = context.RequestServices.GetRequiredService<IMcpReconnectable>();
var reconnectLogger = context.RequestServices.GetRequiredService<ILogger<McpClientManager>>();
_ = Task.Run(async () =>
{
try { await mcpManager.TryReconnectAsync(serverName.Value, CancellationToken.None); }
catch (Exception ex) { reconnectLogger.LogWarning(ex, "Post-OAuth reconnect failed for MCP server '{Name}'", serverName.Value.Value); }
}, CancellationToken.None);
}

context.Response.ContentType = "text/html";
await context.Response.WriteAsync(
"<html><body><h2>Authorization complete</h2><p>You may close this tab.</p></body></html>", ct);
return TypedResults.Content(
"<html><body><h2>Authorization complete</h2><p>You may close this tab.</p></body></html>",
"text/html");
}
catch (Exception ex)
{
context.Response.StatusCode = 500;
context.Response.ContentType = "text/html";
await context.Response.WriteAsync(
$"<html><body><h2>Authorization failed</h2><p>{System.Net.WebUtility.HtmlEncode(ex.Message)}</p></body></html>", ct);
return TypedResults.Content(
$"<html><body><h2>Authorization failed</h2><p>{System.Net.WebUtility.HtmlEncode(ex.Message)}</p></body></html>",
"text/html",
contentEncoding: null,
statusCode: StatusCodes.Status500InternalServerError);
}
}).AllowAnonymous();
})
.WithName("McpOAuthCallback")
.WithSummary("Browser redirect callback that completes an MCP OAuth flow.")
.WithTags("MCP")
.AllowAnonymous();

app.MapGet("/api/mcp/statuses", (McpClientManager mcpManager) =>
{
var statuses = mcpManager.GetServerStatuses();
var result = statuses.ToDictionary(
kvp => kvp.Key.Value,
kvp => new
{
state = kvp.Value.State.ToString(),
toolCount = kvp.Value.ToolCount,
error = kvp.Value.ErrorMessage,
});
return Results.Ok(result);
}).RequireAuthorization();
kvp => new McpServerStatusDto(
kvp.Value.State.ToString(),
kvp.Value.ToolCount,
kvp.Value.ErrorMessage));
return TypedResults.Ok(result);
})
.WithName("GetMcpServerStatuses")
.WithSummary("Get the connection status of all configured MCP servers.")
.WithTags("MCP")
.RequireAuthorization();

app.MapGet("/api/mcp/tools/{name}", (string name, McpClientManager mcpManager) =>
{
var tools = mcpManager.GetToolNames(new McpServerName(name));
return Results.Ok(tools);
}).RequireAuthorization();
return TypedResults.Ok(tools);
})
.WithName("GetMcpServerTools")
.WithSummary("List the tool names exposed by a single MCP server.")
.WithTags("MCP")
.RequireAuthorization();

app.MapGet("/api/mcp/oauth/status/{name}", (string name, McpOAuthService oauthService) =>
{
var status = oauthService.GetFlowStatus(new McpServerName(name));
return Results.Ok(new { status = status.ToString() });
}).RequireAuthorization();
return TypedResults.Ok(new McpOAuthStatusResponse(status.ToString()));
})
.WithName("GetMcpOAuthStatus")
.WithSummary("Get the OAuth flow status for an MCP server by name.")
.WithTags("MCP")
.RequireAuthorization();

app.MapGet("/api/mcp/oauth/status-by-state/{state}", (string state, McpOAuthService oauthService) =>
{
var status = oauthService.GetFlowStatusByState(state);
// Tokens are persisted daemon-side — never expose them over HTTP.
return Results.Ok(new { status = status.ToString() });
}).RequireAuthorization();
return TypedResults.Ok(new McpOAuthStatusResponse(status.ToString()));
})
.WithName("GetMcpOAuthStatusByState")
.WithSummary("Get the OAuth flow status for an MCP server by state token.")
.WithTags("MCP")
.RequireAuthorization();

return app;
}
Expand Down
Loading
Loading