next.js/packages/next/src/cli/internal/turbo-trace-server.ts
turbo-trace-server.ts265 lines8.5 KB
import http from 'node:http'
import z from 'next/dist/compiled/zod'
import { loadBindings } from '../../build/swc'
import type { TraceSpanInfo } from '../../build/swc/generated-native'

const { McpServer } =
  require('next/dist/compiled/@modelcontextprotocol/sdk/server/mcp') as typeof import('next/dist/compiled/@modelcontextprotocol/sdk/server/mcp')
const { StreamableHTTPServerTransport } =
  require('next/dist/compiled/@modelcontextprotocol/sdk/server/streamableHttp') as typeof import('next/dist/compiled/@modelcontextprotocol/sdk/server/streamableHttp')

const DEFAULT_WS_PORT = 5747

/** 100 internal ticks = 1 µs */
const TICKS_PER_US = 100
const TICKS_PER_MS = TICKS_PER_US * 1000
const TICKS_PER_S = TICKS_PER_MS * 1000

function formatDuration(ticks: number): string {
  if (ticks < TICKS_PER_MS) {
    const us = ticks / TICKS_PER_US
    return `${us.toFixed(0)}µs`
  }
  if (ticks < TICKS_PER_S) {
    const ms = ticks / TICKS_PER_MS
    return `${ms.toFixed(2)}ms`
  }
  const s = ticks / TICKS_PER_S
  return `${s.toFixed(3)}s`
}

function formatRelative(ticks: number): string {
  // ticks may be negative if the child starts before the parent reference point
  const prefix = ticks < 0 ? '-' : ''
  return prefix + formatDuration(Math.abs(ticks))
}

/**
 * Render a single span (or aggregated span group) as a markdown section.
 */
function renderSpanMarkdown(span: TraceSpanInfo): string {
  let md = `### \`${span.name}\` (ID: \`${span.id}\`)\n`

  if (span.isAggregated && span.count !== undefined && span.count > 1) {
    md += `- **Count:** ${span.count} spans\n`
    if (span.totalCpuDuration !== undefined) {
      md += `- **Total CPU Duration:** ${formatDuration(span.totalCpuDuration)}\n`
    }
    if (span.avgCpuDuration !== undefined) {
      md += `- **Avg CPU Duration:** ${formatDuration(span.avgCpuDuration)}\n`
    }
    if (span.totalCorrectedDuration !== undefined) {
      md += `- **Total Corrected Duration:** ${formatDuration(span.totalCorrectedDuration)}\n`
    }
    if (span.avgCorrectedDuration !== undefined) {
      md += `- **Avg Corrected Duration:** ${formatDuration(span.avgCorrectedDuration)}\n`
    }
    md += `- **Start (relative to parent):** ${formatRelative(span.startRelativeToParent)}\n`
    md += `- **End (relative to parent):** ${formatRelative(span.endRelativeToParent)}\n`
    const exampleId = span.firstSpanId ?? span.id
    md += `\n#### First span as example (ID: \`${exampleId}\`)\n`
    md += `- **CPU Duration:** ${formatDuration(span.cpuDuration)}\n`
    md += `- **Corrected Duration:** ${formatDuration(span.correctedDuration)}\n`
  } else {
    md += `- **CPU Duration:** ${formatDuration(span.cpuDuration)}\n`
    md += `- **Corrected Duration:** ${formatDuration(span.correctedDuration)}\n`
    md += `- **Start (relative to parent):** ${formatRelative(span.startRelativeToParent)}\n`
    md += `- **End (relative to parent):** ${formatRelative(span.endRelativeToParent)}\n`
  }

  if (span.args && span.args.length > 0) {
    md += `\n**Attributes:**\n`
    for (const [k, v] of span.args) {
      md += `- \`${k}\`: ${v}\n`
    }
  }

  md += '\n---\n\n'
  return md
}

export async function startTurboTraceServerCli(
  file: string,
  port: number | undefined,
  mcpPort: number | undefined
) {
  const wsPort = port ?? DEFAULT_WS_PORT
  const httpPort = mcpPort ?? wsPort + 1

  let bindings
  try {
    bindings = await loadBindings()
  } catch {
    console.error(
      'Error: Could not load native bindings. The trace server requires native (non-WASM) bindings.'
    )
    process.exit(1)
  }

  let handle
  try {
    // Start the WebSocket trace server on a background thread (non-blocking).
    handle = bindings.turbo.startTurbopackTraceServerHandle(file, wsPort)
  } catch (err) {
    console.error(
      `Error: Could not start trace server for "${file}": ${err instanceof Error ? err.message : err}`
    )
    process.exit(1)
  }

  console.log(
    `Turbopack trace server started. View trace at https://trace.nextjs.org?port=${wsPort}`
  )

  // Create the MCP server.
  const mcpServer = new McpServer({
    name: 'Next.js Trace Server MCP',
    version: '0.1.0',
  })

  mcpServer.registerTool(
    'query_spans',
    {
      description:
        'Query spans from a turbopack trace file. Returns spans with timing, CPU usage, and attribute details. Set `outputType` to "json" for machine-readable output or "markdown" (default) for human-readable output. Use the `parent` parameter (with an ID from a previous result) to drill into children. Results are paginated to 20 spans per page.',
      inputSchema: {
        parent: z
          .string()
          .optional()
          .describe(
            'Span ID to enumerate children of. Omit for root-level spans. Use the `id` field from a previous result.'
          ),
        aggregated: z
          .boolean()
          .optional()
          .describe(
            'When true (default), aggregate spans with the same name into a single entry. Set to false to see individual raw spans.'
          ),
        sort: z
          .enum(['value', 'name'])
          .optional()
          .describe(
            'Sort mode: "value" for corrected duration descending, "name" for alphabetical. Omit for execution order.'
          ),
        search: z
          .string()
          .optional()
          .describe(
            'Substring search query applied to span name and category.'
          ),
        page: z.number().optional().describe('1-based page number. Default 1.'),
        outputType: z
          .enum(['markdown', 'json'])
          .optional()
          .describe(
            'Output format. "markdown" (default) returns human-readable markdown. "json" returns structured JSON with all span fields.'
          ),
      },
    },
    (args) => {
      const result = bindings.turbo.queryTraceSpans(handle, {
        parent: args.parent,
        aggregated: args.aggregated ?? true,
        sort: args.sort,
        search: args.search,
        page: args.page ?? 1,
      })

      const { spans, page, totalPages, totalCount } = result

      if (args.outputType === 'json') {
        return {
          content: [
            {
              type: 'text',
              text: JSON.stringify({ spans, page, totalPages, totalCount }),
            },
          ],
        }
      }

      const parentLabel = args.parent
        ? `children of ID \`${args.parent}\``
        : 'root level'
      let md = `## Spans at ${parentLabel} — Page ${page} of ${totalPages} (${totalCount} total)\n\n`

      if (spans.length === 0) {
        md += '_No spans found._\n'
      }

      for (const span of spans) {
        md += renderSpanMarkdown(span)
      }

      if (page < totalPages) {
        md += `Use \`page=${page + 1}\` to see more results.\n`
      }

      return { content: [{ type: 'text', text: md }] }
    }
  )

  // Start the HTTP server for MCP (served at /mcp).
  const server = http.createServer(async (req, res) => {
    if (req.url !== '/mcp') {
      res.writeHead(404, { 'Content-Type': 'text/plain' })
      res.end('Not found. MCP endpoint is at /mcp\n')
      return
    }
    const transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: undefined,
    })
    try {
      res.on('close', () => transport.close())
      await mcpServer.connect(transport)
      let body = ''
      req.setEncoding('utf8')
      await new Promise<void>((resolve, reject) => {
        req.on('data', (chunk: string) => {
          body += chunk
        })
        req.on('end', resolve)
        req.on('error', reject)
      })
      await transport.handleRequest(
        req,
        res,
        body ? JSON.parse(body) : undefined
      )
    } catch (err) {
      if (!res.headersSent) {
        res.writeHead(500, {
          'Content-Type': 'application/json; charset=utf-8',
        })
        res.end(
          JSON.stringify({
            jsonrpc: '2.0',
            error: { code: -32000, message: 'Internal server error' },
            id: null,
          })
        )
      }
    }
  })

  server.on('error', (err: NodeJS.ErrnoException) => {
    if (err.code === 'EADDRINUSE') {
      console.error(
        `Error: MCP port ${httpPort} is already in use. Use --mcp-port to specify a different port.`
      )
    } else {
      console.error(`Error starting MCP server: ${err.message}`)
    }
    process.exit(1)
  })

  server.listen(httpPort, '127.0.0.1', () => {
    console.log(
      `Query this trace from the command line: next internal query-trace --help`
    )
    console.log(
      `Alternatively, connect an MCP client to http://127.0.0.1:${httpPort}/mcp`
    )
  })
}
Quest for Codev2.0.0
/
SIGN IN