Skip to content

Commit f9c3573

Browse files
committed
feat(mcp): add get_span_details tool
Returns the fully detailed span with attributes and AI enrichment data
1 parent 54d95ee commit f9c3573

File tree

8 files changed

+352
-3
lines changed

8 files changed

+352
-3
lines changed
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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+
const durationMs = span.duration / 1_000_000;
58+
59+
const aiData =
60+
span.properties && typeof span.properties === "object"
61+
? extractAISpanData(span.properties as Record<string, unknown>, durationMs)
62+
: undefined;
63+
64+
const triggeredRuns = await $replica.taskRun.findMany({
65+
select: {
66+
friendlyId: true,
67+
taskIdentifier: true,
68+
status: true,
69+
createdAt: true,
70+
},
71+
where: {
72+
parentSpanId: params.spanId,
73+
},
74+
});
75+
76+
const properties =
77+
span.properties &&
78+
typeof span.properties === "object" &&
79+
Object.keys(span.properties as Record<string, unknown>).length > 0
80+
? (span.properties as Record<string, unknown>)
81+
: undefined;
82+
83+
return json(
84+
{
85+
spanId: span.spanId,
86+
parentId: span.parentId,
87+
runId: run.friendlyId,
88+
message: span.message,
89+
isError: span.isError,
90+
isPartial: span.isPartial,
91+
isCancelled: span.isCancelled,
92+
level: span.level,
93+
startTime: span.startTime,
94+
duration: span.duration,
95+
durationMs,
96+
properties,
97+
events: span.events?.length ? span.events : undefined,
98+
entityType: span.entity.type ?? undefined,
99+
ai: aiData
100+
? {
101+
model: aiData.model,
102+
provider: aiData.provider,
103+
operationName: aiData.operationName,
104+
inputTokens: aiData.inputTokens,
105+
outputTokens: aiData.outputTokens,
106+
totalTokens: aiData.totalTokens,
107+
cachedTokens: aiData.cachedTokens,
108+
reasoningTokens: aiData.reasoningTokens,
109+
inputCost: aiData.inputCost,
110+
outputCost: aiData.outputCost,
111+
totalCost: aiData.totalCost,
112+
tokensPerSecond: aiData.tokensPerSecond,
113+
msToFirstChunk: aiData.msToFirstChunk,
114+
durationMs: aiData.durationMs,
115+
finishReason: aiData.finishReason,
116+
responseText: aiData.responseText,
117+
}
118+
: undefined,
119+
triggeredRuns:
120+
triggeredRuns.length > 0
121+
? triggeredRuns.map((r) => ({
122+
runId: r.friendlyId,
123+
taskIdentifier: r.taskIdentifier,
124+
status: r.status,
125+
createdAt: r.createdAt,
126+
}))
127+
: undefined,
128+
},
129+
{ status: 200 }
130+
);
131+
}
132+
);

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: 93 additions & 1 deletion
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

@@ -238,7 +239,7 @@ function formatSpan(
238239
const duration = formatDuration(span.data.duration);
239240
const startTime = formatDateTime(span.data.startTime);
240241

241-
lines.push(`${indent}${prefix} ${span.data.message} ${statusIndicator}`);
242+
lines.push(`${indent}${prefix} [${span.id}] ${span.data.message} ${statusIndicator}`);
242243
lines.push(`${indent} Duration: ${duration}`);
243244
lines.push(`${indent} Started: ${startTime}`);
244245

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

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

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.
@@ -156,6 +156,51 @@ export const getRunDetailsTool = {
156156
}),
157157
};
158158

159+
export const getSpanDetailsTool = {
160+
name: toolsMetadata.get_span_details.name,
161+
title: toolsMetadata.get_span_details.title,
162+
description: toolsMetadata.get_span_details.description,
163+
inputSchema: GetSpanDetailsInput.shape,
164+
handler: toolHandler(GetSpanDetailsInput.shape, async (input, { ctx }) => {
165+
ctx.logger?.log("calling get_span_details", { input });
166+
167+
if (ctx.options.devOnly && input.environment !== "dev") {
168+
return respondWithError(
169+
`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.`
170+
);
171+
}
172+
173+
const projectRef = await ctx.getProjectRef({
174+
projectRef: input.projectRef,
175+
cwd: input.configPath,
176+
});
177+
178+
const apiClient = await ctx.getApiClient({
179+
projectRef,
180+
environment: input.environment,
181+
scopes: [`read:runs:${input.runId}`],
182+
branch: input.branch,
183+
});
184+
185+
const spanDetail = await apiClient.retrieveSpan(input.runId, input.spanId);
186+
const formatted = formatSpanDetail(spanDetail);
187+
188+
const runUrl = await ctx.getDashboardUrl(
189+
`/projects/v3/${projectRef}/runs/${input.runId}`
190+
);
191+
192+
const content = [formatted];
193+
if (runUrl) {
194+
content.push("");
195+
content.push(`[View run in dashboard](${runUrl})`);
196+
}
197+
198+
return {
199+
content: [{ type: "text", text: content.join("\n") }],
200+
};
201+
}),
202+
};
203+
159204
export const waitForRunToCompleteTool = {
160205
name: toolsMetadata.wait_for_run_to_complete.name,
161206
title: toolsMetadata.wait_for_run_to_complete.title,

packages/core/src/v3/apiClient/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
PromptOverrideCreatedResponseBody,
5555
RetrieveRunResponse,
5656
RetrieveRunTraceResponseBody,
57+
RetrieveSpanDetailResponseBody,
5758
ScheduleObject,
5859
SendInputStreamResponseBody,
5960
StreamBatchItemsResponse,
@@ -602,6 +603,18 @@ export class ApiClient {
602603
);
603604
}
604605

606+
retrieveSpan(runId: string, spanId: string, requestOptions?: ZodFetchOptions) {
607+
return zodfetch(
608+
RetrieveSpanDetailResponseBody,
609+
`${this.baseUrl}/api/v1/runs/${runId}/spans/${spanId}`,
610+
{
611+
method: "GET",
612+
headers: this.#getHeaders(false),
613+
},
614+
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
615+
);
616+
}
617+
605618
listRuns(
606619
query?: ListRunsQueryParams,
607620
requestOptions?: ZodFetchOptions

0 commit comments

Comments
 (0)