next.js/packages/next/src/server/mcp/tools/get-page-metadata.ts
get-page-metadata.ts240 lines6.2 KB
import type { McpServer } from 'next/dist/compiled/@modelcontextprotocol/sdk/server/mcp'
import {
  HMR_MESSAGE_SENT_TO_BROWSER,
  type HmrMessageSentToBrowser,
} from '../../dev/hot-reloader-types'
import {
  createBrowserRequest,
  handleBrowserPageResponse,
  DEFAULT_BROWSER_REQUEST_TIMEOUT_MS,
} from './utils/browser-communication'
import type {
  PageMetadata,
  PageSegment,
  SegmentTrieData,
} from '../../../shared/lib/mcp-page-metadata-types'
import type { SegmentTrieNode } from '../../../next-devtools/dev-overlay/segment-explorer-trie'
import { mcpTelemetryTracker } from '../mcp-telemetry-tracker'

export function registerGetPageMetadataTool(
  server: McpServer,
  sendHmrMessage: (message: HmrMessageSentToBrowser) => void,
  getActiveConnectionCount: () => number
) {
  server.registerTool(
    'get_page_metadata',
    {
      description:
        'Get runtime metadata about what contributes to the current page render from active browser sessions.',
      inputSchema: {},
    },
    async (_request) => {
      // Track telemetry
      mcpTelemetryTracker.recordToolCall('mcp/get_page_metadata')

      try {
        const connectionCount = getActiveConnectionCount()
        if (connectionCount === 0) {
          return {
            content: [
              {
                type: 'text',
                text: JSON.stringify({
                  error:
                    'No browser sessions connected. Please open your application in a browser to retrieve page metadata.',
                }),
              },
            ],
          }
        }

        const responses = await createBrowserRequest<SegmentTrieData>(
          HMR_MESSAGE_SENT_TO_BROWSER.REQUEST_PAGE_METADATA,
          sendHmrMessage,
          getActiveConnectionCount,
          DEFAULT_BROWSER_REQUEST_TIMEOUT_MS
        )

        if (responses.length === 0) {
          return {
            content: [
              {
                type: 'text',
                text: JSON.stringify({
                  sessions: [],
                }),
              },
            ],
          }
        }

        const sessionMetadata: Array<{ url: string; metadata: PageMetadata }> =
          []
        for (const response of responses) {
          if (response.data) {
            // TODO: Add other metadata for the current page render here. Currently, we only have segment trie data.
            const pageMetadata = convertSegmentTrieToPageMetadata(response.data)
            sessionMetadata.push({ url: response.url, metadata: pageMetadata })
          }
        }

        if (sessionMetadata.length === 0) {
          return {
            content: [
              {
                type: 'text',
                text: JSON.stringify({
                  sessions: [],
                }),
              },
            ],
          }
        }

        const output = formatPageMetadata(sessionMetadata)

        return {
          content: [
            {
              type: 'text',
              text: JSON.stringify(output),
            },
          ],
        }
      } catch (error) {
        return {
          content: [
            {
              type: 'text',
              text: JSON.stringify({
                error: error instanceof Error ? error.message : String(error),
              }),
            },
          ],
        }
      }
    }
  )
}

export function handlePageMetadataResponse(
  requestId: string,
  segmentTrieData: SegmentTrieData | null,
  url: string | undefined
) {
  handleBrowserPageResponse<SegmentTrieData | null>(
    requestId,
    segmentTrieData,
    url || ''
  )
}

function convertSegmentTrieToPageMetadata(data: SegmentTrieData): PageMetadata {
  const segments: PageSegment[] = []

  if (data.segmentTrie) {
    // Traverse the trie and collect all segments
    function traverseTrie(node: SegmentTrieNode): void {
      if (node.value) {
        segments.push({
          type: node.value.type,
          pagePath: node.value.pagePath,
          boundaryType: node.value.boundaryType,
        })
      }

      for (const childNode of Object.values(node.children)) {
        if (childNode) {
          traverseTrie(childNode)
        }
      }
    }

    traverseTrie(data.segmentTrie)
  }

  return {
    segments,
    routerType: data.routerType,
  }
}

interface FormattedSegment {
  path: string
  type: string
  isBoundary: boolean
  isBuiltin: boolean
}

interface FormattedSession {
  url: string
  routerType: string
  segments: FormattedSegment[]
}

interface FormattedPageMetadataOutput {
  sessions: FormattedSession[]
}

function formatPageMetadata(
  sessionMetadata: Array<{ url: string; metadata: PageMetadata }>
): FormattedPageMetadataOutput {
  const sessions: FormattedSession[] = []

  for (const { url, metadata } of sessionMetadata) {
    let displayUrl = url
    try {
      const urlObj = new URL(url)
      displayUrl = urlObj.pathname + urlObj.search + urlObj.hash
    } catch {
      // If URL parsing fails, use the original URL
    }

    // Ensure consistent output to avoid flaky tests
    const sortedSegments = [...metadata.segments].sort((a, b) => {
      const typeOrder = (segment: PageSegment): number => {
        const type = segment.boundaryType || segment.type
        if (type === 'layout') return 0
        if (type.startsWith('boundary:')) return 1
        if (type === 'page') return 2
        return 3
      }
      const aOrder = typeOrder(a)
      const bOrder = typeOrder(b)
      if (aOrder !== bOrder) return aOrder - bOrder
      return a.pagePath.localeCompare(b.pagePath)
    })

    const formattedSegments: FormattedSegment[] = []
    for (const segment of sortedSegments) {
      const path = segment.pagePath
      const isBuiltin = path.startsWith('__next_builtin__')
      const type = segment.boundaryType || segment.type
      const isBoundary = type.startsWith('boundary:')

      let displayPath = path
        .replace(/@boundary$/, '')
        .replace(/^__next_builtin__/, '')

      if (!isBuiltin && !displayPath.startsWith('app/')) {
        displayPath = `app/${displayPath}`
      }

      formattedSegments.push({
        path: displayPath,
        type,
        isBoundary,
        isBuiltin,
      })
    }

    sessions.push({
      url: displayUrl,
      routerType: metadata.routerType,
      segments: formattedSegments,
    })
  }

  return { sessions }
}
Quest for Codev2.0.0
/
SIGN IN