Skip to content

Instantly share code, notes, and snippets.

@arminulrich
Created April 1, 2026 10:18
Show Gist options
  • Select an option

  • Save arminulrich/e240d1ede6ae25697778ec47c752140d to your computer and use it in GitHub Desktop.

Select an option

Save arminulrich/e240d1ede6ae25697778ec47c752140d to your computer and use it in GitHub Desktop.
<?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