import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { Router } from 'express'; import type { BrowserManager } from '@social/core/browser/manager.js'; import { config } from '@social/core/config/index.js'; import { withErrorHandling, type McpToolResult } from '@social/core/utils/errors.js'; import { resolveMediaInput, cleanupFile } from '@social/core/utils/downloader.js'; import { getIdempotencyStore, computeIdempotencyHash, } from '@social/core/utils/idempotency.js'; import { resolveFeedTarget, resolveUserTarget } from './target-resolver.js'; import { checkLoginStatus, getLoginQRCode, deleteCookies } from './login.js'; import { listFeeds } from './feeds.js'; import { searchFeeds } from './search.js'; import { getFeedDetail, getSubComments } from './feed-detail.js'; import { getUserProfile } from './user-profile.js'; import { publishImageNote } from './publish.js'; import { publishVideoNote } from './publish-video.js'; import { listMyNotes } from './my-notes.js'; import { postComment, replyComment } from './comment.js'; import { setLikeState, setFavoriteState } from './interaction.js'; import { replyNotification } from './notification.js'; import { getNotificationStateStore, type NotificationKeysetCursor, type NotificationTask, type NotificationTaskStatus, } from './notification-state.js'; import { syncCommentNotifications, xhsNotificationPoller } from './notification-sync.js'; import { createXhsRoutes } from './routes.js'; import { CheckLoginSchema, GetLoginQRCodeSchema, DeleteCookiesSchema, ListFeedsSchema, SearchSchema, GetFeedDetailSchema, GetSubCommentsSchema, GetUserProfileSchema, PublishImageSchema, PublishVideoSchema, ListMyNotesSchema, PostCommentSchema, ReplyCommentSchema, SetLikeStateSchema, SetFavoriteStateSchema, ReplyNotificationSchema, GetUnprocessedNotificationsSchema, MarkNotificationTaskSchema, MarkNotificationTasksSchema, ListFailedNotificationTasksSchema, RetryNotificationTaskSchema, RetryNotificationTasksSchema, } from './schemas.js'; import type { SearchFilters } from './types.js'; import type { PlatformPlugin } from '@social/core/server/app.js'; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const PLATFORM = 'xiaohongshu'; /** Maximum file size for video uploads (500 MB). */ const VIDEO_MAX_SIZE_MB = 500; /** Maximum file size for image uploads (20 MB — default in validateMediaPath). */ const IMAGE_MAX_SIZE_MB = 20; /** Default page size for list-like MCP tools. */ const DEFAULT_PAGE_SIZE = 20; /** Maximum page size for list-like MCP tools. */ const MAX_PAGE_SIZE = 200; type McpMeta = Record; function ok(data: unknown, meta?: McpMeta): McpToolResult { return { content: [{ type: 'text', text: JSON.stringify({ success: true, data, meta: meta ?? {}, }), }], }; } function parseCursor(cursor?: string): number { if (!cursor) return 0; if (!/^\d+$/.test(cursor)) { throw new Error('Invalid cursor: must be a non-negative integer string'); } return Number.parseInt(cursor, 10); } function clampPageSize(maxCount?: number): number { return Math.min(MAX_PAGE_SIZE, Math.max(1, maxCount ?? DEFAULT_PAGE_SIZE)); } function paginateArray( items: T[], opts?: { max_count?: number; cursor?: string }, ): { items: T[]; meta: McpMeta } { const limit = clampPageSize(opts?.max_count); const offset = parseCursor(opts?.cursor); const sliced = items.slice(offset, offset + limit); const nextOffset = offset + sliced.length; const nextCursor = nextOffset < items.length ? String(nextOffset) : undefined; return { items: sliced, meta: { pagination: { cursor: opts?.cursor ?? '0', max_count: limit, returned: sliced.length, total: items.length, ...(nextCursor ? { next_cursor: nextCursor } : {}), }, }, }; } function encodeNotificationCursor(cursor: NotificationKeysetCursor): string { return Buffer.from( JSON.stringify({ first_seen_at: cursor.firstSeenAt, fingerprint: cursor.fingerprint, }), 'utf8', ).toString('base64url'); } function decodeNotificationCursor(cursor?: string): NotificationKeysetCursor | undefined { if (!cursor) return undefined; try { const raw = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as { first_seen_at?: unknown; fingerprint?: unknown; }; const firstSeenAt = raw.first_seen_at; const fingerprint = raw.fingerprint; if ( typeof firstSeenAt !== 'number' || !Number.isInteger(firstSeenAt) || firstSeenAt < 0 || typeof fingerprint !== 'string' || fingerprint.length === 0 ) { throw new Error('Invalid notification cursor payload'); } return { firstSeenAt, fingerprint }; } catch { throw new Error('Invalid cursor for notification keyset pagination'); } } interface SubCommentKeysetCursor { createTime: string; replyId: string; } function compareSubCommentKey(a: { createTime: string; id: string }, b: { createTime: string; id: string }): number { const timeCmp = a.createTime.localeCompare(b.createTime); if (timeCmp !== 0) return timeCmp; return a.id.localeCompare(b.id); } function encodeSubCommentCursor(cursor: SubCommentKeysetCursor): string { return Buffer.from( JSON.stringify({ create_time: cursor.createTime, reply_id: cursor.replyId, }), 'utf8', ).toString('base64url'); } function decodeSubCommentCursor(cursor?: string): SubCommentKeysetCursor | undefined { if (!cursor) return undefined; try { const raw = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as { create_time?: unknown; reply_id?: unknown; }; const createTime = raw.create_time; const replyId = raw.reply_id; if ( typeof createTime !== 'string' || createTime.length === 0 || typeof replyId !== 'string' || replyId.length === 0 ) { throw new Error('Invalid sub-comment cursor payload'); } return { createTime, replyId }; } catch { throw new Error('Invalid cursor for sub-comment keyset pagination'); } } async function runWithIdempotency( toolName: string, requestId: string | undefined, inputForHash: unknown, execute: () => Promise, ): Promise<{ data: T; meta?: McpMeta }> { if (!requestId) { return { data: await execute() }; } const store = getIdempotencyStore(); const inputHash = computeIdempotencyHash(inputForHash); const existing = store.get(toolName, requestId); if (existing) { if (existing.inputHash !== inputHash) { throw new Error('request_id already used with different parameters'); } return { data: existing.responseData as T, meta: { request_id: requestId, idempotent_replay: true, first_processed_at: existing.createdAt, }, }; } const data = await execute(); store.put(toolName, requestId, inputHash, data); return { data, meta: { request_id: requestId, idempotent_replay: false, }, }; } async function retryFailedNotificationTaskByFingerprint( browser: BrowserManager, fingerprint: string, replyContentOverride?: string, ): Promise<{ success: boolean; fingerprint: string }> { const store = getNotificationStateStore(); const task = store.getByFingerprint(fingerprint); if (!task) { throw new Error(`Notification task not found: ${fingerprint}`); } if (task.status !== 'failed') { throw new Error(`Task status must be failed to retry, got: ${task.status}`); } const replyContent = replyContentOverride ?? task.replyContent; if (!replyContent) { throw new Error('Retry requires reply_content when task has no stored replyContent'); } store.markPending(fingerprint); const timeoutMs = config.operationTimeouts['reply'] ?? config.operationTimeouts['default'] ?? 20_000; try { const result = await browser.withPage( PLATFORM, async (page) => replyNotification( page, task.notification.userId, task.notification.content, replyContent, ), timeoutMs, ); if (result.success) { store.markReplied(fingerprint, replyContent); } else { store.markFailed(fingerprint, 'Retry reply returned success=false'); } return { success: result.success, fingerprint, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); store.markFailed(fingerprint, message); throw err; } } function resolveReplyTarget(args: { fingerprint?: string; user_id?: string; comment_content?: string; }): { fingerprint?: string; userId: string; commentContent: string } { const store = getNotificationStateStore(); if (args.fingerprint) { const task = store.getByFingerprint(args.fingerprint); if (!task) { throw new Error(`Notification task not found: ${args.fingerprint}`); } return { fingerprint: args.fingerprint, userId: task.notification.userId, commentContent: task.notification.content, }; } if (!args.user_id || !args.comment_content) { throw new Error('Either fingerprint or both user_id and comment_content are required'); } const fp = store.findOpenFingerprint(args.user_id, args.comment_content) ?? undefined; return { ...(fp ? { fingerprint: fp } : {}), userId: args.user_id, commentContent: args.comment_content, }; } // --------------------------------------------------------------------------- // PlatformPlugin implementation // --------------------------------------------------------------------------- export const xiaohongshuPlugin: PlatformPlugin = { name: PLATFORM, apiNamespace: 'xhs', // ========================================================================= // REST API routes (Phase 5) // ========================================================================= registerRoutes(router: Router, browser: BrowserManager): void { xhsNotificationPoller.start(browser); const xhsRouter = createXhsRoutes(browser); router.use('/', xhsRouter); }, shutdown(): Promise { xhsNotificationPoller.stop(); return Promise.resolve(); }, // ========================================================================= // MCP tools // ========================================================================= registerTools(server: McpServer, browser: BrowserManager): void { // ===================================================================== // Phase 2: Login management (3 tools) // ===================================================================== // ----------------------------------------------------------------------- // xhs_check_login // ----------------------------------------------------------------------- server.tool( 'xhs_check_login', 'Check Xiaohongshu login status', CheckLoginSchema, async () => { return withErrorHandling('xhs_check_login', async () => { const timeoutMs = config.operationTimeouts['login'] ?? config.operationTimeouts['default'] ?? 60_000; const status = await browser.withPage( PLATFORM, async (page) => checkLoginStatus(page), timeoutMs, ); return ok(status); }); }, ); // ----------------------------------------------------------------------- // xhs_get_login_qrcode // ----------------------------------------------------------------------- server.tool( 'xhs_get_login_qrcode', 'Get Xiaohongshu login QR code (user scans with phone)', GetLoginQRCodeSchema, async () => { return withErrorHandling('xhs_get_login_qrcode', async () => { const result = await getLoginQRCode(browser); return ok(result); }); }, ); // ----------------------------------------------------------------------- // xhs_delete_cookies // ----------------------------------------------------------------------- server.tool( 'xhs_delete_cookies', 'Delete Xiaohongshu cookies and reset login session', DeleteCookiesSchema, async () => { return withErrorHandling('xhs_delete_cookies', async () => { await deleteCookies(browser); return ok({ message: 'Cookies deleted' }); }); }, ); // ===================================================================== // Phase 3: Content browsing (4 tools) // ===================================================================== // ----------------------------------------------------------------------- // xhs_list_feeds // ----------------------------------------------------------------------- server.tool( 'xhs_list_feeds', 'Get Xiaohongshu explore page recommended feed list', ListFeedsSchema, async (args) => { return withErrorHandling('xhs_list_feeds', async () => { const timeoutMs = config.operationTimeouts['feed_list'] ?? config.operationTimeouts['default'] ?? 60_000; const feeds = await browser.withPage( PLATFORM, async (page) => listFeeds(page), timeoutMs, ); const pageResult = paginateArray(feeds, { max_count: args.max_count, cursor: args.cursor, }); return ok(pageResult.items, pageResult.meta); }); }, ); // ----------------------------------------------------------------------- // xhs_search // ----------------------------------------------------------------------- server.tool( 'xhs_search', 'Search Xiaohongshu notes by keyword with optional filters (sort, type, time range)', SearchSchema, async (args) => { return withErrorHandling('xhs_search', async () => { const timeoutMs = config.operationTimeouts['search'] ?? config.operationTimeouts['default'] ?? 60_000; const filters: SearchFilters | undefined = args.filters ? { sort: args.filters.sort, type: args.filters.type, time: args.filters.time, } : undefined; const feeds = await browser.withPage( PLATFORM, async (page) => searchFeeds(page, args.keyword, filters), timeoutMs, ); const pageResult = paginateArray(feeds, { max_count: args.max_count, cursor: args.cursor, }); return ok(pageResult.items, pageResult.meta); }); }, ); // ----------------------------------------------------------------------- // xhs_get_feed_detail // ----------------------------------------------------------------------- server.tool( 'xhs_get_feed_detail', 'Get Xiaohongshu note detail including content, images, stats, and first-screen comments (use xhs_get_sub_comments to load full replies)', GetFeedDetailSchema, async (args) => { return withErrorHandling('xhs_get_feed_detail', async () => { const timeoutMs = config.operationTimeouts['feed_detail'] ?? config.operationTimeouts['default'] ?? 60_000; const target = resolveFeedTarget({ url: args.url, feed_id: args.feed_id, xsec_token: args.xsec_token, }); const detail = await browser.withPage( PLATFORM, async (page) => getFeedDetail(page, target.feedId, target.xsecToken), timeoutMs, ); return ok(detail); }); }, ); // ----------------------------------------------------------------------- // xhs_get_sub_comments // ----------------------------------------------------------------------- server.tool( 'xhs_get_sub_comments', 'Load sub-comments (replies) for a specific parent comment on a Xiaohongshu note with keyset cursor pagination', GetSubCommentsSchema, async (args) => { return withErrorHandling('xhs_get_sub_comments', async () => { const timeoutMs = config.operationTimeouts['feed_detail'] ?? config.operationTimeouts['default'] ?? 60_000; const limit = clampPageSize(args.max_count); const keysetCursor = decodeSubCommentCursor(args.cursor); const allLoaded = await browser.withPage( PLATFORM, async (page) => getSubComments(page, args.feed_id, args.xsec_token, args.comment_id, MAX_PAGE_SIZE), timeoutMs, ); const sorted = [...allLoaded].sort((a, b) => compareSubCommentKey( { createTime: a.createTime, id: a.id }, { createTime: b.createTime, id: b.id }, )); const startIndex = keysetCursor ? sorted.findIndex((item) => compareSubCommentKey( { createTime: item.createTime, id: item.id }, { createTime: keysetCursor.createTime, id: keysetCursor.replyId }, ) > 0) : 0; const start = startIndex < 0 ? sorted.length : startIndex; const pageItems = sorted.slice(start, start + limit); const hasMore = start + pageItems.length < sorted.length; const nextCursor = hasMore && pageItems.length > 0 ? encodeSubCommentCursor({ createTime: pageItems[pageItems.length - 1]!.createTime, replyId: pageItems[pageItems.length - 1]!.id, }) : undefined; return ok(pageItems, { pagination: { mode: 'keyset', cursor: args.cursor ?? null, max_count: limit, returned: pageItems.length, has_more: hasMore, ...(nextCursor ? { next_cursor: nextCursor } : {}), }, }); }); }, ); // ----------------------------------------------------------------------- // xhs_get_user_profile // ----------------------------------------------------------------------- server.tool( 'xhs_get_user_profile', 'Get Xiaohongshu user profile information including bio, stats, and recent notes', GetUserProfileSchema, async (args) => { return withErrorHandling('xhs_get_user_profile', async () => { const timeoutMs = config.operationTimeouts['user_profile'] ?? config.operationTimeouts['default'] ?? 60_000; const target = resolveUserTarget({ url: args.url, user_id: args.user_id, xsec_token: args.xsec_token, }); const profile = await browser.withPage( PLATFORM, async (page) => getUserProfile(page, target.userId, target.xsecToken), timeoutMs, ); return ok(profile); }); }, ); // ===================================================================== // My published notes (1 tool) // ===================================================================== // ----------------------------------------------------------------------- // xhs_list_my_notes // ----------------------------------------------------------------------- server.tool( 'xhs_list_my_notes', 'List your published notes on Xiaohongshu from the creator center', ListMyNotesSchema, async (args) => { return withErrorHandling('xhs_list_my_notes', async () => { const timeoutMs = config.operationTimeouts['feed_list'] ?? config.operationTimeouts['default'] ?? 60_000; const notes = await browser.withPage( PLATFORM, async (page) => listMyNotes(page), timeoutMs, ); const pageResult = paginateArray(notes, { max_count: args.max_count, cursor: args.cursor, }); return ok(pageResult.items, pageResult.meta); }); }, ); // ===================================================================== // Phase 4: Content publishing (2 tools) // ===================================================================== // ----------------------------------------------------------------------- // xhs_publish_image // ----------------------------------------------------------------------- server.tool( 'xhs_publish_image', 'Publish an image note on Xiaohongshu. Provide local file paths for images.', PublishImageSchema, async (args) => { return withErrorHandling('xhs_publish_image', async () => { const { data, meta } = await runWithIdempotency( 'xhs_publish_image', args.request_id, { title: args.title, content: args.content, images: args.images, tags: args.tags, schedule_at: args.schedule_at, is_original: args.is_original, visibility: args.visibility, }, async () => { // Resolve all images (local path or URL download) before acquiring browser. const resolved: Array<{ path: string; temporary: boolean }> = []; for (const img of args.images) { resolved.push(await resolveMediaInput(img, { maxSizeMB: IMAGE_MAX_SIZE_MB })); } const validatedPaths = resolved.map((r) => r.path); const timeoutMs = config.operationTimeouts['publish'] ?? config.operationTimeouts['default'] ?? 300_000; try { return await browser.withPage( PLATFORM, async (page) => publishImageNote(page, args.title, args.content, validatedPaths, { tags: args.tags, scheduleAt: args.schedule_at, isOriginal: args.is_original, visibility: args.visibility, }), timeoutMs, ); } finally { for (const r of resolved) { if (r.temporary) await cleanupFile(r.path).catch(() => undefined); } } }, ); return ok(data, meta); }); }, ); // ----------------------------------------------------------------------- // xhs_publish_video // ----------------------------------------------------------------------- server.tool( 'xhs_publish_video', 'Publish a video note on Xiaohongshu. Provide a local file path for the video.', PublishVideoSchema, async (args) => { return withErrorHandling('xhs_publish_video', async () => { const { data, meta } = await runWithIdempotency( 'xhs_publish_video', args.request_id, { title: args.title, content: args.content, video: args.video, tags: args.tags, schedule_at: args.schedule_at, visibility: args.visibility, }, async () => { // Resolve video (local path or URL download) before acquiring browser. const resolvedVideo = await resolveMediaInput(args.video, { maxSizeMB: VIDEO_MAX_SIZE_MB }); const timeoutMs = config.operationTimeouts['publish'] ?? config.operationTimeouts['default'] ?? 300_000; try { return await browser.withPage( PLATFORM, async (page) => publishVideoNote(page, args.title, args.content, resolvedVideo.path, { tags: args.tags, scheduleAt: args.schedule_at, visibility: args.visibility, }), timeoutMs, ); } finally { if (resolvedVideo.temporary) await cleanupFile(resolvedVideo.path).catch(() => undefined); } }, ); return ok(data, meta); }); }, ); // ===================================================================== // Phase 4: Interactions (4 tools) // ===================================================================== // ----------------------------------------------------------------------- // xhs_post_comment // ----------------------------------------------------------------------- server.tool( 'xhs_post_comment', 'Post a comment on a Xiaohongshu note', PostCommentSchema, async (args) => { return withErrorHandling('xhs_post_comment', async () => { const { data, meta } = await runWithIdempotency( 'xhs_post_comment', args.request_id, { feed_id: args.feed_id, xsec_token: args.xsec_token, content: args.content, }, async () => { const timeoutMs = config.operationTimeouts['comment'] ?? config.operationTimeouts['default'] ?? 20_000; return await browser.withPage( PLATFORM, async (page) => postComment(page, args.feed_id, args.xsec_token, args.content), timeoutMs, ); }, ); return ok(data, meta); }); }, ); // ----------------------------------------------------------------------- // xhs_reply_comment // ----------------------------------------------------------------------- server.tool( 'xhs_reply_comment', 'Reply to a comment on a Xiaohongshu note', ReplyCommentSchema, async (args) => { return withErrorHandling('xhs_reply_comment', async () => { const { data, meta } = await runWithIdempotency( 'xhs_reply_comment', args.request_id, { feed_id: args.feed_id, xsec_token: args.xsec_token, content: args.content, comment_id: args.comment_id, user_id: args.user_id, }, async () => { const timeoutMs = config.operationTimeouts['reply'] ?? config.operationTimeouts['default'] ?? 20_000; return await browser.withPage( PLATFORM, async (page) => replyComment( page, args.feed_id, args.xsec_token, args.content, args.comment_id, args.user_id, ), timeoutMs, ); }, ); return ok(data, meta); }); }, ); // ----------------------------------------------------------------------- // xhs_set_like_state // ----------------------------------------------------------------------- server.tool( 'xhs_set_like_state', 'Set like state on a Xiaohongshu note (idempotent)', SetLikeStateSchema, async (args) => { return withErrorHandling('xhs_set_like_state', async () => { const timeoutMs = config.operationTimeouts['like'] ?? config.operationTimeouts['default'] ?? 15_000; const result = await browser.withPage( PLATFORM, async (page) => setLikeState(page, args.feed_id, args.xsec_token, args.liked), timeoutMs, ); return ok(result); }); }, ); // ----------------------------------------------------------------------- // xhs_set_favorite_state // ----------------------------------------------------------------------- server.tool( 'xhs_set_favorite_state', 'Set favorite state on a Xiaohongshu note (idempotent)', SetFavoriteStateSchema, async (args) => { return withErrorHandling('xhs_set_favorite_state', async () => { const timeoutMs = config.operationTimeouts['favorite'] ?? config.operationTimeouts['default'] ?? 15_000; const result = await browser.withPage( PLATFORM, async (page) => setFavoriteState(page, args.feed_id, args.xsec_token, args.favorited), timeoutMs, ); return ok(result); }); }, ); // ===================================================================== // Notifications (7 tools) // ===================================================================== // ----------------------------------------------------------------------- // xhs_get_unprocessed_notifications // ----------------------------------------------------------------------- server.tool( 'xhs_get_unprocessed_notifications', 'Get unprocessed notification tasks from local state (SQLite). Optionally sync latest notifications first.', GetUnprocessedNotificationsSchema, async (args) => { return withErrorHandling('xhs_get_unprocessed_notifications', async () => { let syncResult: { fetched: number; inserted: number; updated: number } | null = null; if (args.sync) { syncResult = await syncCommentNotifications(browser, args.max_count); } const statuses: NotificationTaskStatus[] = args.statuses && args.statuses.length > 0 ? args.statuses : ['new', 'failed']; const store = getNotificationStateStore(); const limit = clampPageSize(args.max_count); const keysetCursor = decodeNotificationCursor(args.cursor); const page = store.listByStatusesKeyset(statuses, limit, keysetCursor); const nextCursor = page.nextCursor ? encodeNotificationCursor(page.nextCursor) : undefined; return ok(page.tasks, { ...(syncResult ? { synced: syncResult } : {}), pagination: { mode: 'keyset', cursor: args.cursor ?? null, max_count: limit, returned: page.tasks.length, has_more: page.hasMore, ...(nextCursor ? { next_cursor: nextCursor } : {}), }, }); }); }, ); // ----------------------------------------------------------------------- // xhs_mark_notification_task // ----------------------------------------------------------------------- server.tool( 'xhs_mark_notification_task', 'Manually mark a notification task status (new/pending/ignored/replied/failed)', MarkNotificationTaskSchema, async (args) => { return withErrorHandling('xhs_mark_notification_task', async () => { const store = getNotificationStateStore(); const existing = store.getByFingerprint(args.fingerprint); if (!existing) { throw new Error(`Notification task not found: ${args.fingerprint}`); } store.setStatus(args.fingerprint, args.status, args.note); const updated = store.getByFingerprint(args.fingerprint); return ok(updated); }); }, ); // ----------------------------------------------------------------------- // xhs_mark_notification_tasks // ----------------------------------------------------------------------- server.tool( 'xhs_mark_notification_tasks', 'Batch mark notification task statuses', MarkNotificationTasksSchema, async (args) => { return withErrorHandling('xhs_mark_notification_tasks', async () => { const store = getNotificationStateStore(); const results: Array<{ fingerprint: string; success: boolean; task?: NotificationTask | null; error?: string; }> = []; for (const item of args.tasks) { const existing = store.getByFingerprint(item.fingerprint); if (!existing) { results.push({ fingerprint: item.fingerprint, success: false, error: 'Notification task not found', }); continue; } store.setStatus(item.fingerprint, item.status, item.note); results.push({ fingerprint: item.fingerprint, success: true, task: store.getByFingerprint(item.fingerprint), }); } const successCount = results.filter((r) => r.success).length; return ok(results, { batch: { total: results.length, success: successCount, failed: results.length - successCount, }, }); }); }, ); // ----------------------------------------------------------------------- // xhs_list_failed_notification_tasks // ----------------------------------------------------------------------- server.tool( 'xhs_list_failed_notification_tasks', 'List failed notification tasks from local state store for retry/triage', ListFailedNotificationTasksSchema, async (args) => { return withErrorHandling('xhs_list_failed_notification_tasks', async () => { const store = getNotificationStateStore(); const statuses: NotificationTaskStatus[] = ['failed']; const limit = clampPageSize(args.max_count); const keysetCursor = decodeNotificationCursor(args.cursor); const page = store.listByStatusesKeyset(statuses, limit, keysetCursor); const nextCursor = page.nextCursor ? encodeNotificationCursor(page.nextCursor) : undefined; return ok(page.tasks, { pagination: { mode: 'keyset', cursor: args.cursor ?? null, max_count: limit, returned: page.tasks.length, has_more: page.hasMore, ...(nextCursor ? { next_cursor: nextCursor } : {}), }, }); }); }, ); // ----------------------------------------------------------------------- // xhs_retry_notification_task // ----------------------------------------------------------------------- server.tool( 'xhs_retry_notification_task', 'Retry a failed notification task by fingerprint, optionally overriding reply content', RetryNotificationTaskSchema, async (args) => { return withErrorHandling('xhs_retry_notification_task', async () => { const { data, meta } = await runWithIdempotency( 'xhs_retry_notification_task', args.request_id, { fingerprint: args.fingerprint, reply_content: args.reply_content, }, async () => retryFailedNotificationTaskByFingerprint( browser, args.fingerprint, args.reply_content, ), ); return ok(data, meta); }); }, ); // ----------------------------------------------------------------------- // xhs_retry_notification_tasks // ----------------------------------------------------------------------- server.tool( 'xhs_retry_notification_tasks', 'Batch retry failed notification tasks', RetryNotificationTasksSchema, async (args) => { return withErrorHandling('xhs_retry_notification_tasks', async () => { const { data, meta } = await runWithIdempotency( 'xhs_retry_notification_tasks', args.request_id, { tasks: args.tasks, continue_on_error: args.continue_on_error, }, async () => { const results: Array<{ fingerprint: string; success: boolean; error?: string; }> = []; for (const item of args.tasks) { try { const one = await retryFailedNotificationTaskByFingerprint( browser, item.fingerprint, item.reply_content, ); results.push(one); } catch (err) { const message = err instanceof Error ? err.message : String(err); results.push({ fingerprint: item.fingerprint, success: false, error: message, }); if (!args.continue_on_error) { throw err; } } } const successCount = results.filter((r) => r.success).length; return { results, summary: { total: results.length, success: successCount, failed: results.length - successCount, }, }; }, ); return ok(data, meta); }); }, ); // ----------------------------------------------------------------------- // xhs_reply_notification // ----------------------------------------------------------------------- server.tool( 'xhs_reply_notification', 'Reply to a comment notification inline on the Xiaohongshu notification page', ReplyNotificationSchema, async (args) => { return withErrorHandling('xhs_reply_notification', async () => { const target = resolveReplyTarget({ fingerprint: args.fingerprint, user_id: args.user_id, comment_content: args.comment_content, }); const targetFingerprint = target.fingerprint; const { data, meta } = await runWithIdempotency( 'xhs_reply_notification', args.request_id, { fingerprint: args.fingerprint, user_id: target.userId, comment_content: target.commentContent, reply_content: args.reply_content, }, async () => { const timeoutMs = config.operationTimeouts['reply'] ?? config.operationTimeouts['default'] ?? 20_000; try { if (targetFingerprint) { getNotificationStateStore().markPending(targetFingerprint); } const result = await browser.withPage( PLATFORM, async (page) => replyNotification( page, target.userId, target.commentContent, args.reply_content, ), timeoutMs, ); if (targetFingerprint) { if (result.success) { getNotificationStateStore().markReplied(targetFingerprint, args.reply_content); } else { getNotificationStateStore().markFailed( targetFingerprint, 'Reply action returned success=false', ); } } return { ...result, ...(targetFingerprint ? { fingerprint: targetFingerprint } : {}), }; } catch (err) { if (targetFingerprint) { const message = err instanceof Error ? err.message : String(err); getNotificationStateStore().markFailed(targetFingerprint, message); } throw err; } }, ); return ok(data, meta); }); }, ); }, };