Eingehende Ausloeser: n8n und andere starten Agenten (#725, Stufe 1)

Something outside hands an agent a task - an n8n workflow on a mail, a
schedule, a form, or any system that sends an HTTP request.

- Core/Triggers/InboundTriggers: one token per trigger and agent, kept as a
  hash and shown once; a rate per hour; deliveries up to 64 KB. Each
  delivery becomes a task whose content is framed as foreign content with its
  origin; every delivery, accepted or refused, is in the audit (source
  "trigger").
- POST /hooks/trigger on the instance (MCP socket, own token, no browsers);
  the manager forwards /hooks/{instance}/trigger. /api/triggers to issue,
  list and revoke.
- Agent view: a triggers card with the address and an n8n/curl example.
- Nothing writes back: no right to create or change workflows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-01 13:19:05 +02:00
co-authored by Claude Opus 5.5
parent 47a20913b0
commit 73565eff18
14 changed files with 607 additions and 5 deletions
@@ -61,6 +61,60 @@ internal static class McpEndpoints
: Results.Text(answer, "application/json");
});
// #725: An inbound trigger — n8n or any system that sends a request. Outside /api: its
// own token is the key, and it can do one thing — hand its agent a task.
app.MapPost(Security.McpTransport.TriggerPath, async (HttpContext context, AppHost host, CancellationToken ct) =>
{
if (context.Request.Headers.Origin.Count > 0)
return Results.StatusCode(StatusCodes.Status403Forbidden);
if (host.Triggers is not { } triggers)
return ApiEndpoints.Unavailable("No inbound triggers in this instance.");
if (context.Request.ContentLength is > Core.Triggers.InboundTriggers.MaxPayloadBytes)
return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
string payload;
using (var reader = new StreamReader(context.Request.Body))
{
var buffer = new char[Core.Triggers.InboundTriggers.MaxPayloadBytes + 1];
var read = await reader.ReadBlockAsync(buffer.AsMemory(), ct);
if (read > Core.Triggers.InboundTriggers.MaxPayloadBytes)
return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
payload = new string(buffer, 0, read);
}
var result = await triggers.ReceiveAsync(BearerToken(context.Request), payload, ct);
return result.Outcome switch
{
Core.Triggers.TriggerOutcome.Accepted => Results.Json(new { ok = true, taskId = result.TaskId }, statusCode: StatusCodes.Status202Accepted),
Core.Triggers.TriggerOutcome.Unauthorized => Results.Json(new { ok = false, error = result.Message }, statusCode: StatusCodes.Status401Unauthorized),
Core.Triggers.TriggerOutcome.Limited => Results.Json(new { ok = false, error = result.Message }, statusCode: StatusCodes.Status429TooManyRequests),
Core.Triggers.TriggerOutcome.TooLarge => Results.StatusCode(StatusCodes.Status413PayloadTooLarge),
_ => Results.Json(new { ok = false, error = result.Message }, statusCode: StatusCodes.Status503ServiceUnavailable)
};
});
var triggerAdmin = app.MapGroup("/api/triggers");
triggerAdmin.MapGet("/", async (AppHost host, CancellationToken ct) =>
host.Triggers is { } triggers
? Results.Ok((await triggers.ListAsync(ct)).Select(t => new TriggerDto(t.Id, t.Label, t.AgentId, t.PerHour, t.CreatedAtUtc, t.RevokedAtUtc, t.LastUsedAtUtc, t.Received, t.Active)).ToList())
: Results.Ok(Array.Empty<TriggerDto>()));
triggerAdmin.MapPost("/", async (AppHost host, TriggerInputDto input, CancellationToken ct) =>
{
if (host.Triggers is not { } triggers)
return ApiEndpoints.Unavailable("No inbound triggers in this instance.");
if (!host.Instance.Agents.Any(a => string.Equals(a.AgentId, input.AgentId, StringComparison.OrdinalIgnoreCase)))
return Results.BadRequest(new OutcomeDto(false, $"There is no agent '{input.AgentId}'."));
var (t, token) = await triggers.IssueAsync(input.Label ?? "", input.AgentId, input.PerHour ?? 30, ct);
return Results.Ok(new TriggerIssuedDto(new TriggerDto(t.Id, t.Label, t.AgentId, t.PerHour, t.CreatedAtUtc, null, null, 0, true), token));
});
triggerAdmin.MapDelete("/{id}", async (AppHost host, string id, CancellationToken ct) =>
host.Triggers is { } triggers && await triggers.RevokeAsync(id, ct)
? Results.Ok(new OutcomeDto(true, "Revoked."))
: Results.NotFound(new OutcomeDto(false, $"No active trigger '{id}'.")));
// Streamable HTTP lets a server refuse the event stream; this one keeps no sessions.
app.MapMethods("/mcp", ["GET", "DELETE"], () => Results.StatusCode(StatusCodes.Status405MethodNotAllowed));