@kkauto/kkauto-mcp 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,40 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import { basename } from 'node:path';
3
+ const maxUploadBytes = 10 * 1024 * 1024;
4
+ export async function buildPostFormData(payload, mediaFiles) {
5
+ if (Array.isArray(payload.media) && payload.media.length > 0) {
6
+ throw new Error('Use either media URL array or media_files, not both in the same MCP call.');
7
+ }
8
+ if (mediaFiles.length > 15) {
9
+ throw new Error('media_files accepts at most 15 files.');
10
+ }
11
+ const formData = new FormData();
12
+ const payloadWithoutMediaUrls = { ...payload, media: [] };
13
+ formData.set('data', JSON.stringify(payloadWithoutMediaUrls));
14
+ for (const filePath of mediaFiles) {
15
+ const fileStats = await stat(filePath);
16
+ if (!fileStats.isFile()) {
17
+ throw new Error(`media_files entry is not a file: ${filePath}`);
18
+ }
19
+ if (fileStats.size > maxUploadBytes) {
20
+ throw new Error(`media_files entry exceeds 10 MB: ${filePath}`);
21
+ }
22
+ const buffer = await readFile(filePath);
23
+ const blob = new Blob([buffer], { type: mimeTypeForPath(filePath) });
24
+ formData.append('media[]', blob, basename(filePath));
25
+ }
26
+ return formData;
27
+ }
28
+ function mimeTypeForPath(filePath) {
29
+ const lowerPath = filePath.toLowerCase();
30
+ if (lowerPath.endsWith('.jpg') || lowerPath.endsWith('.jpeg')) {
31
+ return 'image/jpeg';
32
+ }
33
+ if (lowerPath.endsWith('.png')) {
34
+ return 'image/png';
35
+ }
36
+ if (lowerPath.endsWith('.gif')) {
37
+ return 'image/gif';
38
+ }
39
+ throw new Error('media_files only supports .jpg, .jpeg, .png, or .gif files.');
40
+ }
@@ -0,0 +1,326 @@
1
+ import { z } from 'zod';
2
+ const positiveInt = z.number().int().positive();
3
+ const nonEmptyString = z.string().min(1);
4
+ const platformType = z.enum(['facebook', 'tiktok', 'youtube', 'pinterest', 'x', 'instagram', 'douyin', 'other']);
5
+ const sourceType = z.enum(['group', 'page', 'user', 'post', 'hashtag', 'trending', 'other']);
6
+ const crawlerStatus = z.enum(['active', 'inactive', 'paused', 'crawled', 'crawling']);
7
+ const fetchMode = z.enum(['once', 'recurring']);
8
+ const orderDirection = z.enum(['ASC', 'DESC']);
9
+ const crawlerOrderBy = z.enum([
10
+ 'id',
11
+ 'source_name',
12
+ 'source_url',
13
+ 'author_name',
14
+ 'author_url',
15
+ 'platform_type',
16
+ 'source_type',
17
+ 'crawl_interval',
18
+ 'status',
19
+ 'fetch_mode',
20
+ 'last_crawled_at',
21
+ 'next_crawled_at',
22
+ 'total_crawled',
23
+ 'created_at',
24
+ 'updated_at',
25
+ ]);
26
+ const relationStatus = z.enum(['active', 'inactive']);
27
+ const accountPermission = z.enum(['viewer', 'editor', 'admin']);
28
+ const jsonRecord = z.record(z.string(), z.unknown());
29
+ const listSchema = {
30
+ page: positiveInt.optional().describe('Page number, starting at 1.'),
31
+ limit: positiveInt.optional().describe('Items per page. Capped by KK_MCP_MAX_LIST_LIMIT.'),
32
+ platform_type: platformType.optional(),
33
+ status: crawlerStatus.optional(),
34
+ source_type: sourceType.optional(),
35
+ fetch_mode: fetchMode.optional(),
36
+ search: nonEmptyString.optional(),
37
+ order_by: crawlerOrderBy.optional(),
38
+ order_direction: orderDirection.optional(),
39
+ };
40
+ const getSchema = {
41
+ id: positiveInt,
42
+ };
43
+ const postsSchema = {
44
+ id: positiveInt,
45
+ page: positiveInt.optional(),
46
+ limit: positiveInt.optional().describe('Items per page. Capped by KK_MCP_MAX_LIST_LIMIT.'),
47
+ };
48
+ const searchSchema = {
49
+ q: z.string().optional(),
50
+ };
51
+ const createSchema = {
52
+ platform_type: platformType,
53
+ source_type: sourceType,
54
+ crawl_interval: z.number().int().min(0),
55
+ source_name: z.string().min(3).max(255).optional(),
56
+ source_url: z.string().url().max(1000).optional(),
57
+ author_name: z.string().max(255).optional(),
58
+ author_url: z.string().url().max(1000).optional(),
59
+ status: crawlerStatus.optional(),
60
+ fetch_mode: fetchMode.optional(),
61
+ source_description: z.string().optional(),
62
+ source_settings: jsonRecord.optional(),
63
+ crawl_filters: z.union([jsonRecord, z.string()]).optional(),
64
+ crawl_keywords: z.union([z.array(z.string().min(1)), z.string()]).optional(),
65
+ };
66
+ const updateSchema = {
67
+ id: positiveInt,
68
+ source_name: z.string().min(3).max(255).optional(),
69
+ source_url: z.string().url().max(1000).optional(),
70
+ author_name: z.string().max(255).optional(),
71
+ author_url: z.string().url().max(1000).optional(),
72
+ platform_type: platformType.optional(),
73
+ source_type: sourceType.optional(),
74
+ crawl_interval: z.number().int().min(0).optional(),
75
+ status: crawlerStatus.optional(),
76
+ fetch_mode: fetchMode.optional(),
77
+ source_description: z.string().optional(),
78
+ source_settings: jsonRecord.optional(),
79
+ crawl_filters: z.union([jsonRecord, z.string()]).optional(),
80
+ crawl_keywords: z.union([z.array(z.string().min(1)), z.string()]).optional(),
81
+ };
82
+ const hashtagSchema = {
83
+ id: positiveInt,
84
+ hashtag_id: positiveInt,
85
+ priority: z.number().int().min(1).max(10).optional(),
86
+ status: relationStatus.optional(),
87
+ };
88
+ const hashtagPrioritySchema = {
89
+ id: positiveInt,
90
+ hashtag_id: positiveInt,
91
+ priority: z.number().int().min(1).max(10),
92
+ };
93
+ const accountSchema = {
94
+ id: positiveInt,
95
+ account_id: positiveInt,
96
+ permission: accountPermission.optional(),
97
+ status: relationStatus.optional(),
98
+ };
99
+ const bulkAccountSchema = {
100
+ id: positiveInt,
101
+ account_ids: z.array(positiveInt).min(1).max(100),
102
+ permission: accountPermission.optional(),
103
+ status: relationStatus.optional(),
104
+ confirm: z.boolean().describe('Must be true because this can link many accounts.'),
105
+ };
106
+ const removeHashtagSchema = {
107
+ id: positiveInt,
108
+ hashtag_id: positiveInt,
109
+ confirm: z.boolean().describe('Must be true to remove a crawler hashtag relation.'),
110
+ reason: nonEmptyString,
111
+ };
112
+ const removeAccountSchema = {
113
+ id: positiveInt,
114
+ account_id: positiveInt,
115
+ confirm: z.boolean().describe('Must be true to remove a crawler account relation.'),
116
+ reason: nonEmptyString,
117
+ };
118
+ const deleteSchema = {
119
+ id: positiveInt,
120
+ confirm: z.boolean().describe('Must be true for deletion.'),
121
+ reason: nonEmptyString.describe('Human-readable reason for audit context.'),
122
+ expected_name: nonEmptyString.optional().describe('Optional source_name guard checked before delete.'),
123
+ };
124
+ export function registerSourceCrawlerTools(server, client, config) {
125
+ server.registerTool('list_source_crawlers', {
126
+ title: 'List source crawlers',
127
+ description: 'List kkAuto API v2 source crawlers with filters and page pagination.',
128
+ inputSchema: listSchema,
129
+ }, async (input) => {
130
+ const response = await client.get('/api/v2/source-crawlers', {
131
+ query: { ...input, limit: limitOrDefault(input.limit, config) },
132
+ });
133
+ return jsonResult(response ?? { status: 'success', data: [], pagination: null });
134
+ });
135
+ server.registerTool('get_source_crawler', {
136
+ title: 'Get source crawler',
137
+ description: 'Get one source crawler by id, including info, hashtag, and account relations.',
138
+ inputSchema: getSchema,
139
+ }, async (input) => jsonResult(await client.get(`/api/v2/source-crawlers/${input.id}`)));
140
+ server.registerTool('list_source_crawler_posts', {
141
+ title: 'List source crawler posts',
142
+ description: 'List source posts attached to one source crawler through kkAuto API v2.',
143
+ inputSchema: postsSchema,
144
+ }, async (input) => {
145
+ const response = await client.get(`/api/v2/source-crawlers/${input.id}/posts`, {
146
+ query: { page: input.page, limit: limitOrDefault(input.limit, config) },
147
+ });
148
+ return jsonResult(response);
149
+ });
150
+ server.registerTool('search_source_crawler_hashtags', {
151
+ title: 'Search source crawler hashtags',
152
+ description: 'Search hashtag options for source crawler relations.',
153
+ inputSchema: searchSchema,
154
+ }, async (input) => jsonResult(await client.get('/api/v2/source-crawlers/search/hashtags', { query: input })));
155
+ server.registerTool('search_source_crawler_accounts', {
156
+ title: 'Search source crawler accounts',
157
+ description: 'Search active FB account options for source crawler relations.',
158
+ inputSchema: searchSchema,
159
+ }, async (input) => jsonResult(await client.get('/api/v2/source-crawlers/search/accounts', { query: input })));
160
+ server.registerTool('create_source_crawler', {
161
+ title: 'Create source crawler',
162
+ description: 'Create a source crawler through kkAuto API v2.',
163
+ inputSchema: createSchema,
164
+ }, async (input) => {
165
+ const response = await client.post('/api/v2/source-crawlers', {
166
+ body: pruneUndefined({ ...input, status: input.status ?? 'paused', fetch_mode: input.fetch_mode ?? 'once' }),
167
+ });
168
+ return jsonResult(response);
169
+ });
170
+ server.registerTool('update_source_crawler', {
171
+ title: 'Update source crawler',
172
+ description: 'Update one source crawler through kkAuto API v2. Fields not supplied are omitted.',
173
+ inputSchema: updateSchema,
174
+ }, async (input) => {
175
+ const { id, ...fields } = input;
176
+ const payload = pruneUndefined(fields);
177
+ if (Object.keys(payload).length === 0) {
178
+ throw new Error('At least one field is required for update_source_crawler');
179
+ }
180
+ return jsonResult(await client.put(`/api/v2/source-crawlers/${id}`, { body: payload }));
181
+ });
182
+ server.registerTool('pause_source_crawler', {
183
+ title: 'Pause source crawler',
184
+ description: 'Pause one source crawler through kkAuto API v2.',
185
+ inputSchema: getSchema,
186
+ }, async (input) => jsonResult(await client.patch(`/api/v2/source-crawlers/${input.id}/pause`)));
187
+ server.registerTool('resume_source_crawler', {
188
+ title: 'Resume source crawler',
189
+ description: 'Resume one source crawler through kkAuto API v2. Backend requires linked accounts.',
190
+ inputSchema: getSchema,
191
+ }, async (input) => jsonResult(await client.patch(`/api/v2/source-crawlers/${input.id}/resume`)));
192
+ server.registerTool('list_source_crawler_hashtags', {
193
+ title: 'List source crawler hashtags',
194
+ description: 'List hashtag relations for one source crawler.',
195
+ inputSchema: getSchema,
196
+ }, async (input) => jsonResult(await client.get(`/api/v2/source-crawlers/${input.id}/hashtags`)));
197
+ server.registerTool('add_source_crawler_hashtag', {
198
+ title: 'Add source crawler hashtag',
199
+ description: 'Add a hashtag relation to one source crawler.',
200
+ inputSchema: hashtagSchema,
201
+ }, async (input) => {
202
+ const { id, ...body } = input;
203
+ return jsonResult(await client.post(`/api/v2/source-crawlers/${id}/hashtags`, { body: pruneUndefined(body) }));
204
+ });
205
+ server.registerTool('update_source_crawler_hashtag_priority', {
206
+ title: 'Update source crawler hashtag priority',
207
+ description: 'Update a source crawler hashtag relation priority, from 1 to 10.',
208
+ inputSchema: hashtagPrioritySchema,
209
+ }, async (input) => {
210
+ const response = await client.patch(`/api/v2/source-crawlers/${input.id}/hashtags/${input.hashtag_id}/priority`, {
211
+ body: { priority: input.priority },
212
+ });
213
+ return jsonResult(response);
214
+ });
215
+ server.registerTool('remove_source_crawler_hashtag', {
216
+ title: 'Remove source crawler hashtag',
217
+ description: 'Remove a hashtag relation from one source crawler. Disabled unless KK_MCP_ENABLE_DELETE=true.',
218
+ inputSchema: removeHashtagSchema,
219
+ }, async (input) => {
220
+ requireDeleteEnabled(config, 'remove_source_crawler_hashtag');
221
+ requireConfirm(input.confirm, 'remove_source_crawler_hashtag');
222
+ const preflight = await client.get(`/api/v2/source-crawlers/${input.id}`);
223
+ const response = await client.delete(`/api/v2/source-crawlers/${input.id}/hashtags/${input.hashtag_id}`);
224
+ return jsonResult({
225
+ status: 'success',
226
+ source_crawler_id: input.id,
227
+ source_name: extractSourceName(preflight),
228
+ hashtag_id: input.hashtag_id,
229
+ reason: input.reason,
230
+ api_response: response,
231
+ });
232
+ });
233
+ server.registerTool('list_source_crawler_accounts', {
234
+ title: 'List source crawler accounts',
235
+ description: 'List account relations for one source crawler.',
236
+ inputSchema: getSchema,
237
+ }, async (input) => jsonResult(await client.get(`/api/v2/source-crawlers/${input.id}/accounts`)));
238
+ server.registerTool('add_source_crawler_account', {
239
+ title: 'Add source crawler account',
240
+ description: 'Add one account relation to a source crawler.',
241
+ inputSchema: accountSchema,
242
+ }, async (input) => {
243
+ const { id, ...body } = input;
244
+ return jsonResult(await client.post(`/api/v2/source-crawlers/${id}/accounts`, { body: pruneUndefined(body) }));
245
+ });
246
+ server.registerTool('add_source_crawler_accounts_bulk', {
247
+ title: 'Add source crawler accounts bulk',
248
+ description: 'Add up to 100 account relations to one source crawler. Requires confirm=true.',
249
+ inputSchema: bulkAccountSchema,
250
+ }, async (input) => {
251
+ if (!input.confirm) {
252
+ throw new Error('add_source_crawler_accounts_bulk requires confirm=true');
253
+ }
254
+ const { id, confirm: _confirm, ...body } = input;
255
+ return jsonResult(await client.post(`/api/v2/source-crawlers/${id}/accounts/bulk`, { body: pruneUndefined(body) }));
256
+ });
257
+ server.registerTool('remove_source_crawler_account', {
258
+ title: 'Remove source crawler account',
259
+ description: 'Remove an account relation from one source crawler. Disabled unless KK_MCP_ENABLE_DELETE=true.',
260
+ inputSchema: removeAccountSchema,
261
+ }, async (input) => {
262
+ requireDeleteEnabled(config, 'remove_source_crawler_account');
263
+ requireConfirm(input.confirm, 'remove_source_crawler_account');
264
+ const preflight = await client.get(`/api/v2/source-crawlers/${input.id}`);
265
+ const response = await client.delete(`/api/v2/source-crawlers/${input.id}/accounts/${input.account_id}`);
266
+ return jsonResult({
267
+ status: 'success',
268
+ source_crawler_id: input.id,
269
+ source_name: extractSourceName(preflight),
270
+ account_id: input.account_id,
271
+ reason: input.reason,
272
+ api_response: response,
273
+ });
274
+ });
275
+ server.registerTool('delete_source_crawler', {
276
+ title: 'Delete source crawler',
277
+ description: 'Delete a source crawler and its relations through kkAuto API v2. Disabled unless KK_MCP_ENABLE_DELETE=true.',
278
+ inputSchema: deleteSchema,
279
+ }, async (input) => {
280
+ requireDeleteEnabled(config, 'delete_source_crawler');
281
+ requireConfirm(input.confirm, 'delete_source_crawler');
282
+ const preflight = await client.get(`/api/v2/source-crawlers/${input.id}`);
283
+ const sourceName = extractSourceName(preflight);
284
+ if (input.expected_name !== undefined && input.expected_name !== sourceName) {
285
+ throw new Error('expected_name did not match the current source crawler name; delete aborted');
286
+ }
287
+ const response = await client.delete(`/api/v2/source-crawlers/${input.id}`);
288
+ return jsonResult({
289
+ status: 'success',
290
+ deleted_id: input.id,
291
+ deleted_name: sourceName,
292
+ reason: input.reason,
293
+ api_response: response,
294
+ });
295
+ });
296
+ }
297
+ function jsonResult(value) {
298
+ return {
299
+ content: [
300
+ {
301
+ type: 'text',
302
+ text: JSON.stringify(value, null, 2),
303
+ },
304
+ ],
305
+ };
306
+ }
307
+ function pruneUndefined(value) {
308
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
309
+ }
310
+ function limitOrDefault(value, config) {
311
+ return value ? Math.min(value, config.maxListLimit) : config.maxListLimit;
312
+ }
313
+ function requireDeleteEnabled(config, toolName) {
314
+ if (!config.enableDelete) {
315
+ throw new Error(`${toolName} is disabled. Set KK_MCP_ENABLE_DELETE=true to enable it.`);
316
+ }
317
+ }
318
+ function requireConfirm(confirmed, toolName) {
319
+ if (!confirmed) {
320
+ throw new Error(`${toolName} requires confirm=true`);
321
+ }
322
+ }
323
+ function extractSourceName(response) {
324
+ const crawler = response?.data?.crawler;
325
+ return crawler && typeof crawler.source_name === 'string' ? crawler.source_name : undefined;
326
+ }
@@ -0,0 +1,281 @@
1
+ import { z } from 'zod';
2
+ const positiveInt = z.number().int().positive();
3
+ const nonEmptyString = z.string().min(1);
4
+ const sourcePlatform = z.enum([
5
+ 'facebook',
6
+ 'instagram',
7
+ 'twitter',
8
+ 'youtube',
9
+ 'tiktok',
10
+ 'reddit',
11
+ 'pinterest',
12
+ 'website',
13
+ 'linkedin',
14
+ 'telegram',
15
+ 'discord',
16
+ 'other',
17
+ ]);
18
+ const workflowStatus = z.enum(['pending', 'approved', 'rejected', 'waiting_confirmation']);
19
+ const sourceFormat = z.enum(['feed_post', 'reel', 'story', 'short', 'live', 'carousel', 'article', 'unknown', 'other']);
20
+ const topicType = z.enum(['normal', 'tutorial', 'share', 'review', 'comparison', 'introduction', 'promotion', 'news', 'event']);
21
+ const lengthType = z.enum(['short', 'medium', 'long']);
22
+ const contentShape = z.enum(['text_only', 'image_only', 'video_only', 'text_image', 'text_video', 'image_video', 'text_image_video']);
23
+ const orderDirection = z.enum(['ASC', 'DESC']);
24
+ const orderBy = z.enum(['published_at', 'id', 'created_at', 'updated_at', 'quality_score', 'popularity_score']);
25
+ const mediaPayload = z.object({
26
+ media_type: z.enum(['image', 'video']).default('image'),
27
+ media_url: z.string().url(),
28
+ });
29
+ const listSchema = {
30
+ source_platform: sourcePlatform.optional(),
31
+ workflow_status: workflowStatus.optional().describe('Canonical lifecycle status. Use status only for API compatibility checks.'),
32
+ status: workflowStatus.optional().describe('Deprecated API alias for workflow_status.'),
33
+ source_format: sourceFormat.optional(),
34
+ topic_type: topicType.optional(),
35
+ content_shape: contentShape.optional(),
36
+ length_type: lengthType.optional(),
37
+ post_type: lengthType.optional().describe('Deprecated API alias for length_type.'),
38
+ hashtag: nonEmptyString.optional(),
39
+ date_from: nonEmptyString.optional(),
40
+ date_to: nonEmptyString.optional(),
41
+ search: nonEmptyString.optional(),
42
+ order_by: orderBy.optional(),
43
+ order_direction: orderDirection.optional(),
44
+ limit: positiveInt.optional().describe('Result limit. Capped by KK_MCP_MAX_LIST_LIMIT.'),
45
+ offset: z.number().int().min(0).optional(),
46
+ };
47
+ const getSchema = {
48
+ id: positiveInt,
49
+ };
50
+ const createSchema = {
51
+ title: z.string().min(3).max(500),
52
+ content: nonEmptyString,
53
+ source_platform: sourcePlatform,
54
+ source_channel: z.string().min(1).max(255),
55
+ source_author: z.string().min(1).max(255),
56
+ source_url: z.string().url().max(1000),
57
+ source_post_id: z.string().min(1).max(255),
58
+ published_at: nonEmptyString.describe('Publish timestamp accepted by kkAuto API valid_date, e.g. 2026-05-26 10:30:00.'),
59
+ source_format: sourceFormat.optional(),
60
+ workflow_status: workflowStatus.optional(),
61
+ status: workflowStatus.optional().describe('Deprecated API alias for workflow_status.'),
62
+ topic_type: topicType.optional(),
63
+ length_type: lengthType.optional(),
64
+ post_type: lengthType.optional().describe('Deprecated API alias for length_type.'),
65
+ source_crawler_id: positiveInt.optional(),
66
+ media: z.array(mediaPayload).max(50).optional(),
67
+ hashtags: z.union([z.string(), z.array(z.string().min(1))]).optional(),
68
+ comments: z.array(z.record(z.string(), z.unknown())).max(200).optional(),
69
+ analytics: z.record(z.string(), z.unknown()).optional(),
70
+ };
71
+ const updateSchema = {
72
+ id: positiveInt,
73
+ title: z.string().min(3).max(500).optional(),
74
+ content: nonEmptyString.optional(),
75
+ source_platform: sourcePlatform.optional(),
76
+ source_channel: z.string().min(1).max(255).optional(),
77
+ source_author: z.string().min(1).max(255).optional(),
78
+ source_url: z.string().url().max(1000).optional(),
79
+ source_format: sourceFormat.optional(),
80
+ workflow_status: workflowStatus.optional(),
81
+ status: workflowStatus.optional().describe('Deprecated API alias for workflow_status.'),
82
+ topic_type: topicType.optional(),
83
+ length_type: lengthType.optional(),
84
+ post_type: lengthType.optional().describe('Deprecated API alias for length_type.'),
85
+ media: z.array(mediaPayload).max(50).optional().describe('Only send when replacing media rows.'),
86
+ };
87
+ const searchSchema = {
88
+ q: nonEmptyString,
89
+ workflow_status: workflowStatus.optional(),
90
+ status: workflowStatus.optional().describe('Deprecated API alias for workflow_status.'),
91
+ limit: positiveInt.optional().describe('Result limit. Capped by KK_MCP_MAX_LIST_LIMIT.'),
92
+ offset: z.number().int().min(0).optional(),
93
+ };
94
+ const byPlatformSchema = {
95
+ platform: sourcePlatform,
96
+ workflow_status: workflowStatus.optional(),
97
+ status: workflowStatus.optional().describe('Deprecated API alias for workflow_status.'),
98
+ limit: positiveInt.optional().describe('Result limit. Capped by KK_MCP_MAX_LIST_LIMIT.'),
99
+ offset: z.number().int().min(0).optional(),
100
+ };
101
+ const byHashtagSchema = {
102
+ hashtag: nonEmptyString,
103
+ workflow_status: workflowStatus.optional(),
104
+ status: workflowStatus.optional().describe('Deprecated API alias for workflow_status.'),
105
+ limit: positiveInt.optional().describe('Result limit. Capped by KK_MCP_MAX_LIST_LIMIT.'),
106
+ offset: z.number().int().min(0).optional(),
107
+ };
108
+ const statusSchema = {
109
+ id: positiveInt,
110
+ workflow_status: workflowStatus.optional(),
111
+ status: workflowStatus.optional().describe('Deprecated API alias for workflow_status.'),
112
+ notes: z.string().optional(),
113
+ };
114
+ const deleteSchema = {
115
+ id: positiveInt,
116
+ confirm: z.boolean().describe('Must be true for deletion.'),
117
+ reason: nonEmptyString.describe('Human-readable reason for audit context.'),
118
+ expected_title: nonEmptyString.optional().describe('Optional title guard checked before delete.'),
119
+ };
120
+ export function registerSourcePostTools(server, client, config) {
121
+ server.registerTool('list_source_posts', {
122
+ title: 'List source posts',
123
+ description: 'List kkAuto API v2 source posts with filters and offset pagination.',
124
+ inputSchema: listSchema,
125
+ }, async (input) => {
126
+ const response = await client.get('/api/v2/source-posts', {
127
+ query: { ...input, limit: limitOrDefault(input.limit, config) },
128
+ });
129
+ return jsonResult(response ?? { success: true, data: [], total: 0, filters: {} });
130
+ });
131
+ server.registerTool('get_source_post', {
132
+ title: 'Get source post',
133
+ description: 'Get one kkAuto API v2 source post by id, including API-provided details.',
134
+ inputSchema: getSchema,
135
+ }, async (input) => jsonResult(await client.get(`/api/v2/source-posts/${input.id}`)));
136
+ server.registerTool('search_source_posts', {
137
+ title: 'Search source posts',
138
+ description: 'Search source posts by keyword through kkAuto API v2.',
139
+ inputSchema: searchSchema,
140
+ }, async (input) => {
141
+ const response = await client.get('/api/v2/source-posts/search', {
142
+ query: { ...input, limit: limitOrDefault(input.limit, config) },
143
+ });
144
+ return jsonResult(response);
145
+ });
146
+ server.registerTool('list_source_posts_by_platform', {
147
+ title: 'List source posts by platform',
148
+ description: 'List source posts for one platform through kkAuto API v2.',
149
+ inputSchema: byPlatformSchema,
150
+ }, async (input) => {
151
+ const { platform, ...query } = input;
152
+ const response = await client.get(`/api/v2/source-posts/by-platform/${platform}`, {
153
+ query: { ...query, limit: limitOrDefault(input.limit, config) },
154
+ });
155
+ return jsonResult(response);
156
+ });
157
+ server.registerTool('list_source_posts_by_hashtag', {
158
+ title: 'List source posts by hashtag',
159
+ description: 'List source posts for one hashtag through kkAuto API v2.',
160
+ inputSchema: byHashtagSchema,
161
+ }, async (input) => {
162
+ const { hashtag, ...query } = input;
163
+ const response = await client.get(`/api/v2/source-posts/by-hashtag/${encodeURIComponent(hashtag)}`, {
164
+ query: { ...query, limit: limitOrDefault(input.limit, config) },
165
+ });
166
+ return jsonResult(response);
167
+ });
168
+ server.registerTool('get_source_post_statistics', {
169
+ title: 'Get source post statistics',
170
+ description: 'Get kkAuto API v2 aggregate statistics for source posts.',
171
+ inputSchema: {},
172
+ }, async () => jsonResult(await client.get('/api/v2/source-posts/statistics')));
173
+ server.registerTool('create_source_post', {
174
+ title: 'Create source post',
175
+ description: 'Create a source post through kkAuto API v2. Uses remote media URLs only.',
176
+ inputSchema: createSchema,
177
+ }, async (input) => {
178
+ const payload = pruneUndefined({
179
+ ...input,
180
+ source_format: input.source_format ?? 'unknown',
181
+ workflow_status: input.workflow_status ?? input.status ?? 'pending',
182
+ topic_type: input.topic_type ?? 'normal',
183
+ length_type: input.length_type ?? input.post_type ?? 'short',
184
+ hashtags: normalizeHashtags(input.hashtags),
185
+ });
186
+ const response = await client.post('/api/v2/source-posts', { body: payload });
187
+ return jsonResult(response);
188
+ });
189
+ server.registerTool('update_source_post', {
190
+ title: 'Update source post',
191
+ description: 'Update one source post through kkAuto API v2. Fields not supplied are omitted.',
192
+ inputSchema: updateSchema,
193
+ }, async (input) => {
194
+ const { id, ...fields } = input;
195
+ const payload = pruneUndefined({
196
+ ...fields,
197
+ workflow_status: fields.workflow_status ?? fields.status,
198
+ length_type: fields.length_type ?? fields.post_type,
199
+ });
200
+ if (Object.keys(payload).length === 0) {
201
+ throw new Error('At least one field is required for update_source_post');
202
+ }
203
+ const response = await client.put(`/api/v2/source-posts/${id}`, { body: payload });
204
+ return jsonResult(response);
205
+ });
206
+ server.registerTool('update_source_post_status', {
207
+ title: 'Update source post status',
208
+ description: 'Update one source post workflow status through kkAuto API v2.',
209
+ inputSchema: statusSchema,
210
+ }, async (input) => {
211
+ const status = input.workflow_status ?? input.status;
212
+ if (status === undefined) {
213
+ throw new Error('update_source_post_status requires workflow_status or status');
214
+ }
215
+ const response = await client.patch(`/api/v2/source-posts/${input.id}/status`, {
216
+ body: pruneUndefined({ workflow_status: status, notes: input.notes }),
217
+ });
218
+ return jsonResult(response);
219
+ });
220
+ server.registerTool('delete_source_post', {
221
+ title: 'Delete source post',
222
+ description: 'Delete a source post through kkAuto API v2. Disabled unless KK_MCP_ENABLE_DELETE=true.',
223
+ inputSchema: deleteSchema,
224
+ }, async (input) => {
225
+ if (!config.enableDelete) {
226
+ throw new Error('delete_source_post is disabled. Set KK_MCP_ENABLE_DELETE=true to enable it.');
227
+ }
228
+ if (!input.confirm) {
229
+ throw new Error('delete_source_post requires confirm=true');
230
+ }
231
+ const preflight = await client.get(`/api/v2/source-posts/${input.id}`);
232
+ const title = extractTitle(preflight);
233
+ if (input.expected_title !== undefined && input.expected_title !== title) {
234
+ throw new Error('expected_title did not match the current source post title; delete aborted');
235
+ }
236
+ const response = await client.delete(`/api/v2/source-posts/${input.id}`);
237
+ return jsonResult({
238
+ status: 'success',
239
+ deleted_id: input.id,
240
+ deleted_title: title,
241
+ reason: input.reason,
242
+ api_response: response,
243
+ });
244
+ });
245
+ }
246
+ function jsonResult(value) {
247
+ return {
248
+ content: [
249
+ {
250
+ type: 'text',
251
+ text: JSON.stringify(value, null, 2),
252
+ },
253
+ ],
254
+ };
255
+ }
256
+ function pruneUndefined(value) {
257
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
258
+ }
259
+ function limitOrDefault(value, config) {
260
+ return value ? Math.min(value, config.maxListLimit) : config.maxListLimit;
261
+ }
262
+ function normalizeHashtags(value) {
263
+ if (value === undefined) {
264
+ return undefined;
265
+ }
266
+ if (Array.isArray(value)) {
267
+ return value;
268
+ }
269
+ const trimmed = value.trim();
270
+ if (trimmed === '') {
271
+ return [];
272
+ }
273
+ return trimmed
274
+ .split(/[\s,]+/)
275
+ .map((entry) => entry.trim().replace(/^#+/, ''))
276
+ .filter((entry) => entry !== '');
277
+ }
278
+ function extractTitle(response) {
279
+ const data = response?.data;
280
+ return data && typeof data.title === 'string' ? data.title : undefined;
281
+ }