优化小红书MCP:统一返回结构并增强分页、批量与幂等能力

This commit is contained in:
2026-03-03 12:14:33 +08:00
parent 5e0543668e
commit 892e76f7ed
8 changed files with 790 additions and 329 deletions
+3 -1
View File
@@ -6,7 +6,7 @@ Multi-platform social media automation service that exposes browser-based action
## Features
- **20 MCP tools** for Xiaohongshu (login, browsing, publishing, interactions, notifications, automation)
- **22 MCP tools** for Xiaohongshu (login, browsing, publishing, interactions, notifications, automation)
- **REST API** with Bearer token authentication and per-route rate limiting
- **Browser automation** via `rebrowser-playwright` with per-platform serial queueing
- **Cookie persistence** with file-based storage (`0600`, atomic writes)
@@ -105,8 +105,10 @@ Add this in `claude_desktop_config.json`:
| `xhs_favorite` | Toggle favorite state on a note |
| `xhs_get_unprocessed_notifications` | Get unprocessed notification tasks from local SQLite state |
| `xhs_mark_notification_task` | Manually mark notification task status (new/pending/ignored/replied/failed) |
| `xhs_mark_notification_tasks` | Batch mark notification task statuses |
| `xhs_list_failed_notification_tasks` | List failed notification tasks for triage/retry |
| `xhs_retry_notification_task` | Retry a failed notification task by fingerprint |
| `xhs_retry_notification_tasks` | Batch retry failed notification tasks |
| `xhs_reply_notification` | Reply to a specific notification |
## REST API
+3 -1
View File
@@ -6,7 +6,7 @@
## 功能特性
- 小红书 **20 个 MCP 工具**(登录、浏览、发布、互动、通知、自动化)
- 小红书 **22 个 MCP 工具**(登录、浏览、发布、互动、通知、自动化)
- 带 Bearer Token 鉴权与按路由限流的 REST API
- 基于 `rebrowser-playwright` 的浏览器自动化,按平台串行队列执行
- 文件型 Cookie 持久化(`0600` 权限、原子写入)
@@ -105,8 +105,10 @@ pnpm test
| `xhs_favorite` | 切换收藏状态 |
| `xhs_get_unprocessed_notifications` | 从本地 SQLite 获取“未处理”通知任务 |
| `xhs_mark_notification_task` | 手动标记通知任务状态(new/pending/ignored/replied/failed |
| `xhs_mark_notification_tasks` | 批量标记通知任务状态 |
| `xhs_list_failed_notification_tasks` | 获取失败通知任务列表(用于排障/重试) |
| `xhs_retry_notification_task` | 按 fingerprint 重试失败通知任务 |
| `xhs_retry_notification_tasks` | 批量重试失败通知任务 |
| `xhs_reply_notification` | 对通知进行回复 |
## REST API
+422 -214
View File
@@ -3,8 +3,12 @@ import type { Router } from 'express';
import type { BrowserManager } from '../../browser/manager.js';
import { config } from '../../config/index.js';
import { withErrorHandling } from '../../utils/errors.js';
import { withErrorHandling, type McpToolResult } from '../../utils/errors.js';
import { resolveMediaInput, cleanupFile } from '../../utils/downloader.js';
import {
getIdempotencyStore,
computeIdempotencyHash,
} from '../../utils/idempotency.js';
import { checkLoginStatus, getLoginQRCode, deleteCookies } from './login.js';
import { listFeeds } from './feeds.js';
import { searchFeeds } from './search.js';
@@ -16,7 +20,11 @@ import { listMyNotes } from './my-notes.js';
import { postComment, replyComment } from './comment.js';
import { toggleLike, toggleFavorite } from './interaction.js';
import { replyNotification } from './notification.js';
import { getNotificationStateStore, type NotificationTaskStatus } from './notification-state.js';
import {
getNotificationStateStore,
type NotificationTask,
type NotificationTaskStatus,
} from './notification-state.js';
import { syncCommentNotifications, xhsNotificationPoller } from './notification-sync.js';
import { createXhsRoutes } from './routes.js';
import {
@@ -38,8 +46,10 @@ import {
ReplyNotificationSchema,
GetUnprocessedNotificationsSchema,
MarkNotificationTaskSchema,
MarkNotificationTasksSchema,
ListFailedNotificationTasksSchema,
RetryNotificationTaskSchema,
RetryNotificationTasksSchema,
} from './schemas.js';
import type { SearchFilters } from './types.js';
import type { PlatformPlugin } from '../../server/app.js';
@@ -56,6 +66,190 @@ 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<string, unknown>;
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<T>(
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 } : {}),
},
},
};
}
async function runWithIdempotency<T>(
toolName: string,
requestId: string | undefined,
inputForHash: unknown,
execute: () => Promise<T>,
): 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
// ---------------------------------------------------------------------------
@@ -104,15 +298,7 @@ export const xiaohongshuPlugin: PlatformPlugin = {
async (page) => checkLoginStatus(page),
timeoutMs,
);
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(status),
},
],
};
return ok(status);
});
},
);
@@ -128,15 +314,7 @@ export const xiaohongshuPlugin: PlatformPlugin = {
async () => {
return withErrorHandling('xhs_get_login_qrcode', async () => {
const result = await getLoginQRCode(browser);
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(result),
},
],
};
return ok(result);
});
},
);
@@ -152,15 +330,7 @@ export const xiaohongshuPlugin: PlatformPlugin = {
async () => {
return withErrorHandling('xhs_delete_cookies', async () => {
await deleteCookies(browser);
return {
content: [
{
type: 'text' as const,
text: JSON.stringify({ success: true, message: 'Cookies deleted' }),
},
],
};
return ok({ message: 'Cookies deleted' });
});
},
);
@@ -177,7 +347,7 @@ export const xiaohongshuPlugin: PlatformPlugin = {
'xhs_list_feeds',
'Get Xiaohongshu explore page recommended feed list',
ListFeedsSchema,
async () => {
async (args) => {
return withErrorHandling('xhs_list_feeds', async () => {
const timeoutMs = config.operationTimeouts['feed_list'] ?? config.operationTimeouts['default'] ?? 60_000;
@@ -186,15 +356,11 @@ export const xiaohongshuPlugin: PlatformPlugin = {
async (page) => listFeeds(page),
timeoutMs,
);
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(feeds),
},
],
};
const pageResult = paginateArray(feeds, {
max_count: args.max_count,
cursor: args.cursor,
});
return ok(pageResult.items, pageResult.meta);
});
},
);
@@ -224,15 +390,11 @@ export const xiaohongshuPlugin: PlatformPlugin = {
async (page) => searchFeeds(page, args.keyword, filters),
timeoutMs,
);
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(feeds),
},
],
};
const pageResult = paginateArray(feeds, {
max_count: args.max_count,
cursor: args.cursor,
});
return ok(pageResult.items, pageResult.meta);
});
},
);
@@ -255,15 +417,7 @@ export const xiaohongshuPlugin: PlatformPlugin = {
getFeedDetail(page, args.feed_id, args.xsec_token),
timeoutMs,
);
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(detail),
},
],
};
return ok(detail);
});
},
);
@@ -289,15 +443,7 @@ export const xiaohongshuPlugin: PlatformPlugin = {
getSubComments(page, args.feed_id, args.xsec_token, args.comment_id, args.max_count),
timeoutMs,
);
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(result),
},
],
};
return ok(result);
});
},
);
@@ -320,15 +466,7 @@ export const xiaohongshuPlugin: PlatformPlugin = {
getUserProfile(page, args.user_id, args.xsec_token),
timeoutMs,
);
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(profile),
},
],
};
return ok(profile);
});
},
);
@@ -345,7 +483,7 @@ export const xiaohongshuPlugin: PlatformPlugin = {
'xhs_list_my_notes',
'List your published notes on Xiaohongshu from the creator center',
ListMyNotesSchema,
async () => {
async (args) => {
return withErrorHandling('xhs_list_my_notes', async () => {
const timeoutMs =
config.operationTimeouts['feed_list'] ??
@@ -357,10 +495,11 @@ export const xiaohongshuPlugin: PlatformPlugin = {
async (page) => listMyNotes(page),
timeoutMs,
);
return {
content: [{ type: 'text' as const, text: JSON.stringify(notes) }],
};
const pageResult = paginateArray(notes, {
max_count: args.max_count,
cursor: args.cursor,
});
return ok(pageResult.items, pageResult.meta);
});
},
);
@@ -379,6 +518,19 @@ export const xiaohongshuPlugin: PlatformPlugin = {
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) {
@@ -392,7 +544,7 @@ export const xiaohongshuPlugin: PlatformPlugin = {
300_000;
try {
const result = await browser.withPage(
return await browser.withPage(
PLATFORM,
async (page) =>
publishImageNote(page, args.title, args.content, validatedPaths, {
@@ -403,15 +555,14 @@ export const xiaohongshuPlugin: PlatformPlugin = {
}),
timeoutMs,
);
return {
content: [{ type: 'text' as const, text: JSON.stringify(result) }],
};
} finally {
for (const r of resolved) {
if (r.temporary) await cleanupFile(r.path).catch(() => undefined);
}
}
},
);
return ok(data, meta);
});
},
);
@@ -426,6 +577,18 @@ export const xiaohongshuPlugin: PlatformPlugin = {
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 });
@@ -435,7 +598,7 @@ export const xiaohongshuPlugin: PlatformPlugin = {
300_000;
try {
const result = await browser.withPage(
return await browser.withPage(
PLATFORM,
async (page) =>
publishVideoNote(page, args.title, args.content, resolvedVideo.path, {
@@ -445,13 +608,12 @@ export const xiaohongshuPlugin: PlatformPlugin = {
}),
timeoutMs,
);
return {
content: [{ type: 'text' as const, text: JSON.stringify(result) }],
};
} finally {
if (resolvedVideo.temporary) await cleanupFile(resolvedVideo.path).catch(() => undefined);
}
},
);
return ok(data, meta);
});
},
);
@@ -481,15 +643,7 @@ export const xiaohongshuPlugin: PlatformPlugin = {
postComment(page, args.feed_id, args.xsec_token, args.content),
timeoutMs,
);
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(result),
},
],
};
return ok(result);
});
},
);
@@ -504,12 +658,23 @@ export const xiaohongshuPlugin: PlatformPlugin = {
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;
const result = await browser.withPage(
return await browser.withPage(
PLATFORM,
async (page) =>
replyComment(
@@ -522,15 +687,9 @@ export const xiaohongshuPlugin: PlatformPlugin = {
),
timeoutMs,
);
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(result),
},
],
};
);
return ok(data, meta);
});
},
);
@@ -556,15 +715,7 @@ export const xiaohongshuPlugin: PlatformPlugin = {
toggleLike(page, args.feed_id, args.xsec_token),
timeoutMs,
);
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(result),
},
],
};
return ok(result);
});
},
);
@@ -590,21 +741,13 @@ export const xiaohongshuPlugin: PlatformPlugin = {
toggleFavorite(page, args.feed_id, args.xsec_token),
timeoutMs,
);
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(result),
},
],
};
return ok(result);
});
},
);
// =====================================================================
// Notifications (5 tools)
// Notifications (7 tools)
// =====================================================================
// -----------------------------------------------------------------------
@@ -628,16 +771,7 @@ export const xiaohongshuPlugin: PlatformPlugin = {
: ['new', 'failed'];
const tasks = getNotificationStateStore().listByStatuses(statuses, args.max_count);
return {
content: [{
type: 'text' as const,
text: JSON.stringify({
tasks,
...(syncResult ? { synced: syncResult } : {}),
}),
}],
};
return ok(tasks, syncResult ? { synced: syncResult } : undefined);
});
},
);
@@ -660,16 +794,56 @@ export const xiaohongshuPlugin: PlatformPlugin = {
store.setStatus(args.fingerprint, args.status, args.note);
const updated = store.getByFingerprint(args.fingerprint);
return ok(updated);
});
},
);
return {
content: [{
type: 'text' as const,
text: JSON.stringify({
// -----------------------------------------------------------------------
// 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: updated,
}),
}],
};
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,
},
});
});
},
);
@@ -685,12 +859,7 @@ export const xiaohongshuPlugin: PlatformPlugin = {
async (args) => {
return withErrorHandling('xhs_list_failed_notification_tasks', async () => {
const tasks = getNotificationStateStore().listByStatuses(['failed'], args.max_count);
return {
content: [{
type: 'text' as const,
text: JSON.stringify({ tasks }),
}],
};
return ok(tasks);
});
},
);
@@ -705,62 +874,83 @@ export const xiaohongshuPlugin: PlatformPlugin = {
RetryNotificationTaskSchema,
async (args) => {
return withErrorHandling('xhs_retry_notification_task', async () => {
const store = getNotificationStateStore();
const task = store.getByFingerprint(args.fingerprint);
if (!task) {
throw new Error(`Notification task not found: ${args.fingerprint}`);
}
if (task.status !== 'failed') {
throw new Error(`Task status must be failed to retry, got: ${task.status}`);
}
const replyContent = args.reply_content ?? task.replyContent;
if (!replyContent) {
throw new Error(
'Retry requires reply_content when task has no stored replyContent',
);
}
store.markPending(args.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(args.fingerprint, replyContent);
} else {
store.markFailed(args.fingerprint, 'Retry reply returned success=false');
}
return {
content: [{
type: 'text' as const,
text: JSON.stringify({
...result,
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);
store.markFailed(args.fingerprint, message);
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);
});
},
);
@@ -775,23 +965,42 @@ export const xiaohongshuPlugin: PlatformPlugin = {
ReplyNotificationSchema,
async (args) => {
return withErrorHandling('xhs_reply_notification', async () => {
const targetFingerprint =
args.fingerprint ??
getNotificationStateStore().findOpenFingerprint(args.user_id, args.comment_content);
if (targetFingerprint) {
getNotificationStateStore().markPending(targetFingerprint);
}
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, args.user_id, args.comment_content, args.reply_content),
replyNotification(
page,
target.userId,
target.commentContent,
args.reply_content,
),
timeoutMs,
);
@@ -807,13 +1016,8 @@ export const xiaohongshuPlugin: PlatformPlugin = {
}
return {
content: [{
type: 'text' as const,
text: JSON.stringify({
...result,
...(targetFingerprint ? { fingerprint: targetFingerprint } : {}),
}),
}],
};
} catch (err) {
if (targetFingerprint) {
@@ -822,6 +1026,10 @@ export const xiaohongshuPlugin: PlatformPlugin = {
}
throw err;
}
},
);
return ok(data, meta);
});
},
);
+33 -8
View File
@@ -683,11 +683,36 @@ export function createXhsRoutes(browser: BrowserManager): Router {
void (async () => {
try {
const body = ReplyNotificationBodySchema.parse(req.body);
const targetFingerprint =
body.fingerprint ??
getNotificationStateStore().findOpenFingerprint(body.user_id, body.comment_content);
const store = getNotificationStateStore();
const target = (() => {
if (body.fingerprint) {
const task = store.getByFingerprint(body.fingerprint);
if (!task) {
throw new Error(`Notification task not found: ${body.fingerprint}`);
}
return {
fingerprint: body.fingerprint,
userId: task.notification.userId,
commentContent: task.notification.content,
};
}
if (!body.user_id || !body.comment_content) {
throw new Error('Either fingerprint or both user_id and comment_content are required');
}
const fp = store.findOpenFingerprint(body.user_id, body.comment_content) ?? undefined;
return {
...(fp ? { fingerprint: fp } : {}),
userId: body.user_id,
commentContent: body.comment_content,
};
})();
const targetFingerprint = target.fingerprint;
if (targetFingerprint) {
getNotificationStateStore().markPending(targetFingerprint);
store.markPending(targetFingerprint);
}
const timeoutMs =
@@ -699,15 +724,15 @@ export function createXhsRoutes(browser: BrowserManager): Router {
const result = await browser.withPage(
PLATFORM,
async (page) =>
replyNotification(page, body.user_id, body.comment_content, body.reply_content),
replyNotification(page, target.userId, target.commentContent, body.reply_content),
timeoutMs,
);
if (targetFingerprint) {
if (result.success) {
getNotificationStateStore().markReplied(targetFingerprint, body.reply_content);
store.markReplied(targetFingerprint, body.reply_content);
} else {
getNotificationStateStore().markFailed(
store.markFailed(
targetFingerprint,
'Reply action returned success=false',
);
@@ -721,7 +746,7 @@ export function createXhsRoutes(browser: BrowserManager): Router {
} catch (err) {
if (targetFingerprint) {
const message = err instanceof Error ? err.message : String(err);
getNotificationStateStore().markFailed(targetFingerprint, message);
store.markFailed(targetFingerprint, message);
}
throw err;
}
+110 -6
View File
@@ -1,7 +1,7 @@
import { z } from 'zod';
// ---------------------------------------------------------------------------
// MCP tool parameter schemas for all 13 Xiaohongshu tools.
// MCP tool parameter schemas for Xiaohongshu tools.
//
// Phase 2 tools (login) have no parameters — their schemas are empty objects.
// Phase 3/4 schemas are defined here so that the full tool surface is
@@ -22,7 +22,20 @@ export const DeleteCookiesSchema = {};
// -- Phase 3: Content browsing (4 tools) -----------------------------------
/** xhs_list_feeds — no parameters. */
export const ListFeedsSchema = {};
export const ListFeedsSchema = {
max_count: z
.number()
.int()
.min(1)
.max(200)
.optional()
.default(20)
.describe('Maximum number of feeds to return per page (1200, default 20)'),
cursor: z
.string()
.optional()
.describe('Pagination cursor returned by previous call'),
};
/** xhs_search */
export const SearchSchema = {
@@ -44,6 +57,18 @@ export const SearchSchema = {
})
.optional()
.describe('Optional search filters'),
max_count: z
.number()
.int()
.min(1)
.max(200)
.optional()
.default(20)
.describe('Maximum number of search results to return per page (1200, default 20)'),
cursor: z
.string()
.optional()
.describe('Pagination cursor returned by previous call'),
};
/** xhs_get_feed_detail */
@@ -77,6 +102,12 @@ export const GetUserProfileSchema = {
/** xhs_publish_image */
export const PublishImageSchema = {
request_id: z
.string()
.min(1)
.max(128)
.optional()
.describe('Optional idempotency key for publish request'),
title: z.string().min(1).max(20, 'Title must be ≤ 20 characters').describe('Note title (max 20 chars)'),
content: z.string().max(1000, 'Content must be ≤ 1000 characters').describe('Note body text (max 1000 chars)'),
images: z
@@ -103,6 +134,12 @@ export const PublishImageSchema = {
/** xhs_publish_video */
export const PublishVideoSchema = {
request_id: z
.string()
.min(1)
.max(128)
.optional()
.describe('Optional idempotency key for publish request'),
title: z.string().min(1).max(20, 'Title must be ≤ 20 characters').describe('Note title (max 20 chars)'),
content: z.string().max(1000, 'Content must be ≤ 1000 characters').describe('Note body text (max 1000 chars)'),
video: z.string().describe('Local file path or HTTP/HTTPS URL for the video'),
@@ -129,6 +166,12 @@ export const PostCommentSchema = {
/** xhs_reply_comment */
export const ReplyCommentSchema = {
request_id: z
.string()
.min(1)
.max(128)
.optional()
.describe('Optional idempotency key for reply request'),
feed_id: z.string().describe('Feed ID'),
xsec_token: z.string().describe('Security token for the feed'),
comment_id: z.string().optional().describe('Comment ID to reply to'),
@@ -142,8 +185,21 @@ export const LikeSchema = {
xsec_token: z.string().describe('Security token for the feed'),
};
/** xhs_list_my_notes — no parameters. */
export const ListMyNotesSchema = {};
/** xhs_list_my_notes */
export const ListMyNotesSchema = {
max_count: z
.number()
.int()
.min(1)
.max(200)
.optional()
.default(20)
.describe('Maximum number of notes to return per page (1200, default 20)'),
cursor: z
.string()
.optional()
.describe('Pagination cursor returned by previous call'),
};
// -- Phase 5: Notifications & automation -----------------------------------
@@ -161,12 +217,18 @@ export const GetCommentNotificationsSchema = {
/** xhs_reply_notification */
export const ReplyNotificationSchema = {
request_id: z
.string()
.min(1)
.max(128)
.optional()
.describe('Optional idempotency key for notification reply'),
fingerprint: z
.string()
.optional()
.describe('Optional notification fingerprint from xhs_get_unprocessed_notifications'),
user_id: z.string().describe('User ID of the comment author (from notification)'),
comment_content: z.string().describe('Original comment content to match the notification'),
user_id: z.string().optional().describe('User ID of the comment author (fallback when fingerprint is absent)'),
comment_content: z.string().optional().describe('Original comment content to match the notification (fallback when fingerprint is absent)'),
reply_content: z.string().min(1).describe('Reply text to send'),
};
@@ -217,6 +279,12 @@ export const ListFailedNotificationTasksSchema = {
/** xhs_retry_notification_task */
export const RetryNotificationTaskSchema = {
request_id: z
.string()
.min(1)
.max(128)
.optional()
.describe('Optional idempotency key for retry request'),
fingerprint: z.string().describe('Notification task fingerprint to retry'),
reply_content: z
.string()
@@ -225,6 +293,42 @@ export const RetryNotificationTaskSchema = {
.describe('Optional override reply text. If omitted, uses stored reply_content from previous attempt.'),
};
/** xhs_mark_notification_tasks */
export const MarkNotificationTasksSchema = {
tasks: z
.array(z.object({
fingerprint: z.string(),
status: z.enum(['new', 'pending', 'ignored', 'replied', 'failed']),
note: z.string().optional(),
}))
.min(1)
.max(100)
.describe('Batch of task status updates (1100 items)'),
};
/** xhs_retry_notification_tasks */
export const RetryNotificationTasksSchema = {
request_id: z
.string()
.min(1)
.max(128)
.optional()
.describe('Optional idempotency key for batch retry request'),
tasks: z
.array(z.object({
fingerprint: z.string(),
reply_content: z.string().min(1).optional(),
}))
.min(1)
.max(100)
.describe('Batch of failed tasks to retry (1100 items)'),
continue_on_error: z
.boolean()
.optional()
.default(true)
.describe('Continue processing remaining tasks after one task fails'),
};
/** xhs_favorite */
export const FavoriteSchema = {
feed_id: z.string().describe('Feed ID to toggle favorite'),
+4 -1
View File
@@ -123,9 +123,12 @@ export async function withErrorHandling(
);
const payload = JSON.stringify({
success: false,
error: {
tool: toolName,
error: category,
code: category,
message: sanitized,
},
});
return {
+115
View File
@@ -0,0 +1,115 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { config } from '../config/index.js';
interface IdempotencyRow {
tool_name: string;
request_id: string;
input_hash: string;
response_json: string;
created_at: number;
}
export interface IdempotencyRecord {
toolName: string;
requestId: string;
inputHash: string;
responseData: unknown;
createdAt: string;
}
const DB_FILENAME = 'idempotency.db';
function canonicalize(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(canonicalize);
}
if (value && typeof value === 'object') {
const input = value as Record<string, unknown>;
const out: Record<string, unknown> = {};
for (const key of Object.keys(input).sort()) {
out[key] = canonicalize(input[key]);
}
return out;
}
return value;
}
export function computeIdempotencyHash(input: unknown): string {
const canonical = canonicalize(input);
return crypto
.createHash('sha256')
.update(JSON.stringify(canonical))
.digest('hex');
}
export class IdempotencyStore {
private readonly db: DatabaseSync;
constructor(baseDir = config.cookieDir, dbFilename = DB_FILENAME) {
fs.mkdirSync(baseDir, { recursive: true, mode: 0o700 });
const dbPath = path.join(baseDir, dbFilename);
this.db = new DatabaseSync(dbPath);
this.db.exec(`
CREATE TABLE IF NOT EXISTS idempotency_requests (
tool_name TEXT NOT NULL,
request_id TEXT NOT NULL,
input_hash TEXT NOT NULL,
response_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (tool_name, request_id)
);
`);
}
get(toolName: string, requestId: string): IdempotencyRecord | null {
const stmt = this.db.prepare(`
SELECT tool_name, request_id, input_hash, response_json, created_at
FROM idempotency_requests
WHERE tool_name = ? AND request_id = ?
LIMIT 1
`);
const row = stmt.get(toolName, requestId) as IdempotencyRow | undefined;
if (!row) return null;
return {
toolName: row.tool_name,
requestId: row.request_id,
inputHash: row.input_hash,
responseData: JSON.parse(row.response_json),
createdAt: new Date(row.created_at).toISOString(),
};
}
put(
toolName: string,
requestId: string,
inputHash: string,
responseData: unknown,
): void {
const stmt = this.db.prepare(`
INSERT OR REPLACE INTO idempotency_requests (
tool_name, request_id, input_hash, response_json, created_at
) VALUES (?, ?, ?, ?, ?)
`);
stmt.run(
toolName,
requestId,
inputHash,
JSON.stringify(responseData),
Date.now(),
);
}
}
let singleton: IdempotencyStore | null = null;
export function getIdempotencyStore(): IdempotencyStore {
if (!singleton) {
singleton = new IdempotencyStore();
}
return singleton;
}
+8 -6
View File
@@ -129,9 +129,10 @@ describe('withErrorHandling', () => {
expect(result.content).toHaveLength(1);
const payload = JSON.parse(result.content[0]!.text);
expect(payload.tool).toBe('publish_post');
expect(payload.error).toBe(ErrorCategory.TIMEOUT);
expect(typeof payload.message).toBe('string');
expect(payload.success).toBe(false);
expect(payload.error.tool).toBe('publish_post');
expect(payload.error.code).toBe(ErrorCategory.TIMEOUT);
expect(typeof payload.error.message).toBe('string');
});
it('wraps non-Error throws into an Error', async () => {
@@ -142,8 +143,9 @@ describe('withErrorHandling', () => {
expect(result.isError).toBe(true);
const payload = JSON.parse(result.content[0]!.text);
expect(payload.tool).toBe('my_tool');
expect(payload.error).toBe(ErrorCategory.INTERNAL);
expect(payload.message).toContain('raw string error');
expect(payload.success).toBe(false);
expect(payload.error.tool).toBe('my_tool');
expect(payload.error.code).toBe(ErrorCategory.INTERNAL);
expect(payload.error.message).toContain('raw string error');
});
});