@bulkpublishing/mcp-server 1.0.0

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.
Files changed (54) hide show
  1. package/README.md +170 -0
  2. package/dist/ai/engine.d.ts +12 -0
  3. package/dist/ai/engine.d.ts.map +1 -0
  4. package/dist/ai/engine.js +397 -0
  5. package/dist/ai/engine.js.map +1 -0
  6. package/dist/ai/prompts.d.ts +30 -0
  7. package/dist/ai/prompts.d.ts.map +1 -0
  8. package/dist/ai/prompts.js +207 -0
  9. package/dist/ai/prompts.js.map +1 -0
  10. package/dist/auth/context.d.ts +81 -0
  11. package/dist/auth/context.d.ts.map +1 -0
  12. package/dist/auth/context.js +68 -0
  13. package/dist/auth/context.js.map +1 -0
  14. package/dist/auth/validate.d.ts +13 -0
  15. package/dist/auth/validate.d.ts.map +1 -0
  16. package/dist/auth/validate.js +87 -0
  17. package/dist/auth/validate.js.map +1 -0
  18. package/dist/index.d.ts +22 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +78 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/tools/csv.d.ts +18 -0
  23. package/dist/tools/csv.d.ts.map +1 -0
  24. package/dist/tools/csv.js +673 -0
  25. package/dist/tools/csv.js.map +1 -0
  26. package/dist/tools/generation.d.ts +14 -0
  27. package/dist/tools/generation.d.ts.map +1 -0
  28. package/dist/tools/generation.js +291 -0
  29. package/dist/tools/generation.js.map +1 -0
  30. package/dist/tools/indexing.d.ts +11 -0
  31. package/dist/tools/indexing.d.ts.map +1 -0
  32. package/dist/tools/indexing.js +219 -0
  33. package/dist/tools/indexing.js.map +1 -0
  34. package/dist/tools/projects.d.ts +11 -0
  35. package/dist/tools/projects.d.ts.map +1 -0
  36. package/dist/tools/projects.js +181 -0
  37. package/dist/tools/projects.js.map +1 -0
  38. package/dist/tools/research.d.ts +12 -0
  39. package/dist/tools/research.d.ts.map +1 -0
  40. package/dist/tools/research.js +88 -0
  41. package/dist/tools/research.js.map +1 -0
  42. package/dist/tools/seo.d.ts +12 -0
  43. package/dist/tools/seo.d.ts.map +1 -0
  44. package/dist/tools/seo.js +164 -0
  45. package/dist/tools/seo.js.map +1 -0
  46. package/dist/tools/wordpress.d.ts +15 -0
  47. package/dist/tools/wordpress.d.ts.map +1 -0
  48. package/dist/tools/wordpress.js +447 -0
  49. package/dist/tools/wordpress.js.map +1 -0
  50. package/dist/types.d.ts +206 -0
  51. package/dist/types.d.ts.map +1 -0
  52. package/dist/types.js +9 -0
  53. package/dist/types.js.map +1 -0
  54. package/package.json +53 -0
@@ -0,0 +1,673 @@
1
+ /**
2
+ * BPAI MCP Server — CSV Import / Export Tools
3
+ *
4
+ * MCP tool handlers for:
5
+ * - import_csv: Read a local CSV and auto-detect columns
6
+ * - export_csv: Write articles to a CSV file
7
+ * - export_markdown: Write articles as individual .md files
8
+ * - export_json: Write articles as structured JSON
9
+ * - export_bulk: Combined multi-format export with filtering
10
+ *
11
+ * Security:
12
+ * - All operations run on the user's local machine (filesystem only)
13
+ * - No database or remote calls; articles are provided inline by the MCP client
14
+ * - Auth check on every tool call via getUserContext()
15
+ */
16
+ import { z } from 'zod';
17
+ import * as fs from 'fs/promises';
18
+ import * as path from 'path';
19
+ import { getUserContext } from '../auth/context.js';
20
+ // ============================================
21
+ // Column Auto-Detection
22
+ // ============================================
23
+ /** Canonical field → list of recognized CSV header variations (all lowercase) */
24
+ const COLUMN_ALIASES = {
25
+ keyword: ['keyword', 'target_keyword', 'focus_keyword', 'kw', 'primary_keyword', 'search_term', 'query', 'main_keyword', 'keyphrase', 'focus_keyphrase'],
26
+ title: ['title', 'post_title', 'h1', 'headline', 'article_title', 'page_title', 'heading'],
27
+ meta_description: ['meta', 'meta_description', 'meta_desc', 'description', 'seo_description', 'metadescription'],
28
+ slug: ['slug', 'url_slug', 'permalink', 'url', 'path', 'handle', 'url_handle'],
29
+ category: ['category', 'cat', 'section', 'topic', 'group', 'categories'],
30
+ tone: ['tone', 'voice', 'style', 'writing_style', 'writing_tone'],
31
+ word_count: ['word_count', 'length', 'target_length', 'words', 'target_words', 'wordcount'],
32
+ instructions: ['instructions', 'notes', 'prompt_override', 'custom_prompt', 'additional_context', 'context', 'brief'],
33
+ tags: ['tags', 'keywords', 'labels', 'secondary_keywords'],
34
+ content: ['content', 'body', 'article_content', 'body_html', 'html', 'markdown', 'article_body', 'post_content'],
35
+ status: ['status', 'state', 'publish_status'],
36
+ author: ['author', 'author_name', 'writer'],
37
+ date: ['date', 'publish_date', 'created_at', 'created', 'published_date'],
38
+ };
39
+ function detectColumns(headers) {
40
+ const mapping = {};
41
+ const normalised = headers.map(h => h.trim().toLowerCase().replace(/[\s-]+/g, '_'));
42
+ for (const [field, aliases] of Object.entries(COLUMN_ALIASES)) {
43
+ for (let i = 0; i < normalised.length; i++) {
44
+ if (aliases.includes(normalised[i])) {
45
+ mapping[field] = headers[i].trim();
46
+ break;
47
+ }
48
+ }
49
+ }
50
+ return mapping;
51
+ }
52
+ // ============================================
53
+ // CSV Parsing Helpers
54
+ // ============================================
55
+ function parseCSVLine(line, delimiter) {
56
+ const fields = [];
57
+ let current = '';
58
+ let inQuotes = false;
59
+ for (let i = 0; i < line.length; i++) {
60
+ const char = line[i];
61
+ if (inQuotes) {
62
+ if (char === '"') {
63
+ if (i + 1 < line.length && line[i + 1] === '"') {
64
+ current += '"';
65
+ i++;
66
+ }
67
+ else {
68
+ inQuotes = false;
69
+ }
70
+ }
71
+ else {
72
+ current += char;
73
+ }
74
+ }
75
+ else {
76
+ if (char === '"') {
77
+ inQuotes = true;
78
+ }
79
+ else if (char === delimiter) {
80
+ fields.push(current);
81
+ current = '';
82
+ }
83
+ else {
84
+ current += char;
85
+ }
86
+ }
87
+ }
88
+ fields.push(current);
89
+ return fields;
90
+ }
91
+ function parseCSV(raw, delimiter, skipRows) {
92
+ const lines = raw.split(/\r?\n/).filter(l => l.trim().length > 0);
93
+ const dataLines = lines.slice(skipRows);
94
+ if (dataLines.length === 0)
95
+ return { headers: [], rows: [] };
96
+ const headers = parseCSVLine(dataLines[0], delimiter);
97
+ const rows = [];
98
+ for (let i = 1; i < dataLines.length; i++) {
99
+ const values = parseCSVLine(dataLines[i], delimiter);
100
+ const row = {};
101
+ for (let j = 0; j < headers.length; j++) {
102
+ row[headers[j].trim()] = (values[j] || '').trim();
103
+ }
104
+ rows.push(row);
105
+ }
106
+ return { headers, rows };
107
+ }
108
+ // ============================================
109
+ // CSV Writing Helpers
110
+ // ============================================
111
+ function escapeCSVField(value) {
112
+ if (value.includes('"') || value.includes(',') || value.includes('\n') || value.includes('\r')) {
113
+ return `"${value.replace(/"/g, '""')}"`;
114
+ }
115
+ return value;
116
+ }
117
+ // ============================================
118
+ // Shared Article Schema (for export tools)
119
+ // ============================================
120
+ const articleSchema = z.object({
121
+ title: z.string().describe('Article title'),
122
+ slug: z.string().optional().describe('URL slug'),
123
+ keyword: z.string().optional().describe('Target keyword'),
124
+ meta_description: z.string().optional().describe('Meta description'),
125
+ content: z.string().describe('Article content (Markdown or HTML)'),
126
+ word_count: z.number().optional().describe('Word count'),
127
+ model: z.string().optional().describe('AI model used'),
128
+ status: z.string().optional().default('draft').describe('draft, published, etc.'),
129
+ category: z.string().optional().describe('Category name'),
130
+ tags: z.string().optional().describe('Comma-separated tags'),
131
+ author: z.string().optional().describe('Author name'),
132
+ created_at: z.string().optional().describe('Creation date ISO string'),
133
+ research_citations: z.array(z.string()).optional().describe('Source URLs from research'),
134
+ faq_schema: z.string().optional().describe('FAQ structured data'),
135
+ });
136
+ // ============================================
137
+ // Filtering Helper
138
+ // ============================================
139
+ function filterArticles(articles, filters) {
140
+ let result = [...articles];
141
+ if (filters.filter_status && filters.filter_status !== 'all') {
142
+ result = result.filter(a => (a.status || 'draft') === filters.filter_status);
143
+ }
144
+ if (filters.filter_model) {
145
+ result = result.filter(a => a.model && a.model.toLowerCase().includes(filters.filter_model.toLowerCase()));
146
+ }
147
+ if (filters.filter_date_from) {
148
+ const from = new Date(filters.filter_date_from).getTime();
149
+ result = result.filter(a => a.created_at && new Date(a.created_at).getTime() >= from);
150
+ }
151
+ if (filters.filter_date_to) {
152
+ const to = new Date(filters.filter_date_to).getTime();
153
+ result = result.filter(a => a.created_at && new Date(a.created_at).getTime() <= to);
154
+ }
155
+ if (filters.filter_min_words) {
156
+ result = result.filter(a => (a.word_count || 0) >= filters.filter_min_words);
157
+ }
158
+ return result;
159
+ }
160
+ // ============================================
161
+ // Export Generators
162
+ // ============================================
163
+ function generateCSVContent(articles, columns, includeHtml) {
164
+ const defaultCols = ['title', 'slug', 'keyword', 'meta_description', 'word_count', 'model', 'status', 'category', 'tags', 'content'];
165
+ const activeCols = columns && columns.length > 0 ? columns : defaultCols;
166
+ const headerRow = activeCols.map(c => escapeCSVField(c)).join(',');
167
+ const dataRows = articles.map(article => {
168
+ return activeCols.map(col => {
169
+ let val = '';
170
+ switch (col) {
171
+ case 'title':
172
+ val = article.title || '';
173
+ break;
174
+ case 'slug':
175
+ val = article.slug || '';
176
+ break;
177
+ case 'keyword':
178
+ val = article.keyword || '';
179
+ break;
180
+ case 'meta_description':
181
+ val = article.meta_description || '';
182
+ break;
183
+ case 'content':
184
+ val = article.content || '';
185
+ break;
186
+ case 'content_html':
187
+ val = includeHtml ? (article.content || '') : '';
188
+ break;
189
+ case 'word_count':
190
+ val = String(article.word_count || '');
191
+ break;
192
+ case 'model':
193
+ val = article.model || '';
194
+ break;
195
+ case 'status':
196
+ val = article.status || 'draft';
197
+ break;
198
+ case 'category':
199
+ val = article.category || '';
200
+ break;
201
+ case 'tags':
202
+ val = article.tags || '';
203
+ break;
204
+ case 'author':
205
+ val = article.author || '';
206
+ break;
207
+ case 'created_at':
208
+ val = article.created_at || '';
209
+ break;
210
+ case 'citations':
211
+ val = (article.research_citations || []).join(' | ');
212
+ break;
213
+ case 'faq_schema':
214
+ val = article.faq_schema || '';
215
+ break;
216
+ default:
217
+ val = article[col] || '';
218
+ break;
219
+ }
220
+ return escapeCSVField(val);
221
+ }).join(',');
222
+ });
223
+ return [headerRow, ...dataRows].join('\n');
224
+ }
225
+ function generateMarkdownFile(article, projectName) {
226
+ const frontmatter = {
227
+ title: article.title,
228
+ };
229
+ if (article.slug)
230
+ frontmatter.slug = article.slug;
231
+ if (article.keyword)
232
+ frontmatter.keyword = article.keyword;
233
+ if (article.meta_description)
234
+ frontmatter.meta_description = article.meta_description;
235
+ if (article.created_at)
236
+ frontmatter.date = article.created_at.split('T')[0];
237
+ if (article.author)
238
+ frontmatter.author = article.author;
239
+ if (projectName)
240
+ frontmatter.project = projectName;
241
+ if (article.word_count)
242
+ frontmatter.word_count = article.word_count;
243
+ if (article.model)
244
+ frontmatter.model = article.model;
245
+ if (article.status)
246
+ frontmatter.status = article.status;
247
+ if (article.category)
248
+ frontmatter.category = article.category;
249
+ if (article.tags) {
250
+ frontmatter.tags = article.tags.split(',').map(t => t.trim()).filter(Boolean);
251
+ }
252
+ const yamlLines = Object.entries(frontmatter).map(([key, value]) => {
253
+ if (Array.isArray(value)) {
254
+ return `${key}:\n${value.map(v => ` - ${v}`).join('\n')}`;
255
+ }
256
+ if (typeof value === 'number')
257
+ return `${key}: ${value}`;
258
+ return `${key}: "${String(value).replace(/"/g, '\\"')}"`;
259
+ });
260
+ return `---\n${yamlLines.join('\n')}\n---\n\n${article.content}`;
261
+ }
262
+ function generateJSONContent(articles, projectName, pretty) {
263
+ const output = {
264
+ exported_at: new Date().toISOString(),
265
+ project: projectName || 'Default',
266
+ total_articles: articles.length,
267
+ articles: articles.map((a, idx) => ({
268
+ id: `art_${String(idx + 1).padStart(3, '0')}`,
269
+ title: a.title,
270
+ slug: a.slug || '',
271
+ keyword: a.keyword || '',
272
+ meta_description: a.meta_description || '',
273
+ content_markdown: a.content,
274
+ word_count: a.word_count || 0,
275
+ model: a.model || '',
276
+ status: a.status || 'draft',
277
+ category: a.category || '',
278
+ tags: a.tags ? a.tags.split(',').map(t => t.trim()) : [],
279
+ created_at: a.created_at || '',
280
+ research_citations: a.research_citations || [],
281
+ })),
282
+ };
283
+ return JSON.stringify(output, null, pretty !== false ? 2 : 0);
284
+ }
285
+ function slugify(text) {
286
+ return text
287
+ .toLowerCase()
288
+ .replace(/[^\w\s-]/g, '')
289
+ .replace(/[\s_]+/g, '-')
290
+ .replace(/-+/g, '-')
291
+ .replace(/^-|-$/g, '')
292
+ .slice(0, 80);
293
+ }
294
+ // ============================================
295
+ // TOOL REGISTRATION
296
+ // ============================================
297
+ export function registerCSVTools(server) {
298
+ // =====================
299
+ // import_csv
300
+ // =====================
301
+ server.tool('import_csv', 'Read a local CSV file and prepare it for batch generation. Auto-detects common column headers (keyword, title, slug, meta_description, etc.) or accepts manual mapping. Use preview_only to inspect the file before committing.', {
302
+ file_path: z.string().describe('Absolute path to the CSV file'),
303
+ column_map: z.record(z.string(), z.string()).optional().describe('Manual column mapping: { canonical_field: "Your CSV Header" }'),
304
+ delimiter: z.string().default(',').describe('CSV delimiter character (, ; or \\t)'),
305
+ encoding: z.string().default('utf-8').describe('File encoding'),
306
+ skip_rows: z.number().default(0).describe('Number of header/info rows to skip before the column headers'),
307
+ max_rows: z.number().optional().describe('Maximum number of rows to import'),
308
+ preview_only: z.boolean().default(false).describe('Preview first 5 rows without full import'),
309
+ }, async (args) => {
310
+ const ctx = getUserContext();
311
+ if (!ctx.authenticated) {
312
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'Not authenticated. Check your BPAI_API_KEY.' }, null, 2) }] };
313
+ }
314
+ try {
315
+ // Read file
316
+ const raw = await fs.readFile(args.file_path, { encoding: args.encoding });
317
+ const { headers, rows } = parseCSV(raw, args.delimiter, args.skip_rows);
318
+ if (headers.length === 0) {
319
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'No columns found in file. Check the delimiter and skip_rows settings.' }, null, 2) }] };
320
+ }
321
+ // Detect columns
322
+ const autoDetected = detectColumns(headers);
323
+ const columnMapping = args.column_map
324
+ ? { ...autoDetected, ...args.column_map }
325
+ : autoDetected;
326
+ // Map rows to canonical fields
327
+ const invertedMap = {};
328
+ for (const [canonical, csvHeader] of Object.entries(columnMapping)) {
329
+ invertedMap[csvHeader] = canonical;
330
+ }
331
+ const mappedRows = rows.map(row => {
332
+ const mapped = {};
333
+ for (const [header, value] of Object.entries(row)) {
334
+ const canonical = invertedMap[header];
335
+ if (canonical) {
336
+ mapped[canonical] = value;
337
+ }
338
+ else {
339
+ mapped[header] = value;
340
+ }
341
+ }
342
+ return mapped;
343
+ });
344
+ // Apply limits
345
+ const limitedRows = args.max_rows ? mappedRows.slice(0, args.max_rows) : mappedRows;
346
+ const previewRows = args.preview_only ? limitedRows.slice(0, 5) : limitedRows;
347
+ // Column detection summary
348
+ const detectionSummary = {};
349
+ for (const [field, header] of Object.entries(columnMapping)) {
350
+ const isManual = args.column_map && field in args.column_map;
351
+ detectionSummary[field] = `${header} (${isManual ? 'manual override' : 'auto-detected'})`;
352
+ }
353
+ // Identify unmapped columns
354
+ const mappedHeaders = new Set(Object.values(columnMapping));
355
+ const unmapped = headers.filter(h => !mappedHeaders.has(h.trim()));
356
+ const result = {
357
+ status: 'success',
358
+ file: args.file_path,
359
+ rows_found: rows.length,
360
+ rows_imported: args.preview_only ? 0 : limitedRows.length,
361
+ columns_detected: detectionSummary,
362
+ unmapped_columns: unmapped.length > 0 ? unmapped : undefined,
363
+ preview: previewRows.slice(0, 5),
364
+ ready_for_generation: !!columnMapping.keyword,
365
+ };
366
+ if (args.preview_only) {
367
+ result.note = 'Preview mode — no rows imported. Remove preview_only to import.';
368
+ }
369
+ if (!args.preview_only) {
370
+ result.rows = limitedRows;
371
+ }
372
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
373
+ }
374
+ catch (err) {
375
+ if (err.code === 'ENOENT') {
376
+ return { content: [{ type: 'text', text: JSON.stringify({ error: `File not found: ${args.file_path}` }, null, 2) }] };
377
+ }
378
+ return { content: [{ type: 'text', text: JSON.stringify({ error: `Failed to read CSV: ${err.message}` }, null, 2) }] };
379
+ }
380
+ });
381
+ // =====================
382
+ // export_csv
383
+ // =====================
384
+ server.tool('export_csv', 'Export articles as a CSV file. Provide the articles array from a previous generation or pass them directly. Supports column selection and status filtering.', {
385
+ output_path: z.string().describe('Absolute path where the CSV will be saved'),
386
+ articles: z.array(articleSchema).describe('Array of articles to export'),
387
+ columns: z.array(z.string()).optional().describe('Which columns to include (defaults to all standard columns)'),
388
+ filter_status: z.string().optional().describe('Filter by status: draft, published, or all'),
389
+ include_html: z.boolean().default(false).describe('Include HTML content column'),
390
+ include_markdown: z.boolean().default(true).describe('Include Markdown content column'),
391
+ }, async (args) => {
392
+ const ctx = getUserContext();
393
+ if (!ctx.authenticated) {
394
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'Not authenticated. Check your BPAI_API_KEY.' }, null, 2) }] };
395
+ }
396
+ try {
397
+ const filtered = filterArticles(args.articles, { filter_status: args.filter_status });
398
+ if (filtered.length === 0) {
399
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'No articles match the given filters.' }, null, 2) }] };
400
+ }
401
+ const csvContent = generateCSVContent(filtered, args.columns, args.include_html);
402
+ // Ensure directory exists
403
+ await fs.mkdir(path.dirname(args.output_path), { recursive: true });
404
+ await fs.writeFile(args.output_path, csvContent, 'utf-8');
405
+ return {
406
+ content: [{
407
+ type: 'text', text: JSON.stringify({
408
+ status: 'success',
409
+ output_file: args.output_path,
410
+ articles_exported: filtered.length,
411
+ file_size: `${(Buffer.byteLength(csvContent) / 1024).toFixed(1)} KB`,
412
+ }, null, 2)
413
+ }],
414
+ };
415
+ }
416
+ catch (err) {
417
+ return { content: [{ type: 'text', text: JSON.stringify({ error: `CSV export failed: ${err.message}` }, null, 2) }] };
418
+ }
419
+ });
420
+ // =====================
421
+ // export_markdown
422
+ // =====================
423
+ server.tool('export_markdown', 'Export articles as individual Markdown files with YAML frontmatter. Each article becomes a separate .md file. Supports flat or nested folder structure.', {
424
+ output_dir: z.string().describe('Directory to write Markdown files to'),
425
+ articles: z.array(articleSchema).describe('Array of articles to export'),
426
+ project_name: z.string().optional().describe('Project name for frontmatter and nested folder structure'),
427
+ structure: z.enum(['flat', 'nested']).default('flat').describe('flat = all files in one folder, nested = grouped in project subfolder'),
428
+ filename_from: z.enum(['slug', 'title', 'keyword']).default('slug').describe('Which field to use for filenames'),
429
+ frontmatter: z.array(z.string()).optional().describe('Which fields to include in frontmatter (defaults to all)'),
430
+ filter_status: z.string().optional().describe('Filter by status: draft, published, or all'),
431
+ }, async (args) => {
432
+ const ctx = getUserContext();
433
+ if (!ctx.authenticated) {
434
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'Not authenticated. Check your BPAI_API_KEY.' }, null, 2) }] };
435
+ }
436
+ try {
437
+ const filtered = filterArticles(args.articles, { filter_status: args.filter_status });
438
+ if (filtered.length === 0) {
439
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'No articles match the given filters.' }, null, 2) }] };
440
+ }
441
+ // Determine output directory
442
+ const baseDir = args.structure === 'nested' && args.project_name
443
+ ? path.join(args.output_dir, slugify(args.project_name))
444
+ : args.output_dir;
445
+ await fs.mkdir(baseDir, { recursive: true });
446
+ const written = [];
447
+ for (const article of filtered) {
448
+ // Generate filename
449
+ let filename;
450
+ switch (args.filename_from) {
451
+ case 'title':
452
+ filename = slugify(article.title);
453
+ break;
454
+ case 'keyword':
455
+ filename = slugify(article.keyword || article.title);
456
+ break;
457
+ default:
458
+ filename = article.slug || slugify(article.title);
459
+ break;
460
+ }
461
+ const filePath = path.join(baseDir, `${filename}.md`);
462
+ const mdContent = generateMarkdownFile(article, args.project_name);
463
+ await fs.writeFile(filePath, mdContent, 'utf-8');
464
+ written.push(filePath);
465
+ }
466
+ // Write manifest
467
+ const manifest = {
468
+ exported_at: new Date().toISOString(),
469
+ project: args.project_name || 'Default',
470
+ total_files: written.length,
471
+ files: written.map(f => path.basename(f)),
472
+ };
473
+ await fs.writeFile(path.join(args.output_dir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf-8');
474
+ return {
475
+ content: [{
476
+ type: 'text', text: JSON.stringify({
477
+ status: 'success',
478
+ output_directory: baseDir,
479
+ files_written: written.length,
480
+ manifest: path.join(args.output_dir, 'manifest.json'),
481
+ files: written.map(f => path.basename(f)),
482
+ }, null, 2)
483
+ }],
484
+ };
485
+ }
486
+ catch (err) {
487
+ return { content: [{ type: 'text', text: JSON.stringify({ error: `Markdown export failed: ${err.message}` }, null, 2) }] };
488
+ }
489
+ });
490
+ // =====================
491
+ // export_json
492
+ // =====================
493
+ server.tool('export_json', 'Export articles as structured JSON. Useful for custom integrations, static site generators, or API import workflows.', {
494
+ output_path: z.string().describe('File path for JSON output'),
495
+ articles: z.array(articleSchema).describe('Array of articles to export'),
496
+ project_name: z.string().optional().describe('Project name for the export metadata'),
497
+ pretty: z.boolean().default(true).describe('Pretty-print the JSON output'),
498
+ filter_status: z.string().optional().describe('Filter by status: draft, published, or all'),
499
+ }, async (args) => {
500
+ const ctx = getUserContext();
501
+ if (!ctx.authenticated) {
502
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'Not authenticated. Check your BPAI_API_KEY.' }, null, 2) }] };
503
+ }
504
+ try {
505
+ const filtered = filterArticles(args.articles, { filter_status: args.filter_status });
506
+ if (filtered.length === 0) {
507
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'No articles match the given filters.' }, null, 2) }] };
508
+ }
509
+ const jsonContent = generateJSONContent(filtered, args.project_name, args.pretty);
510
+ await fs.mkdir(path.dirname(args.output_path), { recursive: true });
511
+ await fs.writeFile(args.output_path, jsonContent, 'utf-8');
512
+ return {
513
+ content: [{
514
+ type: 'text', text: JSON.stringify({
515
+ status: 'success',
516
+ output_file: args.output_path,
517
+ articles_exported: filtered.length,
518
+ file_size: `${(Buffer.byteLength(jsonContent) / 1024).toFixed(1)} KB`,
519
+ }, null, 2)
520
+ }],
521
+ };
522
+ }
523
+ catch (err) {
524
+ return { content: [{ type: 'text', text: JSON.stringify({ error: `JSON export failed: ${err.message}` }, null, 2) }] };
525
+ }
526
+ });
527
+ // =====================
528
+ // export_bulk
529
+ // =====================
530
+ server.tool('export_bulk', 'Combined multi-format export with advanced filtering. Supports CSV, Markdown, JSON, and HTML output formats. Includes filters for status, model, date range, and minimum word count.', {
531
+ format: z.enum(['csv', 'markdown', 'json', 'html']).describe('Output format: csv, markdown, json, or html'),
532
+ output_path: z.string().describe('Output file path (csv/json/html) or directory (markdown)'),
533
+ articles: z.array(articleSchema).describe('Array of articles to export'),
534
+ project_name: z.string().optional().describe('Project name for metadata and folder structure'),
535
+ structure: z.enum(['flat', 'nested']).default('flat').describe('Folder structure for markdown exports'),
536
+ filter_status: z.string().optional().describe('Filter: draft, published, or all'),
537
+ filter_model: z.string().optional().describe('Filter by model name (partial match)'),
538
+ filter_date_from: z.string().optional().describe('Filter: start date (YYYY-MM-DD)'),
539
+ filter_date_to: z.string().optional().describe('Filter: end date (YYYY-MM-DD)'),
540
+ filter_min_words: z.number().optional().describe('Filter: minimum word count'),
541
+ include_metadata: z.boolean().default(true).describe('Include frontmatter/headers in output'),
542
+ columns: z.array(z.string()).optional().describe('Columns to include (CSV only)'),
543
+ }, async (args) => {
544
+ const ctx = getUserContext();
545
+ if (!ctx.authenticated) {
546
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'Not authenticated. Check your BPAI_API_KEY.' }, null, 2) }] };
547
+ }
548
+ try {
549
+ const filtered = filterArticles(args.articles, {
550
+ filter_status: args.filter_status,
551
+ filter_model: args.filter_model,
552
+ filter_date_from: args.filter_date_from,
553
+ filter_date_to: args.filter_date_to,
554
+ filter_min_words: args.filter_min_words,
555
+ });
556
+ if (filtered.length === 0) {
557
+ return {
558
+ content: [{
559
+ type: 'text', text: JSON.stringify({
560
+ error: 'No articles match the given filters.',
561
+ total_before_filter: args.articles.length,
562
+ filters_applied: {
563
+ status: args.filter_status || 'all',
564
+ model: args.filter_model || 'any',
565
+ date_from: args.filter_date_from || 'none',
566
+ date_to: args.filter_date_to || 'none',
567
+ min_words: args.filter_min_words || 'none',
568
+ },
569
+ }, null, 2)
570
+ }]
571
+ };
572
+ }
573
+ let outputInfo = {};
574
+ switch (args.format) {
575
+ case 'csv': {
576
+ const csvContent = generateCSVContent(filtered, args.columns);
577
+ await fs.mkdir(path.dirname(args.output_path), { recursive: true });
578
+ await fs.writeFile(args.output_path, csvContent, 'utf-8');
579
+ outputInfo = {
580
+ output_file: args.output_path,
581
+ file_size: `${(Buffer.byteLength(csvContent) / 1024).toFixed(1)} KB`,
582
+ };
583
+ break;
584
+ }
585
+ case 'json': {
586
+ const jsonContent = generateJSONContent(filtered, args.project_name, true);
587
+ await fs.mkdir(path.dirname(args.output_path), { recursive: true });
588
+ await fs.writeFile(args.output_path, jsonContent, 'utf-8');
589
+ outputInfo = {
590
+ output_file: args.output_path,
591
+ file_size: `${(Buffer.byteLength(jsonContent) / 1024).toFixed(1)} KB`,
592
+ };
593
+ break;
594
+ }
595
+ case 'markdown': {
596
+ const baseDir = args.structure === 'nested' && args.project_name
597
+ ? path.join(args.output_path, slugify(args.project_name))
598
+ : args.output_path;
599
+ await fs.mkdir(baseDir, { recursive: true });
600
+ const written = [];
601
+ for (const article of filtered) {
602
+ const filename = article.slug || slugify(article.title);
603
+ const filePath = path.join(baseDir, `${filename}.md`);
604
+ const md = generateMarkdownFile(article, args.project_name);
605
+ await fs.writeFile(filePath, md, 'utf-8');
606
+ written.push(path.basename(filePath));
607
+ }
608
+ const manifest = {
609
+ exported_at: new Date().toISOString(),
610
+ project: args.project_name || 'Default',
611
+ total_files: written.length,
612
+ files: written,
613
+ };
614
+ await fs.writeFile(path.join(args.output_path, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf-8');
615
+ outputInfo = {
616
+ output_directory: baseDir,
617
+ files: written,
618
+ manifest: path.join(args.output_path, 'manifest.json'),
619
+ };
620
+ break;
621
+ }
622
+ case 'html': {
623
+ // Generate a single HTML file with all articles
624
+ const htmlParts = filtered.map(a => {
625
+ const meta = args.include_metadata
626
+ ? `<div class="article-meta"><span>${a.keyword || ''}</span> | <span>${a.word_count || 0} words</span> | <span>${a.model || ''}</span></div>\n`
627
+ : '';
628
+ return `<article>\n<h1>${a.title}</h1>\n${meta}<div class="content">\n${a.content}\n</div>\n</article>`;
629
+ });
630
+ const htmlContent = `<!DOCTYPE html>
631
+ <html lang="en">
632
+ <head>
633
+ <meta charset="UTF-8">
634
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
635
+ <title>${args.project_name || 'Exported Articles'}</title>
636
+ <style>
637
+ body { font-family: system-ui, sans-serif; max-width: 800px; margin: 0 auto; padding: 2rem; }
638
+ article { margin-bottom: 3rem; padding-bottom: 2rem; border-bottom: 1px solid #e2e8f0; }
639
+ .article-meta { color: #64748b; font-size: 0.875rem; margin-bottom: 1rem; }
640
+ h1 { color: #0f172a; }
641
+ </style>
642
+ </head>
643
+ <body>
644
+ ${htmlParts.join('\n\n')}
645
+ </body>
646
+ </html>`;
647
+ await fs.mkdir(path.dirname(args.output_path), { recursive: true });
648
+ await fs.writeFile(args.output_path, htmlContent, 'utf-8');
649
+ outputInfo = {
650
+ output_file: args.output_path,
651
+ file_size: `${(Buffer.byteLength(htmlContent) / 1024).toFixed(1)} KB`,
652
+ };
653
+ break;
654
+ }
655
+ }
656
+ return {
657
+ content: [{
658
+ type: 'text', text: JSON.stringify({
659
+ status: 'success',
660
+ format: args.format,
661
+ articles_exported: filtered.length,
662
+ total_before_filter: args.articles.length,
663
+ ...outputInfo,
664
+ }, null, 2)
665
+ }],
666
+ };
667
+ }
668
+ catch (err) {
669
+ return { content: [{ type: 'text', text: JSON.stringify({ error: `Bulk export failed: ${err.message}` }, null, 2) }] };
670
+ }
671
+ });
672
+ }
673
+ //# sourceMappingURL=csv.js.map