-
-
Save arminulrich/e240d1ede6ae25697778ec47c752140d to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| namespace App\Http\Middleware; | |
| use Closure; | |
| use Illuminate\Http\Request; | |
| use Sentry\SentrySdk; | |
| use Sentry\Tracing\SpanContext; | |
| use Sentry\Tracing\SpanStatus; | |
| use Symfony\Component\HttpFoundation\Response; | |
| use Symfony\Component\HttpFoundation\StreamedResponse; | |
| /** | |
| * Traces MCP JSON-RPC requests as Sentry spans. | |
| * | |
| * Sentry ships automatic MCP server monitoring for JavaScript and Python, | |
| * but not for PHP. This middleware implements the same span conventions | |
| * manually using the Sentry PHP SDK so our Laravel-based MCP server | |
| * appears in Sentry's MCP Insights dashboard alongside first-class SDKs. | |
| * | |
| * Span conventions followed (from Sentry's MCP monitoring spec): | |
| * | |
| * op: "mcp.server" | |
| * description: "{method} {identifier}" — e.g. "tools/call create_image" | |
| * | |
| * Span data attributes emitted: | |
| * | |
| * mcp.method.name — the JSON-RPC method (always) | |
| * mcp.request.id — the JSON-RPC request id (when present) | |
| * mcp.transport — transport type, always "http" for this server | |
| * mcp.tool.name — tool identifier (tools/call only) | |
| * mcp.resource.uri — resource URI (resources/read only) | |
| * mcp.prompt.name — prompt identifier (prompts/get only) | |
| * mcp.tool.result.is_error — whether a tool call errored (tools/call only) | |
| * mcp.tool.result.content_count — number of content items returned (tools/call only) | |
| * mcp.error.code — JSON-RPC error code (on error responses) | |
| * mcp.error.message — JSON-RPC error message (on error responses) | |
| * agent.uuid — Placid agent identifier (from route parameter) | |
| * | |
| * Status mapping: | |
| * | |
| * Success → SpanStatus::ok() | |
| * JSON-RPC error → SpanStatus::unknownError() | |
| * Exception thrown → SpanStatus::internalError() | |
| * Streamed response → SpanStatus::ok() (content not inspectable) | |
| * | |
| * Not yet implemented (spec-optional): | |
| * - mcp.session.id (stateless HTTP transport, no persistent sessions) | |
| * - Input/output recording (mcp.request.argument.*, mcp.tool.result.content) | |
| * | |
| * @see https://docs.sentry.io/platforms/javascript/guides/node/tracing/instrumentation/mcp-module/ | |
| * @see https://blog.sentry.io/introducing-mcp-server-monitoring/ | |
| */ | |
| final class SentryMcpTracing | |
| { | |
| /** | |
| * MCP methods that carry an identifier in their params. | |
| * | |
| * Each entry maps a JSON-RPC method to: | |
| * 'param' — the key in the request params holding the identifier | |
| * 'attr' — the Sentry span attribute name to emit | |
| * | |
| * Adding a new identifiable method = one line here. | |
| */ | |
| private const IDENTIFIERS = [ | |
| 'tools/call' => ['param' => 'name', 'attr' => 'mcp.tool.name'], | |
| 'resources/read' => ['param' => 'uri', 'attr' => 'mcp.resource.uri'], | |
| 'prompts/get' => ['param' => 'name', 'attr' => 'mcp.prompt.name'], | |
| ]; | |
| /** Wrap the MCP request in a Sentry child span. Skips when tracing is inactive or request isn't JSON. */ | |
| public function handle(Request $request, Closure $next): Response | |
| { | |
| $parentSpan = SentrySdk::getCurrentHub()->getSpan(); | |
| if (! $parentSpan || ! $request->isJson()) { | |
| return $next($request); | |
| } | |
| $body = $request->json()->all(); | |
| $method = $body['method'] ?? 'unknown'; | |
| $params = $body['params'] ?? []; | |
| $span = $parentSpan->startChild( | |
| SpanContext::make() | |
| ->setOp('mcp.server') | |
| ->setDescription($this->describe($method, $params)) | |
| ->setData($this->spanData($request, $body, $method, $params)) | |
| ); | |
| try { | |
| $response = $next($request); | |
| $responseJson = $this->parseResponse($response); | |
| $span->setData($this->enrichFromResponse($responseJson, $method)); | |
| $span->setStatus($this->resolveStatus($responseJson)); | |
| return $response; | |
| } catch (\Throwable $e) { | |
| $span->setStatus(SpanStatus::internalError()); | |
| throw $e; | |
| } finally { | |
| $span->finish(); | |
| } | |
| } | |
| /** Build span description per Sentry's "{method} {identifier}" convention. */ | |
| private function describe(string $method, array $params): string | |
| { | |
| $id = self::IDENTIFIERS[$method] ?? null; | |
| return $id | |
| ? "$method " . ($params[$id['param']] ?? 'unknown') | |
| : $method; | |
| } | |
| /** Assemble request-side span data. Null values are filtered so attributes only appear on relevant methods. */ | |
| private function spanData(Request $request, array $body, string $method, array $params): array | |
| { | |
| $data = [ | |
| 'agent.uuid' => $request->route('agentUuid'), | |
| 'mcp.method.name' => $method, | |
| 'mcp.request.id' => $body['id'] ?? null, | |
| 'mcp.transport' => 'http', | |
| ]; | |
| if ($id = self::IDENTIFIERS[$method] ?? null) { | |
| $data[$id['attr']] = $params[$id['param']] ?? null; | |
| } | |
| return array_filter($data); | |
| } | |
| /** Decode response JSON once for both status resolution and span enrichment. Returns [] for streamed responses. */ | |
| private function parseResponse(Response $response): array | |
| { | |
| if ($response instanceof StreamedResponse) { | |
| return []; | |
| } | |
| return json_decode($response->getContent(), true) ?? []; | |
| } | |
| /** Enrich span with response-side data: JSON-RPC error details and tool result metadata. */ | |
| private function enrichFromResponse(array $json, string $method): array | |
| { | |
| $data = []; | |
| if (isset($json['error'])) { | |
| $data['mcp.error.code'] = $json['error']['code'] ?? null; | |
| $data['mcp.error.message'] = $json['error']['message'] ?? null; | |
| } | |
| if ($method === 'tools/call' && isset($json['result'])) { | |
| $result = $json['result']; | |
| $data['mcp.tool.result.is_error'] = $result['isError'] ?? false; | |
| if (isset($result['content'])) { | |
| $data['mcp.tool.result.content_count'] = is_array($result['content']) | |
| ? count($result['content']) | |
| : 1; | |
| } | |
| } | |
| return array_filter($data, fn ($v) => $v !== null); | |
| } | |
| /** Map JSON-RPC response to span status. Error envelope → unknownError, everything else → ok. */ | |
| private function resolveStatus(array $json): SpanStatus | |
| { | |
| return isset($json['error']) ? SpanStatus::unknownError() : SpanStatus::ok(); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment