import type { FastMCP } from 'fastmcp'; import { UserError } from 'fastmcp'; import { z } from 'zod'; import { google } from 'googleapis'; import { getAuthClient, getSheetsClient } from '../../../clients.js'; export function register(server: FastMCP) { server.addTool({ name: 'listSheetsComments', description: 'Lists all comments in a Google Spreadsheet. Filter by sheet name, specific row(s), specific cell, or a cell range. Returns comment IDs needed for getSheetsComment, replyToSheetsComment, resolveSheetsComment, or deleteSheetsComment.', parameters: z.strictObject({ spreadsheetId: z .string() .describe( 'The spreadsheet ID — the long string between /d/ and /edit in a Google Sheets URL.' ), sheetName: z .string() .optional() .describe('Optional: filter comments to only this sheet/tab name.'), row: z .number() .int() .min(1) .optional() .describe( 'Optional: filter to comments on this specific row number (1-based, e.g., 5 for row 5).' ), rows: z .array(z.number().int().min(1)) .optional() .describe( 'Optional: filter to comments on any of these row numbers (1-based, e.g., [3, 5, 12]). Ignored if "row" is set.' ), cell: z .string() .optional() .describe( 'Optional: filter to comments on this specific cell in A1 notation (e.g., "B3"). Overrides row/rows filters.' ), range: z .string() .optional() .describe( 'Optional: filter to comments within this A1 range (e.g., "A1:D10", "B:B" for entire column B). Overrides row/rows/cell filters.' ), includeResolved: z .boolean() .optional() .default(false) .describe('If true, include resolved comments. Defaults to false.'), }), execute: async (args, { log }) => { log.info(`Listing comments for spreadsheet ${args.spreadsheetId}`); try { const authClient = await getAuthClient(); const drive = google.drive({ version: 'v3', auth: authClient }); const comments: any[] = []; let pageToken: string | undefined; do { const response = await drive.comments.list({ fileId: args.spreadsheetId, fields: 'comments(id,content,anchor,quotedFileContent,author,createdTime,modifiedTime,resolved,replies(id,content,author,createdTime)),nextPageToken', pageSize: 100, ...(pageToken ? { pageToken } : {}), }); comments.push(...(response.data.comments || [])); pageToken = response.data.nextPageToken || undefined; } while (pageToken); let sheetGidMap: Record = {}; const needSheetMeta = args.sheetName || args.row || args.rows || args.cell || args.range; if (needSheetMeta) { const sheetsClient = await getSheetsClient(); const meta = await sheetsClient.spreadsheets.get({ spreadsheetId: args.spreadsheetId, fields: 'sheets.properties', }); for (const sheet of meta.data.sheets || []) { const props = sheet.properties; if (props?.sheetId !== undefined && props?.title) { sheetGidMap[String(props.sheetId)] = props.title; } } } const rangeFilter = args.range ? parseA1Range(args.range) : null; const cellFilter = args.cell ? a1ToRowCol(args.cell) : null; const rowSet = args.row ? new Set([args.row - 1]) : args.rows ? new Set(args.rows.map((r) => r - 1)) : null; const result = comments .filter((c) => { if (!args.includeResolved && c.resolved) return false; const cellInfo = c.anchor ? parseSheetsAnchor(c.anchor) : null; if (args.sheetName && cellInfo) { const resolvedName = sheetGidMap[String(cellInfo.sheetId)]; if (resolvedName && resolvedName !== args.sheetName) return false; } if (!cellInfo) return !rangeFilter && !cellFilter && !rowSet; if (rangeFilter) { return ( cellInfo.row >= rangeFilter.startRow && cellInfo.row <= rangeFilter.endRow && cellInfo.col >= rangeFilter.startCol && cellInfo.col <= rangeFilter.endCol ); } if (cellFilter) { return cellInfo.row === cellFilter.row && cellInfo.col === cellFilter.col; } if (rowSet) { return rowSet.has(cellInfo.row); } return true; }) .map((comment: any) => { const cellInfo = comment.anchor ? parseSheetsAnchor(comment.anchor) : null; let cellRef: string | null = null; let sheetTitle: string | null = null; if (cellInfo) { cellRef = rowColToA1(cellInfo.row, cellInfo.col); if (Object.keys(sheetGidMap).length > 0) { sheetTitle = sheetGidMap[String(cellInfo.sheetId)] || null; } } return { id: comment.id, author: comment.author?.displayName || null, content: comment.content, cell: cellRef, sheetName: sheetTitle, quotedText: comment.quotedFileContent?.value || null, resolved: comment.resolved || false, createdTime: comment.createdTime, modifiedTime: comment.modifiedTime, replyCount: comment.replies?.length || 0, }; }); return JSON.stringify({ comments: result, total: result.length }, null, 2); } catch (error: any) { log.error(`Error listing sheets comments: ${error.message || error}`); throw new UserError( `Failed to list spreadsheet comments: ${error.message || 'Unknown error'}` ); } }, }); } function parseSheetsAnchor( anchorStr: string ): { sheetId: number; row: number; col: number } | null { try { const anchor = JSON.parse(anchorStr); const actions = anchor.a; if (!actions || !Array.isArray(actions)) return null; for (const action of actions) { if (action.sht) { const sid = action.sht.sid; const rng = action.sht.rng; if (sid !== undefined && rng) { return { sheetId: sid, row: rng.r || 0, col: rng.c || 0 }; } } } return null; } catch { return null; } } function rowColToA1(row: number, col: number): string { let colStr = ''; let c = col; do { colStr = String.fromCharCode(65 + (c % 26)) + colStr; c = Math.floor(c / 26) - 1; } while (c >= 0); return `${colStr}${row + 1}`; } function a1ToRowCol(a1: string): { row: number; col: number } { const match = a1.match(/^([A-Za-z]+)(\d+)$/); if (!match) return { row: 0, col: 0 }; const colStr = match[1].toUpperCase(); let col = 0; for (let i = 0; i < colStr.length; i++) { col = col * 26 + (colStr.charCodeAt(i) - 64); } return { row: parseInt(match[2], 10) - 1, col: col - 1 }; } function parseA1Range(range: string): { startRow: number; endRow: number; startCol: number; endCol: number; } { const stripped = range.includes('!') ? range.split('!')[1] : range; const parts = stripped.split(':'); const colOnly = /^[A-Za-z]+$/; const rowOnly = /^\d+$/; const parseCorner = (s: string): { row: number | null; col: number | null } => { if (colOnly.test(s)) { const upper = s.toUpperCase(); let c = 0; for (let i = 0; i < upper.length; i++) c = c * 26 + (upper.charCodeAt(i) - 64); return { row: null, col: c - 1 }; } if (rowOnly.test(s)) { return { row: parseInt(s, 10) - 1, col: null }; } const rc = a1ToRowCol(s); return { row: rc.row, col: rc.col }; }; if (parts.length === 1) { const p = parseCorner(parts[0]); return { startRow: p.row ?? 0, endRow: p.row ?? 999999, startCol: p.col ?? 0, endCol: p.col ?? 999999, }; } const start = parseCorner(parts[0]); const end = parseCorner(parts[1]); return { startRow: start.row ?? 0, endRow: end.row ?? 999999, startCol: start.col ?? 0, endCol: end.col ?? 999999, }; }