- Webhook registration via MCP (register_webhook, list_webhooks, delete_webhook) - HMAC-SHA256 payload signing, SSRF-safe HTTP client, loop detection (depth 5) - 8-worker goroutine delivery pool with exponential backoff retry (1s/5s/30s) - Dead letter queue with auto-purge, auto-disable after 50 consecutive failures - Per-agent rate limiting (60 deliveries/min) - K8s Job runner (register_k8s_handler, list_k8s_handlers, delete_k8s_handler) - Auto-detect in-cluster via InClusterConfig, NoopRunner fallback - REST API for webhook deliveries, dead letters, K8s job runs and logs - Web UI: webhook management, K8s handler pages, dead letters view - MultiDispatcher fan-out pattern for webhook + K8s event dispatch - SQLite migration 009: webhooks, webhook_deliveries, k8s_handlers, k8s_job_runs - 51 tests across 9 test packages, all passing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
317 lines
10 KiB
Go
317 lines
10 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/mark3labs/mcp-go/server"
|
|
|
|
"github.com/synapbus/synapbus/internal/k8s"
|
|
"github.com/synapbus/synapbus/internal/webhooks"
|
|
)
|
|
|
|
// WebhookToolRegistrar registers webhook and K8s handler MCP tools.
|
|
type WebhookToolRegistrar struct {
|
|
webhookService *webhooks.WebhookService
|
|
k8sService *k8s.K8sService
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewWebhookToolRegistrar creates a new webhook tool registrar.
|
|
func NewWebhookToolRegistrar(webhookService *webhooks.WebhookService, k8sService *k8s.K8sService) *WebhookToolRegistrar {
|
|
return &WebhookToolRegistrar{
|
|
webhookService: webhookService,
|
|
k8sService: k8sService,
|
|
logger: slog.Default().With("component", "mcp-webhook-tools"),
|
|
}
|
|
}
|
|
|
|
// RegisterAll registers all webhook and K8s handler tools on the MCP server.
|
|
func (r *WebhookToolRegistrar) RegisterAll(s *server.MCPServer) {
|
|
count := 0
|
|
|
|
// Webhook tools
|
|
if r.webhookService != nil {
|
|
s.AddTool(r.registerWebhookTool(), r.handleRegisterWebhook)
|
|
s.AddTool(r.listWebhooksTool(), r.handleListWebhooks)
|
|
s.AddTool(r.deleteWebhookTool(), r.handleDeleteWebhook)
|
|
count += 3
|
|
}
|
|
|
|
// K8s handler tools
|
|
if r.k8sService != nil {
|
|
s.AddTool(r.registerK8sHandlerTool(), r.handleRegisterK8sHandler)
|
|
s.AddTool(r.listK8sHandlersTool(), r.handleListK8sHandlers)
|
|
s.AddTool(r.deleteK8sHandlerTool(), r.handleDeleteK8sHandler)
|
|
count += 3
|
|
}
|
|
|
|
r.logger.Info("webhook/K8s MCP tools registered", "count", count)
|
|
}
|
|
|
|
// --- Webhook Tool Definitions ---
|
|
|
|
func (r *WebhookToolRegistrar) registerWebhookTool() mcp.Tool {
|
|
return mcp.NewTool("register_webhook",
|
|
mcp.WithDescription("Register a webhook URL to receive event notifications. When matching events occur (messages, mentions), SynapBus will POST a signed JSON payload to your URL. Max 3 webhooks per agent. HTTPS required in production."),
|
|
mcp.WithString("url", mcp.Description("HTTPS URL to receive webhook POST requests"), mcp.Required()),
|
|
mcp.WithString("events", mcp.Description("Comma-separated event types: message.received, message.mentioned, channel.message"), mcp.Required()),
|
|
mcp.WithString("secret", mcp.Description("Shared secret for HMAC-SHA256 payload signing (X-SynapBus-Signature header)"), mcp.Required()),
|
|
)
|
|
}
|
|
|
|
func (r *WebhookToolRegistrar) listWebhooksTool() mcp.Tool {
|
|
return mcp.NewTool("list_webhooks",
|
|
mcp.WithDescription("List your registered webhooks and their status (active/disabled, failure counts)."),
|
|
)
|
|
}
|
|
|
|
func (r *WebhookToolRegistrar) deleteWebhookTool() mcp.Tool {
|
|
return mcp.NewTool("delete_webhook",
|
|
mcp.WithDescription("Delete one of your registered webhooks by ID."),
|
|
mcp.WithNumber("webhook_id", mcp.Description("ID of the webhook to delete"), mcp.Required()),
|
|
)
|
|
}
|
|
|
|
// --- Webhook Tool Handlers ---
|
|
|
|
func (r *WebhookToolRegistrar) handleRegisterWebhook(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
agentName, ok := extractAgentName(ctx)
|
|
if !ok {
|
|
return mcp.NewToolResultError("authentication required"), nil
|
|
}
|
|
|
|
url := req.GetString("url", "")
|
|
eventsStr := req.GetString("events", "")
|
|
secret := req.GetString("secret", "")
|
|
|
|
if url == "" {
|
|
return mcp.NewToolResultError("'url' parameter is required"), nil
|
|
}
|
|
if eventsStr == "" {
|
|
return mcp.NewToolResultError("'events' parameter is required"), nil
|
|
}
|
|
if secret == "" {
|
|
return mcp.NewToolResultError("'secret' parameter is required"), nil
|
|
}
|
|
|
|
// Parse comma-separated events
|
|
events := parseEvents(eventsStr)
|
|
|
|
wh, err := r.webhookService.RegisterWebhook(ctx, agentName, url, events, secret)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(fmt.Sprintf("register_webhook failed: %s", err)), nil
|
|
}
|
|
|
|
return resultJSON(map[string]any{
|
|
"webhook_id": wh.ID,
|
|
"url": wh.URL,
|
|
"events": wh.Events,
|
|
"status": wh.Status,
|
|
})
|
|
}
|
|
|
|
func (r *WebhookToolRegistrar) handleListWebhooks(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
agentName, ok := extractAgentName(ctx)
|
|
if !ok {
|
|
return mcp.NewToolResultError("authentication required"), nil
|
|
}
|
|
|
|
hooks, err := r.webhookService.ListWebhooks(ctx, agentName)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(fmt.Sprintf("list_webhooks failed: %s", err)), nil
|
|
}
|
|
|
|
result := make([]map[string]any, len(hooks))
|
|
for i, wh := range hooks {
|
|
result[i] = map[string]any{
|
|
"id": wh.ID,
|
|
"url": wh.URL,
|
|
"events": wh.Events,
|
|
"status": wh.Status,
|
|
"consecutive_failures": wh.ConsecutiveFailures,
|
|
"created_at": wh.CreatedAt,
|
|
}
|
|
}
|
|
|
|
return resultJSON(map[string]any{
|
|
"webhooks": result,
|
|
"count": len(result),
|
|
})
|
|
}
|
|
|
|
func (r *WebhookToolRegistrar) handleDeleteWebhook(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
agentName, ok := extractAgentName(ctx)
|
|
if !ok {
|
|
return mcp.NewToolResultError("authentication required"), nil
|
|
}
|
|
|
|
webhookID, err := req.RequireInt("webhook_id")
|
|
if err != nil {
|
|
return mcp.NewToolResultError("'webhook_id' parameter is required"), nil
|
|
}
|
|
|
|
if err := r.webhookService.DeleteWebhook(ctx, agentName, int64(webhookID)); err != nil {
|
|
return mcp.NewToolResultError(fmt.Sprintf("delete_webhook failed: %s", err)), nil
|
|
}
|
|
|
|
return resultJSON(map[string]any{
|
|
"deleted": true,
|
|
"webhook_id": webhookID,
|
|
})
|
|
}
|
|
|
|
// --- K8s Handler Tool Definitions ---
|
|
|
|
func (r *WebhookToolRegistrar) registerK8sHandlerTool() mcp.Tool {
|
|
return mcp.NewTool("register_k8s_handler",
|
|
mcp.WithDescription("Register a Kubernetes Job handler. When matching events occur, SynapBus launches a K8s Job with message data injected via environment variables. Only available when SynapBus runs in-cluster."),
|
|
mcp.WithString("image", mcp.Description("Container image to run (e.g. myregistry/handler:v1)"), mcp.Required()),
|
|
mcp.WithString("events", mcp.Description("Comma-separated event types: message.received, message.mentioned, channel.message"), mcp.Required()),
|
|
mcp.WithString("namespace", mcp.Description("Kubernetes namespace (default: SynapBus's namespace)")),
|
|
mcp.WithString("resources_memory", mcp.Description("Memory limit (e.g. 256Mi, 1Gi)")),
|
|
mcp.WithString("resources_cpu", mcp.Description("CPU limit (e.g. 100m, 1)")),
|
|
mcp.WithString("env", mcp.Description("Comma-separated KEY=VALUE environment variables")),
|
|
mcp.WithNumber("timeout_seconds", mcp.Description("Job timeout in seconds (default 300)")),
|
|
)
|
|
}
|
|
|
|
func (r *WebhookToolRegistrar) listK8sHandlersTool() mcp.Tool {
|
|
return mcp.NewTool("list_k8s_handlers",
|
|
mcp.WithDescription("List your registered Kubernetes Job handlers and their status."),
|
|
)
|
|
}
|
|
|
|
func (r *WebhookToolRegistrar) deleteK8sHandlerTool() mcp.Tool {
|
|
return mcp.NewTool("delete_k8s_handler",
|
|
mcp.WithDescription("Delete one of your registered Kubernetes Job handlers by ID."),
|
|
mcp.WithNumber("handler_id", mcp.Description("ID of the K8s handler to delete"), mcp.Required()),
|
|
)
|
|
}
|
|
|
|
// --- K8s Handler Tool Handlers ---
|
|
|
|
func (r *WebhookToolRegistrar) handleRegisterK8sHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
agentName, ok := extractAgentName(ctx)
|
|
if !ok {
|
|
return mcp.NewToolResultError("authentication required"), nil
|
|
}
|
|
|
|
image := req.GetString("image", "")
|
|
eventsStr := req.GetString("events", "")
|
|
|
|
if image == "" {
|
|
return mcp.NewToolResultError("'image' parameter is required"), nil
|
|
}
|
|
if eventsStr == "" {
|
|
return mcp.NewToolResultError("'events' parameter is required"), nil
|
|
}
|
|
|
|
events := parseEvents(eventsStr)
|
|
|
|
// Parse env vars
|
|
envMap := make(map[string]string)
|
|
if envStr := req.GetString("env", ""); envStr != "" {
|
|
for _, pair := range strings.Split(envStr, ",") {
|
|
pair = strings.TrimSpace(pair)
|
|
if parts := strings.SplitN(pair, "=", 2); len(parts) == 2 {
|
|
envMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
|
}
|
|
}
|
|
}
|
|
|
|
handlerReq := k8s.RegisterHandlerRequest{
|
|
Image: image,
|
|
Events: events,
|
|
Namespace: req.GetString("namespace", ""),
|
|
ResourcesMemory: req.GetString("resources_memory", ""),
|
|
ResourcesCPU: req.GetString("resources_cpu", ""),
|
|
Env: envMap,
|
|
TimeoutSeconds: req.GetInt("timeout_seconds", 300),
|
|
}
|
|
|
|
handler, err := r.k8sService.RegisterHandler(ctx, agentName, handlerReq)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(fmt.Sprintf("register_k8s_handler failed: %s", err)), nil
|
|
}
|
|
|
|
return resultJSON(map[string]any{
|
|
"handler_id": handler.ID,
|
|
"image": handler.Image,
|
|
"events": handler.Events,
|
|
"namespace": handler.Namespace,
|
|
"timeout_seconds": handler.TimeoutSeconds,
|
|
"status": handler.Status,
|
|
})
|
|
}
|
|
|
|
func (r *WebhookToolRegistrar) handleListK8sHandlers(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
agentName, ok := extractAgentName(ctx)
|
|
if !ok {
|
|
return mcp.NewToolResultError("authentication required"), nil
|
|
}
|
|
|
|
handlers, err := r.k8sService.ListHandlers(ctx, agentName)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(fmt.Sprintf("list_k8s_handlers failed: %s", err)), nil
|
|
}
|
|
|
|
result := make([]map[string]any, len(handlers))
|
|
for i, h := range handlers {
|
|
result[i] = map[string]any{
|
|
"id": h.ID,
|
|
"image": h.Image,
|
|
"events": h.Events,
|
|
"namespace": h.Namespace,
|
|
"resources_memory": h.ResourcesMemory,
|
|
"resources_cpu": h.ResourcesCPU,
|
|
"timeout_seconds": h.TimeoutSeconds,
|
|
"status": h.Status,
|
|
"created_at": h.CreatedAt,
|
|
}
|
|
}
|
|
|
|
return resultJSON(map[string]any{
|
|
"handlers": result,
|
|
"count": len(result),
|
|
"k8s_available": r.k8sService.IsAvailable(),
|
|
})
|
|
}
|
|
|
|
func (r *WebhookToolRegistrar) handleDeleteK8sHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
agentName, ok := extractAgentName(ctx)
|
|
if !ok {
|
|
return mcp.NewToolResultError("authentication required"), nil
|
|
}
|
|
|
|
handlerID, err := req.RequireInt("handler_id")
|
|
if err != nil {
|
|
return mcp.NewToolResultError("'handler_id' parameter is required"), nil
|
|
}
|
|
|
|
if err := r.k8sService.DeleteHandler(ctx, agentName, int64(handlerID)); err != nil {
|
|
return mcp.NewToolResultError(fmt.Sprintf("delete_k8s_handler failed: %s", err)), nil
|
|
}
|
|
|
|
return resultJSON(map[string]any{
|
|
"deleted": true,
|
|
"handler_id": handlerID,
|
|
})
|
|
}
|
|
|
|
// parseEvents splits a comma-separated event string into a trimmed slice.
|
|
func parseEvents(s string) []string {
|
|
parts := strings.Split(s, ",")
|
|
events := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p != "" {
|
|
events = append(events, p)
|
|
}
|
|
}
|
|
return events
|
|
}
|