agent/php/Mcp/Services/McpWebhookDispatcher.php
Snider 91551dec9b feat(mcp): implement extended RFC services + transport (#842)
Additive-only — no existing files modified.

Services (php/Mcp/Services/):
- CircuitBreaker (3-state, Cache::add trial lock)
- DataRedactor (28 sensitive + 16 PII keys, partial-redact algorithm)
- McpHealthService (YAML registry + JSON-RPC stdio ping protocolVersion 2024-11-05)
- McpMetricsService (p50/p95/p99 linear interpolation)
- McpWebhookDispatcher (mcp.tool.executed → WebhookEndpoints)
- OpenApiGenerator (OpenAPI 3.0.3)
- ToolRateLimiter (Cache::put first, Cache::increment after — no reset)
- AgentSessionService (php/Mod/Mcp/Services/ namespace per spec)

Transport (php/Mcp/Transport/):
- McpContext (transport-agnostic callbacks)
- Contracts/McpToolHandler interface

Resources (php/Mcp/Resources/):
- AppConfig, ContentResource, DatabaseSchema

Config: php/resources/mcp/registry.yaml.
Pest Feature tests _Good/_Bad/_Ugly per AX-10 for each new class.

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=842
2026-04-25 05:50:16 +01:00

176 lines
5.4 KiB
PHP

<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mcp\Services;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use ReflectionMethod;
final class McpWebhookDispatcher
{
public function dispatchToolExecuted(
int $workspaceId,
string $serverId,
string $toolName,
array $arguments,
bool $success,
int $durationMs,
?string $errorMessage = null
): void {
$endpointClass = $this->endpointModelClass();
if ($endpointClass === null) {
return;
}
$eventType = 'mcp.tool.executed';
$payload = [
'event' => $eventType,
'timestamp' => now()->toIso8601String(),
'data' => [
'server_id' => $serverId,
'tool_name' => $toolName,
'arguments' => $arguments,
'success' => $success,
'duration_ms' => $durationMs,
'error' => $errorMessage,
],
];
$query = $endpointClass::query();
$model = $query->getModel();
if (method_exists($model, 'scopeForWorkspace')) {
$query->forWorkspace($workspaceId);
} else {
$query->where('workspace_id', $workspaceId);
}
if (method_exists($model, 'scopeActive')) {
$query->active();
} else {
$query->where('active', true);
}
if (method_exists($model, 'scopeForEvent')) {
$query->forEvent($eventType);
} else {
$query->where(function ($inner) use ($eventType): void {
$inner->whereJsonContains('events', $eventType)
->orWhereJsonContains('events', '*');
});
}
foreach ($query->get() as $endpoint) {
$this->deliverWebhook($endpoint, $payload);
}
}
protected function deliverWebhook(object $endpoint, array $payload): void
{
$timestamp = (string) ($payload['timestamp'] ?? now()->toIso8601String());
$payloadJson = json_encode($payload, JSON_UNESCAPED_SLASHES);
$signature = $this->generateSignature($endpoint, $payloadJson, $timestamp);
try {
$response = $this->sendWebhook($endpoint, $payloadJson, [
'Content-Type' => 'application/json',
'X-Webhook-Signature' => $signature,
'X-Webhook-Event' => (string) $payload['event'],
'X-Webhook-Timestamp' => $timestamp,
]);
$this->recordDelivery($endpoint, $payload, $response->status(), $response->body(), $response->successful());
if ($response->successful() && method_exists($endpoint, 'recordSuccess')) {
$endpoint->recordSuccess();
}
if (! $response->successful() && method_exists($endpoint, 'recordFailure')) {
$endpoint->recordFailure();
}
} catch (\Throwable $throwable) {
$this->recordDelivery($endpoint, $payload, 0, $throwable->getMessage(), false);
if (method_exists($endpoint, 'recordFailure')) {
$endpoint->recordFailure();
}
}
}
protected function sendWebhook(object $endpoint, string $payloadJson, array $headers): Response
{
return Http::timeout(10)
->withHeaders($headers)
->withBody($payloadJson, 'application/json')
->post((string) $endpoint->url);
}
protected function recordDelivery(object $endpoint, array $payload, int $responseCode, string $responseBody, bool $successful): void
{
$deliveryClass = $this->deliveryModelClass();
if ($deliveryClass === null) {
return;
}
$deliveryClass::create([
'webhook_endpoint_id' => $endpoint->id,
'event_id' => 'evt_'.Str::random(24),
'event_type' => (string) $payload['event'],
'payload' => $payload,
'response_code' => $responseCode,
'response_body' => mb_substr($responseBody, 0, 1000),
'status' => $successful ? 'success' : 'failed',
'attempt' => 1,
'delivered_at' => $successful ? now() : null,
]);
}
protected function generateSignature(object $endpoint, string $payloadJson, string $timestamp): string
{
if (! method_exists($endpoint, 'generateSignature')) {
return '';
}
$method = new ReflectionMethod($endpoint, 'generateSignature');
return match (true) {
$method->getNumberOfRequiredParameters() <= 1 => (string) $endpoint->generateSignature($payloadJson),
default => (string) $endpoint->generateSignature($payloadJson, strtotime($timestamp) ?: time()),
};
}
protected function endpointModelClass(): ?string
{
foreach ([
'Core\\Api\\Models\\WebhookEndpoint',
'Core\\Mod\\Api\\Models\\WebhookEndpoint',
] as $class) {
if (class_exists($class)) {
return $class;
}
}
return null;
}
protected function deliveryModelClass(): ?string
{
foreach ([
'Core\\Api\\Models\\WebhookDelivery',
'Core\\Mod\\Api\\Models\\WebhookDelivery',
] as $class) {
if (class_exists($class)) {
return $class;
}
}
return null;
}
}