feat(mcp): add get_span_details tool (#3255)
Returns the fully detailed span with attributes and AI enrichment data
This commit is contained in:
@@ -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
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user