Skip to content

Commit c00dae0

Browse files
authored
feat(mcp): add get_span_details tool (#3255)
Returns the fully detailed span with attributes and AI enrichment data
1 parent 774007e commit c00dae0

File tree

10 files changed

+372
-4
lines changed

10 files changed

+372
-4
lines changed

.changeset/mcp-get-span-details.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@trigger.dev/core": patch
3+
"trigger.dev": patch
4+
---
5+
6+
Add `get_span_details` MCP tool for inspecting individual spans within a run trace.
7+
8+
- New `get_span_details` tool returns full span attributes, timing, events, and AI enrichment (model, tokens, cost, speed)
9+
- Span IDs now shown in `get_run_details` trace output for easy discovery
10+
- New API endpoint `GET /api/v1/runs/:runId/spans/:spanId`
11+
- New `retrieveSpan()` method on the API client
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
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.
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { json } from "@remix-run/server-runtime";
2+
import { BatchId } from "@trigger.dev/core/v3/isomorphic";
3+
import { z } from "zod";
4+
import { $replica } from "~/db.server";
5+
import { extractAISpanData } from "~/components/runs/v3/ai";
6+
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
7+
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
8+
import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server";
9+
10+
const ParamsSchema = z.object({
11+
runId: z.string(),
12+
spanId: z.string(),
13+
});
14+
15+
export const loader = createLoaderApiRoute(
16+
{
17+
params: ParamsSchema,
18+
allowJWT: true,
19+
corsStrategy: "all",
20+
findResource: (params, auth) => {
21+
return $replica.taskRun.findFirst({
22+
where: {
23+
friendlyId: params.runId,
24+
runtimeEnvironmentId: auth.environment.id,
25+
},
26+
});
27+
},
28+
shouldRetryNotFound: true,
29+
authorization: {
30+
action: "read",
31+
resource: (run) => ({
32+
runs: run.friendlyId,
33+
tags: run.runTags,
34+
batch: run.batchId ? BatchId.toFriendlyId(run.batchId) : undefined,
35+
tasks: run.taskIdentifier,
36+
}),
37+
superScopes: ["read:runs", "read:all", "admin"],
38+
},
39+
},
40+
async ({ params, resource: run, authentication }) => {
41+
const eventRepository = resolveEventRepositoryForStore(run.taskEventStore);
42+
const eventStore = getTaskEventStoreTableForRun(run);
43+
44+
const span = await eventRepository.getSpan(
45+
eventStore,
46+
authentication.environment.id,
47+
params.spanId,
48+
run.traceId,
49+
run.createdAt,
50+
run.completedAt ?? undefined
51+
);
52+
53+
if (!span) {
54+
return json({ error: "Span not found" }, { status: 404 });
55+
}
56+
57+
// Duration is nanoseconds from ClickHouse (Postgres store is deprecated)
58+
const durationMs = span.duration / 1_000_000;
59+
60+
const aiData =
61+
span.properties && typeof span.properties === "object"
62+
? extractAISpanData(span.properties as Record<string, unknown>, durationMs)
63+
: undefined;
64+
65+
const triggeredRuns = await $replica.taskRun.findMany({
66+
take: 50,
67+
select: {
68+
friendlyId: true,
69+
taskIdentifier: true,
70+
status: true,
71+
createdAt: true,
72+
},
73+
where: {
74+
runtimeEnvironmentId: authentication.environment.id,
75+
parentSpanId: params.spanId,
76+
},
77+
});
78+
79+
const properties =
80+
span.properties &&
81+
typeof span.properties === "object" &&
82+
Object.keys(span.properties as Record<string, unknown>).length > 0
83+
? (span.properties as Record<string, unknown>)
84+
: undefined;
85+
86+
return json(
87+
{
88+
spanId: span.spanId,
89+
parentId: span.parentId,
90+
runId: run.friendlyId,
91+
message: span.message,
92+
isError: span.isError,
93+
isPartial: span.isPartial,
94+
isCancelled: span.isCancelled,
95+
level: span.level,
96+
startTime: span.startTime,
97+
durationMs,
98+
properties,
99+
events: span.events?.length ? span.events : undefined,
100+
entityType: span.entity.type ?? undefined,
101+
ai: aiData
102+
? {
103+
model: aiData.model,
104+
provider: aiData.provider,
105+
operationName: aiData.operationName,
106+
inputTokens: aiData.inputTokens,
107+
outputTokens: aiData.outputTokens,
108+
totalTokens: aiData.totalTokens,
109+
cachedTokens: aiData.cachedTokens,
110+
reasoningTokens: aiData.reasoningTokens,
111+
inputCost: aiData.inputCost,
112+
outputCost: aiData.outputCost,
113+
totalCost: aiData.totalCost,
114+
tokensPerSecond: aiData.tokensPerSecond,
115+
msToFirstChunk: aiData.msToFirstChunk,
116+
durationMs: aiData.durationMs,
117+
finishReason: aiData.finishReason,
118+
responseText: aiData.responseText,
119+
}
120+
: undefined,
121+
triggeredRuns:
122+
triggeredRuns.length > 0
123+
? triggeredRuns.map((r) => ({
124+
runId: r.friendlyId,
125+
taskIdentifier: r.taskIdentifier,
126+
status: r.status,
127+
createdAt: r.createdAt,
128+
}))
129+
: undefined,
130+
},
131+
{ status: 200 }
132+
);
133+
}
134+
);

packages/cli-v3/src/mcp/config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ export const toolsMetadata = {
7070
description:
7171
"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_.",
7272
},
73+
get_span_details: {
74+
name: "get_span_details",
75+
title: "Get Span Details",
76+
description:
77+
"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.",
78+
},
7379
wait_for_run_to_complete: {
7480
name: "wait_for_run_to_complete",
7581
title: "Wait for Run to Complete",

packages/cli-v3/src/mcp/formatters.ts

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
ListRunResponseItem,
44
RetrieveRunResponse,
55
RetrieveRunTraceResponseBody,
6+
RetrieveSpanDetailResponseBody,
67
} from "@trigger.dev/core/v3/schemas";
78
import type { CursorPageResponse } from "@trigger.dev/core/v3/zodfetch";
89

@@ -235,10 +236,11 @@ function formatSpan(
235236

236237
// Format span header
237238
const statusIndicator = getStatusIndicator(span.data);
238-
const duration = formatDuration(span.data.duration);
239+
// Trace durations are nanoseconds from ClickHouse
240+
const duration = formatDuration(span.data.duration / 1_000_000);
239241
const startTime = formatDateTime(span.data.startTime);
240242

241-
lines.push(`${indent}${prefix} ${span.data.message} ${statusIndicator}`);
243+
lines.push(`${indent}${prefix} [${span.id}] ${span.data.message} ${statusIndicator}`);
242244
lines.push(`${indent} Duration: ${duration}`);
243245
lines.push(`${indent} Started: ${startTime}`);
244246

@@ -459,3 +461,94 @@ export function formatQueryResults(rows: Record<string, unknown>[]): string {
459461

460462
return [header, separator, ...body].join("\n");
461463
}
464+
465+
export function formatSpanDetail(span: RetrieveSpanDetailResponseBody): string {
466+
const lines: string[] = [];
467+
468+
const statusIndicator = span.isCancelled
469+
? "[CANCELLED]"
470+
: span.isError
471+
? "[ERROR]"
472+
: span.isPartial
473+
? "[IN PROGRESS]"
474+
: "[COMPLETED]";
475+
476+
lines.push(`## Span: ${span.message} ${statusIndicator}`);
477+
lines.push(`Span ID: ${span.spanId}`);
478+
if (span.parentId) lines.push(`Parent ID: ${span.parentId}`);
479+
lines.push(`Run ID: ${span.runId}`);
480+
lines.push(`Level: ${span.level}`);
481+
lines.push(`Started: ${formatDateTime(span.startTime)}`);
482+
lines.push(`Duration: ${formatDuration(span.durationMs)}`);
483+
if (span.entityType) lines.push(`Entity Type: ${span.entityType}`);
484+
485+
if (span.ai) {
486+
lines.push("");
487+
lines.push("### AI Details");
488+
lines.push(`Model: ${span.ai.model}`);
489+
lines.push(`Provider: ${span.ai.provider}`);
490+
lines.push(`Operation: ${span.ai.operationName}`);
491+
lines.push(
492+
`Tokens: ${span.ai.inputTokens} in / ${span.ai.outputTokens} out (${span.ai.totalTokens} total)`
493+
);
494+
if (span.ai.cachedTokens) {
495+
lines.push(`Cached tokens: ${span.ai.cachedTokens}`);
496+
}
497+
if (span.ai.reasoningTokens) {
498+
lines.push(`Reasoning tokens: ${span.ai.reasoningTokens}`);
499+
}
500+
if (span.ai.totalCost !== undefined) {
501+
lines.push(`Cost: $${span.ai.totalCost.toFixed(6)}`);
502+
if (span.ai.inputCost !== undefined && span.ai.outputCost !== undefined) {
503+
lines.push(
504+
` Input: $${span.ai.inputCost.toFixed(6)}, Output: $${span.ai.outputCost.toFixed(6)}`
505+
);
506+
}
507+
}
508+
if (span.ai.tokensPerSecond !== undefined) {
509+
lines.push(`Speed: ${span.ai.tokensPerSecond} tokens/sec`);
510+
}
511+
if (span.ai.msToFirstChunk !== undefined) {
512+
lines.push(`Time to first chunk: ${span.ai.msToFirstChunk.toFixed(0)}ms`);
513+
}
514+
if (span.ai.finishReason) {
515+
lines.push(`Finish reason: ${span.ai.finishReason}`);
516+
}
517+
if (span.ai.responseText) {
518+
lines.push("");
519+
lines.push("### AI Response");
520+
lines.push(span.ai.responseText);
521+
}
522+
}
523+
524+
if (span.properties && Object.keys(span.properties).length > 0) {
525+
lines.push("");
526+
lines.push("### Properties");
527+
lines.push(JSON.stringify(span.properties, null, 2));
528+
}
529+
530+
if (span.events && span.events.length > 0) {
531+
lines.push("");
532+
lines.push(`### Events (${span.events.length})`);
533+
const maxEvents = 10;
534+
for (let i = 0; i < Math.min(span.events.length, maxEvents); i++) {
535+
const event = span.events[i];
536+
if (typeof event === "object" && event !== null) {
537+
lines.push(JSON.stringify(event, null, 2));
538+
}
539+
}
540+
if (span.events.length > maxEvents) {
541+
lines.push(`... and ${span.events.length - maxEvents} more events`);
542+
}
543+
}
544+
545+
if (span.triggeredRuns && span.triggeredRuns.length > 0) {
546+
lines.push("");
547+
lines.push("### Triggered Runs");
548+
for (const run of span.triggeredRuns) {
549+
lines.push(`- ${run.runId} (${run.taskIdentifier}) - ${run.status.toLowerCase()}`);
550+
}
551+
}
552+
553+
return lines.join("\n");
554+
}

packages/cli-v3/src/mcp/schemas.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,16 @@ export const GetRunDetailsInput = CommonRunsInput.extend({
152152

153153
export type GetRunDetailsInput = z.output<typeof GetRunDetailsInput>;
154154

155+
export const GetSpanDetailsInput = CommonRunsInput.extend({
156+
spanId: z
157+
.string()
158+
.describe(
159+
"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."
160+
),
161+
});
162+
163+
export type GetSpanDetailsInput = z.output<typeof GetSpanDetailsInput>;
164+
155165
export const ListRunsInput = CommonProjectsInput.extend({
156166
cursor: z.string().describe("The cursor to use for pagination, starts with run_").optional(),
157167
limit: z

packages/cli-v3/src/mcp/tools.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { getQuerySchemaTool, queryTool } from "./tools/query.js";
1515
import {
1616
cancelRunTool,
1717
getRunDetailsTool,
18+
getSpanDetailsTool,
1819
listRunsTool,
1920
waitForRunToCompleteTool,
2021
} from "./tools/runs.js";
@@ -56,6 +57,7 @@ export function registerTools(context: McpContext) {
5657
triggerTaskTool,
5758
listRunsTool,
5859
getRunDetailsTool,
60+
getSpanDetailsTool,
5961
waitForRunToCompleteTool,
6062
cancelRunTool,
6163
deployTool,

packages/cli-v3/src/mcp/tools/runs.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import fs from "node:fs";
33
import os from "node:os";
44
import path from "node:path";
55
import { toolsMetadata } from "../config.js";
6-
import { formatRun, formatRunList, formatRunShape, formatRunTrace } from "../formatters.js";
7-
import { CommonRunsInput, GetRunDetailsInput, ListRunsInput, WaitForRunInput } from "../schemas.js";
6+
import { formatRun, formatRunList, formatRunShape, formatRunTrace, formatSpanDetail } from "../formatters.js";
7+
import { CommonRunsInput, GetRunDetailsInput, GetSpanDetailsInput, ListRunsInput, WaitForRunInput } from "../schemas.js";
88
import { respondWithError, toolHandler } from "../utils.js";
99

1010
// Cache formatted traces in temp files keyed by runId.
@@ -170,6 +170,51 @@ export const getRunDetailsTool = {
170170
}),
171171
};
172172

173+
export const getSpanDetailsTool = {
174+
name: toolsMetadata.get_span_details.name,
175+
title: toolsMetadata.get_span_details.title,
176+
description: toolsMetadata.get_span_details.description,
177+
inputSchema: GetSpanDetailsInput.shape,
178+
handler: toolHandler(GetSpanDetailsInput.shape, async (input, { ctx }) => {
179+
ctx.logger?.log("calling get_span_details", { input });
180+
181+
if (ctx.options.devOnly && input.environment !== "dev") {
182+
return respondWithError(
183+
`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.`
184+
);
185+
}
186+
187+
const projectRef = await ctx.getProjectRef({
188+
projectRef: input.projectRef,
189+
cwd: input.configPath,
190+
});
191+
192+
const apiClient = await ctx.getApiClient({
193+
projectRef,
194+
environment: input.environment,
195+
scopes: [`read:runs:${input.runId}`],
196+
branch: input.branch,
197+
});
198+
199+
const spanDetail = await apiClient.retrieveSpan(input.runId, input.spanId);
200+
const formatted = formatSpanDetail(spanDetail);
201+
202+
const runUrl = await ctx.getDashboardUrl(
203+
`/projects/v3/${projectRef}/runs/${input.runId}`
204+
);
205+
206+
const content = [formatted];
207+
if (runUrl) {
208+
content.push("");
209+
content.push(`[View run in dashboard](${runUrl})`);
210+
}
211+
212+
return {
213+
content: [{ type: "text", text: content.join("\n") }],
214+
};
215+
}),
216+
};
217+
173218
export const waitForRunToCompleteTool = {
174219
name: toolsMetadata.wait_for_run_to_complete.name,
175220
title: toolsMetadata.wait_for_run_to_complete.title,

0 commit comments

Comments
 (0)