feat(mcp): add get_span_details tool (#3255)

Returns the fully detailed span with attributes and AI enrichment data
This commit is contained in:
Eric Allam
2026-03-24 13:18:49 +00:00
committed by GitHub
parent 774007e9b9
commit c00dae006b
10 changed files with 372 additions and 4 deletions
+11
View File
@@ -0,0 +1,11 @@
---
"@trigger.dev/core": patch
"trigger.dev": patch
---
Add `get_span_details` MCP tool for inspecting individual spans within a run trace.
- New `get_span_details` tool returns full span attributes, timing, events, and AI enrichment (model, tokens, cost, speed)
- Span IDs now shown in `get_run_details` trace output for easy discovery
- New API endpoint `GET /api/v1/runs/:runId/spans/:spanId`
- New `retrieveSpan()` method on the API client
+6
View File
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---
Add API endpoint `GET /api/v1/runs/:runId/spans/:spanId` that returns detailed span information including properties, events, AI enrichment (model, tokens, cost), and triggered child runs.
@@ -0,0 +1,134 @@
import { json } from "@remix-run/server-runtime";
import { BatchId } from "@trigger.dev/core/v3/isomorphic";
import { z } from "zod";
import { $replica } from "~/db.server";
import { extractAISpanData } from "~/components/runs/v3/ai";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server";
const ParamsSchema = z.object({
runId: z.string(),
spanId: z.string(),
});
export const loader = createLoaderApiRoute(
{
params: ParamsSchema,
allowJWT: true,
corsStrategy: "all",
findResource: (params, auth) => {
return $replica.taskRun.findFirst({
where: {
friendlyId: params.runId,
runtimeEnvironmentId: auth.environment.id,
},
});
},
shouldRetryNotFound: true,
authorization: {
action: "read",
resource: (run) => ({
runs: run.friendlyId,
tags: run.runTags,
batch: run.batchId ? BatchId.toFriendlyId(run.batchId) : undefined,
tasks: run.taskIdentifier,
}),
superScopes: ["read:runs", "read:all", "admin"],
},
},
async ({ params, resource: run, authentication }) => {
const eventRepository = resolveEventRepositoryForStore(run.taskEventStore);
const eventStore = getTaskEventStoreTableForRun(run);
const span = await eventRepository.getSpan(
eventStore,
authentication.environment.id,
params.spanId,
run.traceId,
run.createdAt,
run.completedAt ?? undefined
);
if (!span) {
return json({ error: "Span not found" }, { status: 404 });
}
// Duration is nanoseconds from ClickHouse (Postgres store is deprecated)
const durationMs = span.duration / 1_000_000;
const aiData =
span.properties && typeof span.properties === "object"
? extractAISpanData(span.properties as Record<string, unknown>, durationMs)
: undefined;
const triggeredRuns = await $replica.taskRun.findMany({
take: 50,
select: {
friendlyId: true,
taskIdentifier: true,
status: true,
createdAt: true,
},
where: {
runtimeEnvironmentId: authentication.environment.id,
parentSpanId: params.spanId,
},
});
const properties =
span.properties &&
typeof span.properties === "object" &&
Object.keys(span.properties as Record<string, unknown>).length > 0
? (span.properties as Record<string, unknown>)
: undefined;
return json(
{
spanId: span.spanId,
parentId: span.parentId,
runId: run.friendlyId,
message: span.message,
isError: span.isError,
isPartial: span.isPartial,
isCancelled: span.isCancelled,
level: span.level,
startTime: span.startTime,
durationMs,
properties,
events: span.events?.length ? span.events : undefined,
entityType: span.entity.type ?? undefined,
ai: aiData
? {
model: aiData.model,
provider: aiData.provider,
operationName: aiData.operationName,
inputTokens: aiData.inputTokens,
outputTokens: aiData.outputTokens,
totalTokens: aiData.totalTokens,
cachedTokens: aiData.cachedTokens,
reasoningTokens: aiData.reasoningTokens,
inputCost: aiData.inputCost,
outputCost: aiData.outputCost,
totalCost: aiData.totalCost,
tokensPerSecond: aiData.tokensPerSecond,
msToFirstChunk: aiData.msToFirstChunk,
durationMs: aiData.durationMs,
finishReason: aiData.finishReason,
responseText: aiData.responseText,
}
: undefined,
triggeredRuns:
triggeredRuns.length > 0
? triggeredRuns.map((r) => ({
runId: r.friendlyId,
taskIdentifier: r.taskIdentifier,
status: r.status,
createdAt: r.createdAt,
}))
: undefined,
},
{ status: 200 }
);
}
);
+6
View File
@@ -70,6 +70,12 @@ export const toolsMetadata = {
description:
"Get the details and trace of a run. Trace events are paginated — the first call returns run details and the first page of trace lines. Pass the returned cursor to fetch subsequent pages without re-fetching the trace. The run ID starts with run_.",
},
get_span_details: {
name: "get_span_details",
title: "Get Span Details",
description:
"Get detailed information about a specific span within a run trace. Use get_run_details first to see the trace and find span IDs (shown as [spanId] in the trace output). Returns timing, properties/attributes, error info, and for AI spans: model, tokens, cost, and response data.",
},
wait_for_run_to_complete: {
name: "wait_for_run_to_complete",
title: "Wait for Run to Complete",
+95 -2
View File
@@ -3,6 +3,7 @@ import {
ListRunResponseItem,
RetrieveRunResponse,
RetrieveRunTraceResponseBody,
RetrieveSpanDetailResponseBody,
} from "@trigger.dev/core/v3/schemas";
import type { CursorPageResponse } from "@trigger.dev/core/v3/zodfetch";
@@ -235,10 +236,11 @@ function formatSpan(
// Format span header
const statusIndicator = getStatusIndicator(span.data);
const duration = formatDuration(span.data.duration);
// Trace durations are nanoseconds from ClickHouse
const duration = formatDuration(span.data.duration / 1_000_000);
const startTime = formatDateTime(span.data.startTime);
lines.push(`${indent}${prefix} ${span.data.message} ${statusIndicator}`);
lines.push(`${indent}${prefix} [${span.id}] ${span.data.message} ${statusIndicator}`);
lines.push(`${indent} Duration: ${duration}`);
lines.push(`${indent} Started: ${startTime}`);
@@ -459,3 +461,94 @@ export function formatQueryResults(rows: Record<string, unknown>[]): string {
return [header, separator, ...body].join("\n");
}
export function formatSpanDetail(span: RetrieveSpanDetailResponseBody): string {
const lines: string[] = [];
const statusIndicator = span.isCancelled
? "[CANCELLED]"
: span.isError
? "[ERROR]"
: span.isPartial
? "[IN PROGRESS]"
: "[COMPLETED]";
lines.push(`## Span: ${span.message} ${statusIndicator}`);
lines.push(`Span ID: ${span.spanId}`);
if (span.parentId) lines.push(`Parent ID: ${span.parentId}`);
lines.push(`Run ID: ${span.runId}`);
lines.push(`Level: ${span.level}`);
lines.push(`Started: ${formatDateTime(span.startTime)}`);
lines.push(`Duration: ${formatDuration(span.durationMs)}`);
if (span.entityType) lines.push(`Entity Type: ${span.entityType}`);
if (span.ai) {
lines.push("");
lines.push("### AI Details");
lines.push(`Model: ${span.ai.model}`);
lines.push(`Provider: ${span.ai.provider}`);
lines.push(`Operation: ${span.ai.operationName}`);
lines.push(
`Tokens: ${span.ai.inputTokens} in / ${span.ai.outputTokens} out (${span.ai.totalTokens} total)`
);
if (span.ai.cachedTokens) {
lines.push(`Cached tokens: ${span.ai.cachedTokens}`);
}
if (span.ai.reasoningTokens) {
lines.push(`Reasoning tokens: ${span.ai.reasoningTokens}`);
}
if (span.ai.totalCost !== undefined) {
lines.push(`Cost: $${span.ai.totalCost.toFixed(6)}`);
if (span.ai.inputCost !== undefined && span.ai.outputCost !== undefined) {
lines.push(
` Input: $${span.ai.inputCost.toFixed(6)}, Output: $${span.ai.outputCost.toFixed(6)}`
);
}
}
if (span.ai.tokensPerSecond !== undefined) {
lines.push(`Speed: ${span.ai.tokensPerSecond} tokens/sec`);
}
if (span.ai.msToFirstChunk !== undefined) {
lines.push(`Time to first chunk: ${span.ai.msToFirstChunk.toFixed(0)}ms`);
}
if (span.ai.finishReason) {
lines.push(`Finish reason: ${span.ai.finishReason}`);
}
if (span.ai.responseText) {
lines.push("");
lines.push("### AI Response");
lines.push(span.ai.responseText);
}
}
if (span.properties && Object.keys(span.properties).length > 0) {
lines.push("");
lines.push("### Properties");
lines.push(JSON.stringify(span.properties, null, 2));
}
if (span.events && span.events.length > 0) {
lines.push("");
lines.push(`### Events (${span.events.length})`);
const maxEvents = 10;
for (let i = 0; i < Math.min(span.events.length, maxEvents); i++) {
const event = span.events[i];
if (typeof event === "object" && event !== null) {
lines.push(JSON.stringify(event, null, 2));
}
}
if (span.events.length > maxEvents) {
lines.push(`... and ${span.events.length - maxEvents} more events`);
}
}
if (span.triggeredRuns && span.triggeredRuns.length > 0) {
lines.push("");
lines.push("### Triggered Runs");
for (const run of span.triggeredRuns) {
lines.push(`- ${run.runId} (${run.taskIdentifier}) - ${run.status.toLowerCase()}`);
}
}
return lines.join("\n");
}
+10
View File
@@ -152,6 +152,16 @@ export const GetRunDetailsInput = CommonRunsInput.extend({
export type GetRunDetailsInput = z.output<typeof GetRunDetailsInput>;
export const GetSpanDetailsInput = CommonRunsInput.extend({
spanId: z
.string()
.describe(
"The span ID to get details for. You can find span IDs in the trace output from get_run_details — they appear as [spanId] before each span message."
),
});
export type GetSpanDetailsInput = z.output<typeof GetSpanDetailsInput>;
export const ListRunsInput = CommonProjectsInput.extend({
cursor: z.string().describe("The cursor to use for pagination, starts with run_").optional(),
limit: z
+2
View File
@@ -15,6 +15,7 @@ import { getQuerySchemaTool, queryTool } from "./tools/query.js";
import {
cancelRunTool,
getRunDetailsTool,
getSpanDetailsTool,
listRunsTool,
waitForRunToCompleteTool,
} from "./tools/runs.js";
@@ -56,6 +57,7 @@ export function registerTools(context: McpContext) {
triggerTaskTool,
listRunsTool,
getRunDetailsTool,
getSpanDetailsTool,
waitForRunToCompleteTool,
cancelRunTool,
deployTool,
+47 -2
View File
@@ -3,8 +3,8 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { toolsMetadata } from "../config.js";
import { formatRun, formatRunList, formatRunShape, formatRunTrace } from "../formatters.js";
import { CommonRunsInput, GetRunDetailsInput, ListRunsInput, WaitForRunInput } from "../schemas.js";
import { formatRun, formatRunList, formatRunShape, formatRunTrace, formatSpanDetail } from "../formatters.js";
import { CommonRunsInput, GetRunDetailsInput, GetSpanDetailsInput, ListRunsInput, WaitForRunInput } from "../schemas.js";
import { respondWithError, toolHandler } from "../utils.js";
// Cache formatted traces in temp files keyed by runId.
@@ -170,6 +170,51 @@ export const getRunDetailsTool = {
}),
};
export const getSpanDetailsTool = {
name: toolsMetadata.get_span_details.name,
title: toolsMetadata.get_span_details.title,
description: toolsMetadata.get_span_details.description,
inputSchema: GetSpanDetailsInput.shape,
handler: toolHandler(GetSpanDetailsInput.shape, async (input, { ctx }) => {
ctx.logger?.log("calling get_span_details", { input });
if (ctx.options.devOnly && input.environment !== "dev") {
return respondWithError(
`This MCP server is only available for the dev environment. You tried to access the ${input.environment} environment. Remove the --dev-only flag to access other environments.`
);
}
const projectRef = await ctx.getProjectRef({
projectRef: input.projectRef,
cwd: input.configPath,
});
const apiClient = await ctx.getApiClient({
projectRef,
environment: input.environment,
scopes: [`read:runs:${input.runId}`],
branch: input.branch,
});
const spanDetail = await apiClient.retrieveSpan(input.runId, input.spanId);
const formatted = formatSpanDetail(spanDetail);
const runUrl = await ctx.getDashboardUrl(
`/projects/v3/${projectRef}/runs/${input.runId}`
);
const content = [formatted];
if (runUrl) {
content.push("");
content.push(`[View run in dashboard](${runUrl})`);
}
return {
content: [{ type: "text", text: content.join("\n") }],
};
}),
};
export const waitForRunToCompleteTool = {
name: toolsMetadata.wait_for_run_to_complete.name,
title: toolsMetadata.wait_for_run_to_complete.title,
+13
View File
@@ -54,6 +54,7 @@ import {
PromptOverrideCreatedResponseBody,
RetrieveRunResponse,
RetrieveRunTraceResponseBody,
RetrieveSpanDetailResponseBody,
ScheduleObject,
SendInputStreamResponseBody,
StreamBatchItemsResponse,
@@ -605,6 +606,18 @@ export class ApiClient {
);
}
retrieveSpan(runId: string, spanId: string, requestOptions?: ZodFetchOptions) {
return zodfetch(
RetrieveSpanDetailResponseBody,
`${this.baseUrl}/api/v1/runs/${runId}/spans/${spanId}`,
{
method: "GET",
headers: this.#getHeaders(false),
},
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
);
}
listRuns(
query?: ListRunsQueryParams,
requestOptions?: ZodFetchOptions
+48
View File
@@ -1643,6 +1643,54 @@ export const RetrieveRunTraceResponseBody = z.object({
export type RetrieveRunTraceResponseBody = z.infer<typeof RetrieveRunTraceResponseBody>;
export const RetrieveSpanDetailResponseBody = z.object({
spanId: z.string(),
parentId: z.string().nullable(),
runId: z.string(),
message: z.string(),
isError: z.boolean(),
isPartial: z.boolean(),
isCancelled: z.boolean(),
level: z.string(),
startTime: z.coerce.date(),
durationMs: z.number(),
properties: z.record(z.any()).optional(),
events: z.array(z.any()).optional(),
entityType: z.string().optional(),
ai: z
.object({
model: z.string(),
provider: z.string(),
operationName: z.string(),
inputTokens: z.number(),
outputTokens: z.number(),
totalTokens: z.number(),
cachedTokens: z.number().optional(),
reasoningTokens: z.number().optional(),
inputCost: z.number().optional(),
outputCost: z.number().optional(),
totalCost: z.number().optional(),
tokensPerSecond: z.number().optional(),
msToFirstChunk: z.number().optional(),
durationMs: z.number(),
finishReason: z.string().optional(),
responseText: z.string().optional(),
})
.optional(),
triggeredRuns: z
.array(
z.object({
runId: z.string(),
taskIdentifier: z.string(),
status: z.string(),
createdAt: z.coerce.date(),
})
)
.optional(),
});
export type RetrieveSpanDetailResponseBody = z.infer<typeof RetrieveSpanDetailResponseBody>;
export const CreateStreamResponseBody = z.object({
version: z.string(),
});