suparank 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.
package/mcp-client.js ADDED
@@ -0,0 +1,2446 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Suparank MCP - Stdio Client
5
+ *
6
+ * Local MCP client that connects to the Suparank backend API.
7
+ * Works with Claude Desktop and Cursor via stdio transport.
8
+ *
9
+ * Usage:
10
+ * npx suparank
11
+ * node mcp-client.js <project-slug> <api-key>
12
+ *
13
+ * Credentials:
14
+ * Local credentials are loaded from ~/.suparank/credentials.json
15
+ * These enable additional tools: image generation, CMS publishing, webhooks
16
+ *
17
+ * Note: API keys are more secure than JWT tokens for MCP connections
18
+ * because they don't expire and can be revoked individually.
19
+ */
20
+
21
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
22
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
23
+ import {
24
+ CallToolRequestSchema,
25
+ ListToolsRequestSchema,
26
+ InitializeRequestSchema
27
+ } from '@modelcontextprotocol/sdk/types.js'
28
+ import * as fs from 'fs'
29
+ import { marked } from 'marked'
30
+ import * as path from 'path'
31
+ import * as os from 'os'
32
+
33
+ // Parse command line arguments
34
+ const projectSlug = process.argv[2]
35
+ const apiKey = process.argv[3]
36
+ const apiUrl = process.env.SUPARANK_API_URL || 'https://api.suparank.io'
37
+
38
+ if (!projectSlug) {
39
+ console.error('Error: Project slug is required')
40
+ console.error('Usage: node mcp-client.js <project-slug> <api-key>')
41
+ console.error('Example: node mcp-client.js my-project sk_live_abc123...')
42
+ process.exit(1)
43
+ }
44
+
45
+ if (!apiKey) {
46
+ console.error('Error: API key is required')
47
+ console.error('Usage: node mcp-client.js <project-slug> <api-key>')
48
+ console.error('')
49
+ console.error('To create an API key:')
50
+ console.error('1. Sign in at https://suparank.io/dashboard')
51
+ console.error('2. Go to Settings > API Keys')
52
+ console.error('3. Click "Create API Key"')
53
+ console.error('4. Copy the key (shown only once!)')
54
+ console.error('')
55
+ console.error('Or run: npx suparank setup')
56
+ process.exit(1)
57
+ }
58
+
59
+ // Validate API key format
60
+ if (!apiKey.startsWith('sk_live_') && !apiKey.startsWith('sk_test_')) {
61
+ console.error('Error: Invalid API key format')
62
+ console.error('API keys must start with "sk_live_" or "sk_test_"')
63
+ console.error('Example: sk_live_abc123...')
64
+ process.exit(1)
65
+ }
66
+
67
+ // Log to stderr (stdout is used for MCP protocol)
68
+ const log = (...args) => console.error('[suparank]', ...args)
69
+
70
+ // Structured progress logging for user visibility
71
+ const progress = (step, message) => console.error(`[suparank] ${step}: ${message}`)
72
+
73
+ // Local credentials storage
74
+ let localCredentials = null
75
+
76
+ // Session state for orchestration - stores content between steps
77
+ const sessionState = {
78
+ currentWorkflow: null,
79
+ stepResults: {},
80
+ article: null,
81
+ title: null,
82
+ imageUrl: null, // Cover image
83
+ inlineImages: [], // Array of inline image URLs
84
+ keywords: null,
85
+ metadata: null,
86
+ metaTitle: null,
87
+ metaDescription: null,
88
+ contentFolder: null // Path to saved content folder
89
+ }
90
+
91
+ /**
92
+ * Get the path to the Suparank config directory (~/.suparank/)
93
+ */
94
+ function getSuparankDir() {
95
+ return path.join(os.homedir(), '.suparank')
96
+ }
97
+
98
+ /**
99
+ * Get the path to the session file (~/.suparank/session.json)
100
+ */
101
+ function getSessionFilePath() {
102
+ return path.join(getSuparankDir(), 'session.json')
103
+ }
104
+
105
+ /**
106
+ * Get the path to the content directory (~/.suparank/content/)
107
+ */
108
+ function getContentDir() {
109
+ return path.join(getSuparankDir(), 'content')
110
+ }
111
+
112
+ /**
113
+ * Ensure the Suparank config directory exists
114
+ */
115
+ function ensureSuparankDir() {
116
+ const dir = getSuparankDir()
117
+ if (!fs.existsSync(dir)) {
118
+ fs.mkdirSync(dir, { recursive: true })
119
+ log(`Created config directory: ${dir}`)
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Ensure content directory exists
125
+ */
126
+ function ensureContentDir() {
127
+ const dir = getContentDir()
128
+ if (!fs.existsSync(dir)) {
129
+ fs.mkdirSync(dir, { recursive: true })
130
+ }
131
+ return dir
132
+ }
133
+
134
+ /**
135
+ * Generate a slug from title for folder naming
136
+ */
137
+ function slugify(text) {
138
+ return text
139
+ .toLowerCase()
140
+ .replace(/[^a-z0-9]+/g, '-')
141
+ .replace(/^-|-$/g, '')
142
+ .substring(0, 50)
143
+ }
144
+
145
+ /**
146
+ * Atomic file write - prevents corruption on concurrent writes
147
+ */
148
+ function atomicWriteSync(filePath, data) {
149
+ const tmpFile = filePath + '.tmp.' + process.pid
150
+ try {
151
+ fs.writeFileSync(tmpFile, data)
152
+ fs.renameSync(tmpFile, filePath) // Atomic on POSIX
153
+ } catch (error) {
154
+ // Clean up temp file if rename failed
155
+ try { fs.unlinkSync(tmpFile) } catch (e) { /* ignore */ }
156
+ throw error
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Fetch with timeout - prevents hanging requests
162
+ */
163
+ async function fetchWithTimeout(url, options = {}, timeoutMs = 30000) {
164
+ const controller = new AbortController()
165
+ const timeout = setTimeout(() => controller.abort(), timeoutMs)
166
+
167
+ try {
168
+ const response = await fetch(url, {
169
+ ...options,
170
+ signal: controller.signal
171
+ })
172
+ return response
173
+ } finally {
174
+ clearTimeout(timeout)
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Fetch with retry - handles transient failures
180
+ */
181
+ async function fetchWithRetry(url, options = {}, maxRetries = 3, timeoutMs = 30000) {
182
+ let lastError
183
+
184
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
185
+ try {
186
+ const response = await fetchWithTimeout(url, options, timeoutMs)
187
+
188
+ // Retry on 5xx errors or rate limiting
189
+ if (response.status >= 500 || response.status === 429) {
190
+ const retryAfter = response.headers.get('retry-after')
191
+ const delay = retryAfter ? parseInt(retryAfter) * 1000 : Math.pow(2, attempt) * 1000
192
+
193
+ if (attempt < maxRetries) {
194
+ log(`Request failed (${response.status}), retrying in ${delay}ms... (attempt ${attempt}/${maxRetries})`)
195
+ await new Promise(resolve => setTimeout(resolve, delay))
196
+ continue
197
+ }
198
+ }
199
+
200
+ return response
201
+ } catch (error) {
202
+ lastError = error
203
+
204
+ // Retry on network errors
205
+ if (error.name === 'AbortError') {
206
+ lastError = new Error(`Request timeout after ${timeoutMs}ms`)
207
+ }
208
+
209
+ if (attempt < maxRetries) {
210
+ const delay = Math.pow(2, attempt) * 1000
211
+ log(`Request error: ${lastError.message}, retrying in ${delay}ms... (attempt ${attempt}/${maxRetries})`)
212
+ await new Promise(resolve => setTimeout(resolve, delay))
213
+ }
214
+ }
215
+ }
216
+
217
+ throw lastError
218
+ }
219
+
220
+ /**
221
+ * Load session state from file (survives MCP restarts)
222
+ */
223
+ function loadSession() {
224
+ try {
225
+ const sessionFile = getSessionFilePath()
226
+ if (fs.existsSync(sessionFile)) {
227
+ const content = fs.readFileSync(sessionFile, 'utf-8')
228
+ const saved = JSON.parse(content)
229
+
230
+ // Check if session is stale (older than 24 hours)
231
+ const savedAt = new Date(saved.savedAt)
232
+ const hoursSinceSave = (Date.now() - savedAt.getTime()) / (1000 * 60 * 60)
233
+ if (hoursSinceSave > 24) {
234
+ log(`Session expired (${Math.round(hoursSinceSave)} hours old), starting fresh`)
235
+ clearSessionFile()
236
+ return false
237
+ }
238
+
239
+ // Restore session state
240
+ sessionState.currentWorkflow = saved.currentWorkflow || null
241
+ sessionState.stepResults = saved.stepResults || {}
242
+ sessionState.article = saved.article || null
243
+ sessionState.title = saved.title || null
244
+ sessionState.imageUrl = saved.imageUrl || null
245
+ sessionState.inlineImages = saved.inlineImages || []
246
+ sessionState.keywords = saved.keywords || null
247
+ sessionState.metadata = saved.metadata || null
248
+ sessionState.metaTitle = saved.metaTitle || null
249
+ sessionState.metaDescription = saved.metaDescription || null
250
+ sessionState.contentFolder = saved.contentFolder || null
251
+
252
+ log(`Restored session from ${sessionFile}`)
253
+ if (sessionState.title) {
254
+ log(` - Article: "${sessionState.title}" (${sessionState.article?.split(/\s+/).length || 0} words)`)
255
+ }
256
+ if (sessionState.imageUrl) {
257
+ log(` - Cover image: ${sessionState.imageUrl.substring(0, 50)}...`)
258
+ }
259
+ if (sessionState.contentFolder) {
260
+ log(` - Content folder: ${sessionState.contentFolder}`)
261
+ }
262
+ return true
263
+ }
264
+ } catch (error) {
265
+ log(`Warning: Failed to load session: ${error.message}`)
266
+ }
267
+ return false
268
+ }
269
+
270
+ /**
271
+ * Save session state to file (persists across MCP restarts)
272
+ * Uses atomic write to prevent corruption
273
+ */
274
+ function saveSession() {
275
+ try {
276
+ ensureSuparankDir()
277
+ const sessionFile = getSessionFilePath()
278
+
279
+ const toSave = {
280
+ currentWorkflow: sessionState.currentWorkflow,
281
+ stepResults: sessionState.stepResults,
282
+ article: sessionState.article,
283
+ title: sessionState.title,
284
+ imageUrl: sessionState.imageUrl,
285
+ inlineImages: sessionState.inlineImages,
286
+ keywords: sessionState.keywords,
287
+ metadata: sessionState.metadata,
288
+ metaTitle: sessionState.metaTitle,
289
+ metaDescription: sessionState.metaDescription,
290
+ contentFolder: sessionState.contentFolder,
291
+ savedAt: new Date().toISOString()
292
+ }
293
+
294
+ // Atomic write to prevent corruption
295
+ atomicWriteSync(sessionFile, JSON.stringify(toSave, null, 2))
296
+ progress('Session', `Saved to ${sessionFile}`)
297
+ } catch (error) {
298
+ log(`Warning: Failed to save session: ${error.message}`)
299
+ progress('Session', `FAILED to save: ${error.message}`)
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Save content to a dedicated folder with all assets
305
+ * Creates: ~/.suparank/content/{date}-{slug}/
306
+ * - article.md (markdown content)
307
+ * - metadata.json (title, keywords, etc.)
308
+ * - workflow.json (workflow state for resuming)
309
+ */
310
+ function saveContentToFolder() {
311
+ if (!sessionState.title || !sessionState.article) {
312
+ return null
313
+ }
314
+
315
+ try {
316
+ ensureContentDir()
317
+
318
+ // Create folder name: YYYY-MM-DD-slug
319
+ const date = new Date().toISOString().split('T')[0]
320
+ const slug = slugify(sessionState.title)
321
+ const folderName = `${date}-${slug}`
322
+ const folderPath = path.join(getContentDir(), folderName)
323
+
324
+ // Create folder if doesn't exist
325
+ if (!fs.existsSync(folderPath)) {
326
+ fs.mkdirSync(folderPath, { recursive: true })
327
+ }
328
+
329
+ // Save markdown article
330
+ atomicWriteSync(
331
+ path.join(folderPath, 'article.md'),
332
+ sessionState.article
333
+ )
334
+
335
+ // Save metadata
336
+ const metadata = {
337
+ title: sessionState.title,
338
+ keywords: sessionState.keywords || [],
339
+ metaDescription: sessionState.metaDescription || '',
340
+ metaTitle: sessionState.metaTitle || sessionState.title,
341
+ imageUrl: sessionState.imageUrl,
342
+ inlineImages: sessionState.inlineImages || [],
343
+ wordCount: sessionState.article.split(/\s+/).length,
344
+ createdAt: new Date().toISOString(),
345
+ projectSlug: projectSlug
346
+ }
347
+ atomicWriteSync(
348
+ path.join(folderPath, 'metadata.json'),
349
+ JSON.stringify(metadata, null, 2)
350
+ )
351
+
352
+ // Save workflow state for resuming
353
+ if (sessionState.currentWorkflow) {
354
+ atomicWriteSync(
355
+ path.join(folderPath, 'workflow.json'),
356
+ JSON.stringify({
357
+ workflow: sessionState.currentWorkflow,
358
+ stepResults: sessionState.stepResults,
359
+ savedAt: new Date().toISOString()
360
+ }, null, 2)
361
+ )
362
+ }
363
+
364
+ // Store folder path in session
365
+ sessionState.contentFolder = folderPath
366
+
367
+ progress('Content', `Saved to folder: ${folderPath}`)
368
+ return folderPath
369
+ } catch (error) {
370
+ log(`Warning: Failed to save content to folder: ${error.message}`)
371
+ return null
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Clear session file (called after successful publish or on reset)
377
+ */
378
+ function clearSessionFile() {
379
+ try {
380
+ const sessionFile = getSessionFilePath()
381
+ if (fs.existsSync(sessionFile)) {
382
+ fs.unlinkSync(sessionFile)
383
+ }
384
+ } catch (error) {
385
+ log(`Warning: Failed to clear session file: ${error.message}`)
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Reset session state for new workflow
391
+ */
392
+ function resetSession() {
393
+ sessionState.currentWorkflow = null
394
+ sessionState.stepResults = {}
395
+ sessionState.article = null
396
+ sessionState.title = null
397
+ sessionState.imageUrl = null
398
+ sessionState.inlineImages = []
399
+ sessionState.keywords = null
400
+ sessionState.metadata = null
401
+ sessionState.metaTitle = null
402
+ sessionState.metaDescription = null
403
+ sessionState.contentFolder = null
404
+
405
+ // Clear persisted session file when starting fresh
406
+ clearSessionFile()
407
+ }
408
+
409
+ /**
410
+ * Load credentials from ~/.suparank/credentials.json
411
+ * Falls back to legacy .env.superwriter paths for backward compatibility
412
+ */
413
+ function loadLocalCredentials() {
414
+ const searchPaths = [
415
+ path.join(os.homedir(), '.suparank', 'credentials.json'),
416
+ path.join(process.cwd(), '.env.superwriter'), // Legacy support
417
+ path.join(os.homedir(), '.env.superwriter') // Legacy support
418
+ ]
419
+
420
+ for (const filePath of searchPaths) {
421
+ if (fs.existsSync(filePath)) {
422
+ try {
423
+ const content = fs.readFileSync(filePath, 'utf-8')
424
+ const parsed = JSON.parse(content)
425
+ log(`Loaded credentials from: ${filePath}`)
426
+ return parsed
427
+ } catch (e) {
428
+ log(`Warning: Failed to parse ${filePath}: ${e.message}`)
429
+ }
430
+ }
431
+ }
432
+
433
+ log('No credentials found. Run "npx suparank setup" to configure. Action tools will be limited.')
434
+ return null
435
+ }
436
+
437
+ /**
438
+ * Check if a credential type is available
439
+ */
440
+ function hasCredential(type) {
441
+ if (!localCredentials) return false
442
+
443
+ switch (type) {
444
+ case 'wordpress':
445
+ return !!localCredentials.wordpress?.secret_key || !!localCredentials.wordpress?.app_password
446
+ case 'ghost':
447
+ return !!localCredentials.ghost?.admin_api_key
448
+ case 'fal':
449
+ return !!localCredentials.fal?.api_key
450
+ case 'gemini':
451
+ return !!localCredentials.gemini?.api_key
452
+ case 'wiro':
453
+ return !!localCredentials.wiro?.api_key
454
+ case 'image':
455
+ const provider = localCredentials.image_provider
456
+ return provider && hasCredential(provider)
457
+ case 'webhooks':
458
+ return !!localCredentials.webhooks && Object.values(localCredentials.webhooks).some(Boolean)
459
+ default:
460
+ return false
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Get composition hints for a tool from local credentials
466
+ */
467
+ function getCompositionHints(toolName) {
468
+ if (!localCredentials?.tool_instructions) return null
469
+
470
+ const instruction = localCredentials.tool_instructions.find(t => t.tool_name === toolName)
471
+ return instruction?.composition_hints || null
472
+ }
473
+
474
+ /**
475
+ * Get list of external MCPs configured
476
+ */
477
+ function getExternalMCPs() {
478
+ return localCredentials?.external_mcps || []
479
+ }
480
+
481
+ // Fetch project config from API
482
+ async function fetchProjectConfig() {
483
+ try {
484
+ const response = await fetchWithRetry(`${apiUrl}/projects/${projectSlug}`, {
485
+ headers: {
486
+ 'Authorization': `Bearer ${apiKey}`,
487
+ 'Content-Type': 'application/json'
488
+ }
489
+ }, 3, 15000) // 3 retries, 15s timeout
490
+
491
+ if (!response.ok) {
492
+ const error = await response.text()
493
+
494
+ if (response.status === 401) {
495
+ throw new Error(`Invalid or expired API key. Please create a new one in the dashboard.`)
496
+ }
497
+
498
+ throw new Error(`Failed to fetch project: ${error}`)
499
+ }
500
+
501
+ const data = await response.json()
502
+ return data.project
503
+ } catch (error) {
504
+ log('Error fetching project config:', error.message)
505
+ throw error
506
+ }
507
+ }
508
+
509
+ // Call backend API to execute tool
510
+ async function callBackendTool(toolName, args) {
511
+ try {
512
+ const response = await fetch(`${apiUrl}/tools/${projectSlug}/${toolName}`, {
513
+ method: 'POST',
514
+ headers: {
515
+ 'Authorization': `Bearer ${apiKey}`,
516
+ 'Content-Type': 'application/json'
517
+ },
518
+ body: JSON.stringify({ arguments: args })
519
+ })
520
+
521
+ if (!response.ok) {
522
+ const error = await response.text()
523
+
524
+ if (response.status === 401) {
525
+ throw new Error(`Invalid or expired API key. Please create a new one in the dashboard.`)
526
+ }
527
+
528
+ throw new Error(`Tool execution failed: ${error}`)
529
+ }
530
+
531
+ const result = await response.json()
532
+ return result
533
+ } catch (error) {
534
+ log('Error calling tool:', error.message)
535
+ throw error
536
+ }
537
+ }
538
+
539
+ // Tool definitions (synced with backend)
540
+ const TOOLS = [
541
+ {
542
+ name: 'keyword_research',
543
+ description: 'Conduct keyword research and competitive analysis for SEO content. Analyzes search volume, difficulty, and opportunity. If no keyword provided, uses project primary keywords automatically.',
544
+ inputSchema: {
545
+ type: 'object',
546
+ properties: {
547
+ seed_keyword: {
548
+ type: 'string',
549
+ description: 'Starting keyword or topic to research (optional - uses project primary keywords if not specified)'
550
+ },
551
+ content_goal: {
552
+ type: 'string',
553
+ enum: ['traffic', 'conversions', 'brand-awareness'],
554
+ description: 'Primary goal for the content strategy (optional - defaults to traffic)'
555
+ },
556
+ competitor_domain: {
557
+ type: 'string',
558
+ description: 'Optional: Competitor domain to analyze'
559
+ }
560
+ }
561
+ }
562
+ },
563
+ {
564
+ name: 'seo_strategy',
565
+ description: 'Create comprehensive SEO strategy and content brief. Works with project keywords automatically if none specified.',
566
+ inputSchema: {
567
+ type: 'object',
568
+ properties: {
569
+ target_keyword: {
570
+ type: 'string',
571
+ description: 'Main keyword to target (optional - uses project primary keywords if not specified)'
572
+ },
573
+ content_type: {
574
+ type: 'string',
575
+ enum: ['guide', 'listicle', 'how-to', 'comparison', 'review'],
576
+ description: 'Type of content to create (optional - defaults to guide)'
577
+ },
578
+ search_intent: {
579
+ type: 'string',
580
+ enum: ['informational', 'commercial', 'transactional', 'navigational'],
581
+ description: 'Primary search intent to target (optional - auto-detected)'
582
+ }
583
+ }
584
+ }
585
+ },
586
+ {
587
+ name: 'topical_map',
588
+ description: 'Design pillar-cluster content architecture for topical authority. Uses project niche and keywords automatically.',
589
+ inputSchema: {
590
+ type: 'object',
591
+ properties: {
592
+ core_topic: {
593
+ type: 'string',
594
+ description: 'Main topic for the content cluster (optional - uses project niche if not specified)'
595
+ },
596
+ depth: {
597
+ type: 'number',
598
+ enum: [1, 2, 3],
599
+ description: 'Depth of content cluster: 1 (pillar + 5 articles), 2 (+ subtopics), 3 (full hierarchy)',
600
+ default: 2
601
+ }
602
+ }
603
+ }
604
+ },
605
+ {
606
+ name: 'content_calendar',
607
+ description: 'Create editorial calendar and publication schedule. Uses project keywords and niche automatically.',
608
+ inputSchema: {
609
+ type: 'object',
610
+ properties: {
611
+ time_period: {
612
+ type: 'string',
613
+ enum: ['week', 'month', 'quarter'],
614
+ description: 'Planning period for the content calendar (optional - defaults to month)',
615
+ default: 'month'
616
+ },
617
+ content_types: {
618
+ type: 'array',
619
+ items: { type: 'string' },
620
+ description: 'Types of content to include (optional - defaults to blog)'
621
+ },
622
+ priority_keywords: {
623
+ type: 'array',
624
+ items: { type: 'string' },
625
+ description: 'Keywords to prioritize (optional - uses project keywords)'
626
+ }
627
+ }
628
+ }
629
+ },
630
+ {
631
+ name: 'content_write',
632
+ description: 'Write comprehensive, SEO-optimized blog articles. Creates engaging content with proper structure, internal links, and semantic optimization. Uses project brand voice and keywords automatically.',
633
+ inputSchema: {
634
+ type: 'object',
635
+ properties: {
636
+ title: {
637
+ type: 'string',
638
+ description: 'Article title or headline (optional - can be generated from topic)'
639
+ },
640
+ target_keyword: {
641
+ type: 'string',
642
+ description: 'Primary keyword to optimize for (optional - uses project keywords)'
643
+ },
644
+ outline: {
645
+ type: 'string',
646
+ description: 'Optional: Article outline or structure (H2/H3 headings)'
647
+ },
648
+ tone: {
649
+ type: 'string',
650
+ enum: ['professional', 'casual', 'conversational', 'technical'],
651
+ description: 'Writing tone (optional - uses project brand voice)'
652
+ }
653
+ }
654
+ }
655
+ },
656
+ {
657
+ name: 'image_prompt',
658
+ description: 'Create optimized prompts for AI image generation. Designs prompts for blog hero images, section illustrations, and branded visuals. Uses project visual style and brand automatically.',
659
+ inputSchema: {
660
+ type: 'object',
661
+ properties: {
662
+ image_purpose: {
663
+ type: 'string',
664
+ enum: ['hero', 'section', 'diagram', 'comparison', 'infographic'],
665
+ description: 'Purpose of the image (optional - defaults to hero)',
666
+ default: 'hero'
667
+ },
668
+ subject: {
669
+ type: 'string',
670
+ description: 'Main subject or concept for the image (optional - uses project niche)'
671
+ },
672
+ mood: {
673
+ type: 'string',
674
+ description: 'Optional: Desired mood (uses project visual style if not specified)'
675
+ }
676
+ }
677
+ }
678
+ },
679
+ {
680
+ name: 'internal_links',
681
+ description: 'Develop strategic internal linking plan. Analyzes existing content and identifies linking opportunities for improved site architecture. Works with project content automatically.',
682
+ inputSchema: {
683
+ type: 'object',
684
+ properties: {
685
+ current_page: {
686
+ type: 'string',
687
+ description: 'URL or title of the page to optimize (optional - can work with last created content)'
688
+ },
689
+ available_pages: {
690
+ type: 'array',
691
+ items: { type: 'string' },
692
+ description: 'List of existing pages to consider (optional - can analyze site automatically)'
693
+ },
694
+ link_goal: {
695
+ type: 'string',
696
+ enum: ['authority-building', 'user-navigation', 'conversion'],
697
+ description: 'Primary goal for internal linking (optional - defaults to authority-building)'
698
+ }
699
+ }
700
+ }
701
+ },
702
+ {
703
+ name: 'schema_generate',
704
+ description: 'Implement Schema.org structured data markup. Analyzes content to recommend and generate appropriate JSON-LD schemas for enhanced search visibility. Auto-detects page type if not specified.',
705
+ inputSchema: {
706
+ type: 'object',
707
+ properties: {
708
+ page_type: {
709
+ type: 'string',
710
+ enum: ['article', 'product', 'how-to', 'faq', 'review', 'organization'],
711
+ description: 'Type of page to generate schema for (optional - auto-detected from content)'
712
+ },
713
+ content_summary: {
714
+ type: 'string',
715
+ description: 'Brief summary of the page content (optional - can analyze content)'
716
+ }
717
+ }
718
+ }
719
+ },
720
+ {
721
+ name: 'geo_optimize',
722
+ description: 'Optimize content for AI search engines and Google SGE. Implements GEO (Generative Engine Optimization) best practices for LLM-friendly content. Works with project content automatically.',
723
+ inputSchema: {
724
+ type: 'object',
725
+ properties: {
726
+ content_url: {
727
+ type: 'string',
728
+ description: 'URL or title of content to optimize (optional - can work with last created content)'
729
+ },
730
+ target_engines: {
731
+ type: 'array',
732
+ items: {
733
+ type: 'string',
734
+ enum: ['chatgpt', 'perplexity', 'claude', 'gemini', 'google-sge']
735
+ },
736
+ description: 'AI search engines to optimize for (optional - defaults to all)',
737
+ default: ['chatgpt', 'google-sge']
738
+ }
739
+ }
740
+ }
741
+ },
742
+ {
743
+ name: 'quality_check',
744
+ description: 'Perform comprehensive pre-publish quality assurance. Checks grammar, SEO requirements, brand consistency, accessibility, and technical accuracy. Can review last created content automatically.',
745
+ inputSchema: {
746
+ type: 'object',
747
+ properties: {
748
+ content: {
749
+ type: 'string',
750
+ description: 'Full content to review (optional - can review last created content)'
751
+ },
752
+ check_type: {
753
+ type: 'string',
754
+ enum: ['full', 'seo-only', 'grammar-only', 'brand-only'],
755
+ description: 'Type of quality check to perform (optional - defaults to full)',
756
+ default: 'full'
757
+ }
758
+ }
759
+ }
760
+ },
761
+ {
762
+ name: 'full_pipeline',
763
+ description: 'Execute complete 5-phase content creation pipeline. Orchestrates research, planning, creation, optimization, and quality checking in one workflow. Works with project configuration automatically - just describe what you need!',
764
+ inputSchema: {
765
+ type: 'object',
766
+ properties: {
767
+ seed_keyword: {
768
+ type: 'string',
769
+ description: 'Starting keyword for the pipeline (optional - uses project primary keywords and niche)'
770
+ },
771
+ content_type: {
772
+ type: 'string',
773
+ enum: ['guide', 'listicle', 'how-to', 'comparison', 'review'],
774
+ description: 'Type of content to create (optional - defaults to guide)',
775
+ default: 'guide'
776
+ },
777
+ skip_phases: {
778
+ type: 'array',
779
+ items: {
780
+ type: 'string',
781
+ enum: ['research', 'planning', 'creation', 'optimization', 'quality']
782
+ },
783
+ description: 'Optional: Phases to skip in the pipeline'
784
+ }
785
+ }
786
+ }
787
+ }
788
+ ]
789
+
790
+ // Action tools that require local credentials
791
+ const ACTION_TOOLS = [
792
+ {
793
+ name: 'generate_image',
794
+ description: 'Generate AI images using configured provider (fal.ai, Gemini Imagen, or wiro.ai). Requires image provider API key in ~/.suparank/credentials.json',
795
+ inputSchema: {
796
+ type: 'object',
797
+ properties: {
798
+ prompt: {
799
+ type: 'string',
800
+ description: 'Detailed prompt for image generation'
801
+ },
802
+ style: {
803
+ type: 'string',
804
+ description: 'Style guidance (e.g., "minimalist", "photorealistic", "illustration")'
805
+ },
806
+ aspect_ratio: {
807
+ type: 'string',
808
+ enum: ['1:1', '16:9', '9:16', '4:3', '3:4'],
809
+ description: 'Image aspect ratio',
810
+ default: '16:9'
811
+ }
812
+ },
813
+ required: ['prompt']
814
+ },
815
+ requiresCredential: 'image'
816
+ },
817
+ {
818
+ name: 'publish_wordpress',
819
+ description: 'Publish content directly to WordPress (supports .com and .org). Requires WordPress credentials in ~/.suparank/credentials.json',
820
+ inputSchema: {
821
+ type: 'object',
822
+ properties: {
823
+ title: {
824
+ type: 'string',
825
+ description: 'Post title'
826
+ },
827
+ content: {
828
+ type: 'string',
829
+ description: 'Full post content (HTML or Markdown)'
830
+ },
831
+ status: {
832
+ type: 'string',
833
+ enum: ['draft', 'publish'],
834
+ description: 'Publication status',
835
+ default: 'draft'
836
+ },
837
+ categories: {
838
+ type: 'array',
839
+ items: { type: 'string' },
840
+ description: 'Category names'
841
+ },
842
+ tags: {
843
+ type: 'array',
844
+ items: { type: 'string' },
845
+ description: 'Tag names'
846
+ },
847
+ featured_image_url: {
848
+ type: 'string',
849
+ description: 'URL of featured image to upload'
850
+ }
851
+ },
852
+ required: ['title', 'content']
853
+ },
854
+ requiresCredential: 'wordpress'
855
+ },
856
+ {
857
+ name: 'publish_ghost',
858
+ description: 'Publish content to Ghost CMS. Requires Ghost Admin API key in ~/.suparank/credentials.json',
859
+ inputSchema: {
860
+ type: 'object',
861
+ properties: {
862
+ title: {
863
+ type: 'string',
864
+ description: 'Post title'
865
+ },
866
+ content: {
867
+ type: 'string',
868
+ description: 'Full post content (HTML or Markdown)'
869
+ },
870
+ status: {
871
+ type: 'string',
872
+ enum: ['draft', 'published'],
873
+ description: 'Publication status',
874
+ default: 'draft'
875
+ },
876
+ tags: {
877
+ type: 'array',
878
+ items: { type: 'string' },
879
+ description: 'Tag names'
880
+ },
881
+ featured_image_url: {
882
+ type: 'string',
883
+ description: 'URL of featured image'
884
+ }
885
+ },
886
+ required: ['title', 'content']
887
+ },
888
+ requiresCredential: 'ghost'
889
+ },
890
+ {
891
+ name: 'send_webhook',
892
+ description: 'Send data to configured webhooks (Make.com, n8n, Zapier, Slack). Requires webhook URLs in ~/.suparank/credentials.json',
893
+ inputSchema: {
894
+ type: 'object',
895
+ properties: {
896
+ webhook_type: {
897
+ type: 'string',
898
+ enum: ['default', 'make', 'n8n', 'zapier', 'slack'],
899
+ description: 'Which webhook to use',
900
+ default: 'default'
901
+ },
902
+ payload: {
903
+ type: 'object',
904
+ description: 'Data to send in the webhook'
905
+ },
906
+ message: {
907
+ type: 'string',
908
+ description: 'For Slack: formatted message text'
909
+ }
910
+ },
911
+ required: ['webhook_type']
912
+ },
913
+ requiresCredential: 'webhooks'
914
+ }
915
+ ]
916
+
917
+ // Orchestrator tools for automated workflows
918
+ const ORCHESTRATOR_TOOLS = [
919
+ {
920
+ name: 'create_content',
921
+ description: `🚀 MAIN ENTRY POINT - Just tell me what content you want!
922
+
923
+ Examples:
924
+ - "Write a blog post about AI tools"
925
+ - "Create 3 articles about SEO"
926
+ - "Write content for my project"
927
+
928
+ I will automatically:
929
+ 1. Research keywords (using SEO MCP if available)
930
+ 2. Plan content structure
931
+ 3. Guide you through writing
932
+ 4. Generate images
933
+ 5. Publish to your configured CMS (Ghost/WordPress)
934
+
935
+ No parameters needed - I use your project settings automatically!`,
936
+ inputSchema: {
937
+ type: 'object',
938
+ properties: {
939
+ request: {
940
+ type: 'string',
941
+ description: 'What content do you want? (e.g., "write a blog post about AI", "create 5 articles")'
942
+ },
943
+ count: {
944
+ type: 'number',
945
+ description: 'Number of articles to create (default: 1)',
946
+ default: 1
947
+ },
948
+ publish_to: {
949
+ type: 'array',
950
+ items: { type: 'string', enum: ['ghost', 'wordpress', 'none'] },
951
+ description: 'Where to publish (default: all configured CMS)',
952
+ default: []
953
+ },
954
+ with_images: {
955
+ type: 'boolean',
956
+ description: 'Generate hero images (default: true)',
957
+ default: true
958
+ }
959
+ }
960
+ }
961
+ },
962
+ {
963
+ name: 'save_content',
964
+ description: 'Save generated content to session for publishing. Call this after you finish writing an article.',
965
+ inputSchema: {
966
+ type: 'object',
967
+ properties: {
968
+ title: {
969
+ type: 'string',
970
+ description: 'Article title'
971
+ },
972
+ content: {
973
+ type: 'string',
974
+ description: 'Full article content (markdown)'
975
+ },
976
+ keywords: {
977
+ type: 'array',
978
+ items: { type: 'string' },
979
+ description: 'Target keywords used'
980
+ },
981
+ meta_description: {
982
+ type: 'string',
983
+ description: 'SEO meta description'
984
+ }
985
+ },
986
+ required: ['title', 'content']
987
+ }
988
+ },
989
+ {
990
+ name: 'publish_content',
991
+ description: 'Publish saved content to configured CMS platforms. Automatically uses saved article and generated image.',
992
+ inputSchema: {
993
+ type: 'object',
994
+ properties: {
995
+ platforms: {
996
+ type: 'array',
997
+ items: { type: 'string', enum: ['ghost', 'wordpress', 'all'] },
998
+ description: 'Platforms to publish to (default: all configured)',
999
+ default: ['all']
1000
+ },
1001
+ status: {
1002
+ type: 'string',
1003
+ enum: ['draft', 'publish'],
1004
+ description: 'Publication status',
1005
+ default: 'draft'
1006
+ },
1007
+ category: {
1008
+ type: 'string',
1009
+ description: 'WordPress category name - pick the most relevant one from available categories shown in save_content response'
1010
+ }
1011
+ }
1012
+ }
1013
+ },
1014
+ {
1015
+ name: 'get_session',
1016
+ description: 'Get current session state - see what content and images have been created.',
1017
+ inputSchema: {
1018
+ type: 'object',
1019
+ properties: {}
1020
+ }
1021
+ }
1022
+ ]
1023
+
1024
+ /**
1025
+ * Build workflow plan based on user request and available credentials
1026
+ *
1027
+ * ALL data comes from project.config (Supabase database) - NO HARDCODED DEFAULTS
1028
+ */
1029
+ /**
1030
+ * Validate project configuration with helpful error messages
1031
+ */
1032
+ function validateProjectConfig(config) {
1033
+ const errors = []
1034
+
1035
+ if (!config) {
1036
+ throw new Error('Project configuration not found. Please configure your project in the dashboard.')
1037
+ }
1038
+
1039
+ // Check required fields
1040
+ if (!config.content?.default_word_count) {
1041
+ errors.push('Word count: Not set → Dashboard → Project Settings → Content')
1042
+ } else if (typeof config.content.default_word_count !== 'number' || config.content.default_word_count < 100) {
1043
+ errors.push('Word count: Must be at least 100 words')
1044
+ } else if (config.content.default_word_count > 10000) {
1045
+ errors.push('Word count: Maximum 10,000 words supported')
1046
+ }
1047
+
1048
+ if (!config.brand?.voice) {
1049
+ errors.push('Brand voice: Not set → Dashboard → Project Settings → Brand')
1050
+ }
1051
+
1052
+ if (!config.site?.niche) {
1053
+ errors.push('Niche: Not set → Dashboard → Project Settings → Site')
1054
+ }
1055
+
1056
+ // Warnings (non-blocking but helpful)
1057
+ const warnings = []
1058
+ if (!config.seo?.primary_keywords?.length) {
1059
+ warnings.push('No primary keywords set - content may lack SEO focus')
1060
+ }
1061
+ if (!config.brand?.target_audience) {
1062
+ warnings.push('No target audience set - content may be too generic')
1063
+ }
1064
+
1065
+ if (errors.length > 0) {
1066
+ throw new Error(`Project configuration incomplete:\n${errors.map(e => ` • ${e}`).join('\n')}`)
1067
+ }
1068
+
1069
+ return { warnings }
1070
+ }
1071
+
1072
+ function buildWorkflowPlan(request, count, publishTo, withImages, project) {
1073
+ const steps = []
1074
+ const hasGhost = hasCredential('ghost')
1075
+ const hasWordPress = hasCredential('wordpress')
1076
+ const hasImageGen = hasCredential('image')
1077
+
1078
+ // Get project config from database - MUST be dynamic, no hardcoding
1079
+ const config = project?.config
1080
+
1081
+ // Validate configuration with helpful messages
1082
+ const { warnings } = validateProjectConfig(config)
1083
+ if (warnings.length > 0) {
1084
+ log(`Config warnings: ${warnings.join('; ')}`)
1085
+ }
1086
+
1087
+ // Extract all settings from project.config (database schema)
1088
+ const targetWordCount = config.content?.default_word_count
1089
+ const readingLevel = config.content?.reading_level
1090
+ const includeImages = config.content?.include_images
1091
+ const brandVoice = config.brand?.voice
1092
+ const targetAudience = config.brand?.target_audience
1093
+ const differentiators = config.brand?.differentiators || []
1094
+ const visualStyle = config.visual_style?.image_aesthetic
1095
+ const brandColors = config.visual_style?.colors || []
1096
+ const primaryKeywords = config.seo?.primary_keywords || []
1097
+ const geoFocus = config.seo?.geo_focus
1098
+ const niche = config.site?.niche
1099
+ const siteName = config.site?.name
1100
+ const siteUrl = config.site?.url
1101
+ const siteDescription = config.site?.description
1102
+
1103
+ // Calculate required images: 1 cover + 1 per 300 words (only if includeImages is true)
1104
+ const shouldGenerateImages = withImages && includeImages && hasImageGen
1105
+ const contentImageCount = shouldGenerateImages ? Math.floor(targetWordCount / 300) : 0
1106
+ const totalImages = shouldGenerateImages ? 1 + contentImageCount : 0 // cover + inline images
1107
+
1108
+ // Format reading level for display (stored as number, display as "Grade X")
1109
+ const readingLevelDisplay = readingLevel ? `Grade ${readingLevel}` : 'Not set'
1110
+
1111
+ // Format keywords for display
1112
+ const keywordsDisplay = primaryKeywords.length > 0 ? primaryKeywords.join(', ') : 'No keywords set'
1113
+
1114
+ // Determine publish targets
1115
+ let targets = publishTo || []
1116
+ if (targets.length === 0 || targets.includes('all')) {
1117
+ targets = []
1118
+ if (hasGhost) targets.push('ghost')
1119
+ if (hasWordPress) targets.push('wordpress')
1120
+ }
1121
+
1122
+ let stepNum = 0
1123
+
1124
+ // Step 1: Keyword Research
1125
+ // Build dynamic MCP hints from local credentials (user-configured in credentials.json)
1126
+ const externalMcps = getExternalMCPs()
1127
+ const keywordResearchHints = getCompositionHints('keyword_research')
1128
+
1129
+ let mcpInstructions = ''
1130
+ if (externalMcps.length > 0) {
1131
+ const mcpList = externalMcps.map(m => `- **${m.name}**: ${m.available_tools?.join(', ') || 'tools available'}`).join('\n')
1132
+ mcpInstructions = `\n💡 **External MCPs Available (from your credentials.json):**\n${mcpList}`
1133
+ if (keywordResearchHints) {
1134
+ mcpInstructions += `\n\n**Integration Hint:** ${keywordResearchHints}`
1135
+ }
1136
+ }
1137
+
1138
+ stepNum++
1139
+ steps.push({
1140
+ step: stepNum,
1141
+ type: 'llm_execute',
1142
+ action: 'keyword_research',
1143
+ instruction: `Research keywords for: "${request}"
1144
+
1145
+ **Project Context (from database):**
1146
+ - Site: ${siteName} (${siteUrl})
1147
+ - Niche: ${niche}
1148
+ - Description: ${siteDescription || 'Not set'}
1149
+ - Primary keywords: ${keywordsDisplay}
1150
+ - Geographic focus: ${geoFocus || 'Global'}
1151
+ ${mcpInstructions}
1152
+
1153
+ **Deliverables:**
1154
+ - 1 primary keyword to target (lower difficulty preferred)
1155
+ - 3-5 secondary/LSI keywords
1156
+ - 2-3 question-based keywords for FAQ section`,
1157
+ store: 'keywords'
1158
+ })
1159
+
1160
+ // Step 2: Content Planning with SEO Meta
1161
+ stepNum++
1162
+ steps.push({
1163
+ step: stepNum,
1164
+ type: 'llm_execute',
1165
+ action: 'content_planning',
1166
+ instruction: `Create a detailed content outline with SEO meta:
1167
+
1168
+ **Project Requirements (from database):**
1169
+ - Site: ${siteName}
1170
+ - Target audience: ${targetAudience || 'Not specified'}
1171
+ - Brand voice: ${brandVoice}
1172
+ - Brand differentiators: ${differentiators.length > 0 ? differentiators.join(', ') : 'Not set'}
1173
+ - Word count: **${targetWordCount} words MINIMUM** (this is required!)
1174
+ - Reading level: **${readingLevelDisplay}** (use simple sentences, avoid jargon)
1175
+
1176
+ **You MUST create:**
1177
+
1178
+ 1. **SEO Meta Title** (50-60 characters, include primary keyword)
1179
+ 2. **SEO Meta Description** (150-160 characters, compelling, include keyword)
1180
+ 3. **URL Slug** (lowercase, hyphens, keyword-rich)
1181
+ 4. **Content Outline:**
1182
+ - H1: Main title
1183
+ - 6-8 H2 sections (to achieve ${targetWordCount} words)
1184
+ - H3 subsections where needed
1185
+ - FAQ section with 4-5 questions
1186
+
1187
+ ${shouldGenerateImages ? `**Image Placeholders:** Mark where ${contentImageCount} inline images should go (1 every ~300 words)
1188
+ Use format: [IMAGE: description of what image should show]` : '**Note:** Images disabled for this project.'}`,
1189
+ store: 'outline'
1190
+ })
1191
+
1192
+ // Step 3: Write Content
1193
+ stepNum++
1194
+ steps.push({
1195
+ step: stepNum,
1196
+ type: 'llm_execute',
1197
+ action: 'content_write',
1198
+ instruction: `Write the COMPLETE article following your outline.
1199
+
1200
+ **⚠️ CRITICAL REQUIREMENTS (from project database):**
1201
+ - Word count: **${targetWordCount} words MINIMUM** - Count your words!
1202
+ - Reading level: **${readingLevelDisplay}** - Simple sentences, short paragraphs, no jargon
1203
+ - Brand voice: ${brandVoice}
1204
+ - Target audience: ${targetAudience || 'General readers'}
1205
+
1206
+ **Content Structure:**
1207
+ - Engaging hook in first 2 sentences
1208
+ - All H2/H3 sections from your outline
1209
+ - Statistics, examples, and actionable tips in each section
1210
+ ${shouldGenerateImages ? '- Image placeholders: [IMAGE: description] where images should go' : ''}
1211
+ - FAQ section with 4-5 Q&As
1212
+ - Strong conclusion with clear CTA
1213
+
1214
+ **After writing, call 'save_content' with:**
1215
+ - title: Your SEO-optimized title
1216
+ - content: The full article (markdown)
1217
+ - keywords: Array of target keywords
1218
+ - meta_description: Your 150-160 char meta description
1219
+
1220
+ ⚠️ DO NOT proceed until you've written ${targetWordCount}+ words!`,
1221
+ store: 'article'
1222
+ })
1223
+
1224
+ // Step 4: Generate Images (if enabled in project settings AND credentials available)
1225
+ if (shouldGenerateImages) {
1226
+ // Format brand colors for image style guidance
1227
+ const colorsDisplay = brandColors.length > 0 ? brandColors.join(', ') : 'Not specified'
1228
+
1229
+ stepNum++
1230
+ steps.push({
1231
+ step: stepNum,
1232
+ type: 'llm_execute',
1233
+ action: 'generate_images',
1234
+ instruction: `Generate ${totalImages} images for the article:
1235
+
1236
+ **Required Images:**
1237
+ 1. **Cover/Hero Image** - Main article header (16:9 aspect ratio)
1238
+ ${Array.from({length: contentImageCount}, (_, i) => `${i + 2}. **Section Image ${i + 1}** - For content section ${i + 1} (16:9 aspect ratio)`).join('\n')}
1239
+
1240
+ **For each image, call 'generate_image' tool with:**
1241
+ - prompt: Detailed description based on article content
1242
+ - style: ${visualStyle || 'professional minimalist'}
1243
+ - aspect_ratio: 16:9
1244
+
1245
+ **Visual Style (from project database):**
1246
+ - Image aesthetic: ${visualStyle || 'Not specified'}
1247
+ - Brand colors: ${colorsDisplay}
1248
+ - Keep consistent with ${siteName} brand identity
1249
+
1250
+ **Image Style Guide:**
1251
+ - Professional, clean aesthetic
1252
+ - Relevant to the section topic
1253
+ - No text in images
1254
+ - Consistent style across all images
1255
+
1256
+ After generating, note the URLs - they will be saved automatically for publishing.`,
1257
+ image_count: totalImages,
1258
+ store: 'images'
1259
+ })
1260
+ }
1261
+
1262
+ // Step 5: Publish
1263
+ if (targets.length > 0) {
1264
+ stepNum++
1265
+ steps.push({
1266
+ step: stepNum,
1267
+ type: 'action',
1268
+ action: 'publish',
1269
+ instruction: `Publish the article to: ${targets.join(', ')}
1270
+
1271
+ Call 'publish_content' tool - it will automatically use:
1272
+ - Saved article title and content
1273
+ - SEO meta description
1274
+ - Generated images (cover + inline)
1275
+ - Target keywords as tags`,
1276
+ targets: targets
1277
+ })
1278
+ }
1279
+
1280
+ return {
1281
+ workflow_id: `wf_${Date.now()}`,
1282
+ request: request,
1283
+ total_articles: count,
1284
+ current_article: 1,
1285
+ total_steps: steps.length,
1286
+ current_step: 1,
1287
+ // All settings come from project.config (database) - no hardcoded values
1288
+ project_info: {
1289
+ name: siteName,
1290
+ url: siteUrl,
1291
+ niche: niche
1292
+ },
1293
+ settings: {
1294
+ target_word_count: targetWordCount,
1295
+ reading_level: readingLevel,
1296
+ reading_level_display: readingLevelDisplay,
1297
+ brand_voice: brandVoice,
1298
+ target_audience: targetAudience,
1299
+ include_images: includeImages,
1300
+ total_images: totalImages,
1301
+ content_images: contentImageCount,
1302
+ visual_style: visualStyle,
1303
+ primary_keywords: primaryKeywords,
1304
+ geo_focus: geoFocus
1305
+ },
1306
+ available_integrations: {
1307
+ external_mcps: externalMcps.map(m => m.name),
1308
+ ghost: hasGhost,
1309
+ wordpress: hasWordPress,
1310
+ image_generation: hasImageGen
1311
+ },
1312
+ steps: steps
1313
+ }
1314
+ }
1315
+
1316
+ /**
1317
+ * Execute orchestrator tools
1318
+ */
1319
+ async function executeOrchestratorTool(toolName, args, project) {
1320
+ switch (toolName) {
1321
+ case 'create_content': {
1322
+ resetSession()
1323
+ const { request = '', count = 1, publish_to = [], with_images = true } = args
1324
+
1325
+ const plan = buildWorkflowPlan(
1326
+ request || `content about ${project?.niche || 'the project topic'}`,
1327
+ count,
1328
+ publish_to,
1329
+ with_images,
1330
+ project
1331
+ )
1332
+
1333
+ sessionState.currentWorkflow = plan
1334
+
1335
+ // Persist session to file for workflow continuity
1336
+ saveSession()
1337
+
1338
+ // Build response with clear instructions - all data from database
1339
+ const mcpList = plan.available_integrations.external_mcps.length > 0
1340
+ ? plan.available_integrations.external_mcps.join(', ')
1341
+ : 'None configured'
1342
+
1343
+ let response = `# 🚀 Content Creation Workflow Started
1344
+
1345
+ ## Your Request
1346
+ "${plan.request}"
1347
+
1348
+ ## Project: ${plan.project_info.name}
1349
+ - **URL:** ${plan.project_info.url}
1350
+ - **Niche:** ${plan.project_info.niche}
1351
+
1352
+ ## Content Settings (from database)
1353
+ | Setting | Value |
1354
+ |---------|-------|
1355
+ | **Word Count** | ${plan.settings.target_word_count} words |
1356
+ | **Reading Level** | ${plan.settings.reading_level_display} |
1357
+ | **Brand Voice** | ${plan.settings.brand_voice} |
1358
+ | **Target Audience** | ${plan.settings.target_audience || 'Not specified'} |
1359
+ | **Primary Keywords** | ${plan.settings.primary_keywords?.join(', ') || 'Not set'} |
1360
+ | **Geographic Focus** | ${plan.settings.geo_focus || 'Global'} |
1361
+ | **Visual Style** | ${plan.settings.visual_style || 'Not specified'} |
1362
+ | **Include Images** | ${plan.settings.include_images ? 'Yes' : 'No'} |
1363
+ | **Images Required** | ${plan.settings.total_images} (1 cover + ${plan.settings.content_images} inline) |
1364
+
1365
+ ## Workflow Plan
1366
+ ${plan.steps.map(s => `${s.step}. **${s.action}** ${s.type === 'action' ? '(automatic)' : '(you execute)'}`).join('\n')}
1367
+
1368
+ ## Available Integrations (from ~/.suparank/credentials.json)
1369
+ - External MCPs: ${mcpList}
1370
+ - Image Generation: ${plan.available_integrations.image_generation ? '✅ Ready' : '❌ Not configured'}
1371
+ - Ghost CMS: ${plan.available_integrations.ghost ? '✅ Ready' : '❌ Not configured'}
1372
+ - WordPress: ${plan.available_integrations.wordpress ? '✅ Ready' : '❌ Not configured'}
1373
+
1374
+ ---
1375
+
1376
+ ## Step 1 of ${plan.total_steps}: ${plan.steps[0].action.toUpperCase()}
1377
+
1378
+ ${plan.steps[0].instruction}
1379
+
1380
+ ---
1381
+
1382
+ **When you complete this step, move to Step 2.**
1383
+ `
1384
+
1385
+ return {
1386
+ content: [{
1387
+ type: 'text',
1388
+ text: response
1389
+ }]
1390
+ }
1391
+ }
1392
+
1393
+ case 'save_content': {
1394
+ const { title, content, keywords = [], meta_description = '' } = args
1395
+
1396
+ sessionState.title = title
1397
+ sessionState.article = content
1398
+ sessionState.keywords = keywords
1399
+ sessionState.metaDescription = meta_description
1400
+ sessionState.metadata = { meta_description }
1401
+
1402
+ // Persist session to file and save to content folder
1403
+ saveSession()
1404
+ const contentFolder = saveContentToFolder()
1405
+
1406
+ const wordCount = content.split(/\s+/).length
1407
+ progress('Content', `Saved "${title}" (${wordCount} words)${contentFolder ? ` → ${contentFolder}` : ''}`)
1408
+ const workflow = sessionState.currentWorkflow
1409
+ const targetWordCount = workflow?.settings?.target_word_count
1410
+ const wordCountOk = targetWordCount ? wordCount >= targetWordCount * 0.9 : true // Allow 10% tolerance
1411
+
1412
+ // Find next step
1413
+ const imageStep = workflow?.steps?.find(s => s.action === 'generate_images')
1414
+ const totalImages = workflow?.settings?.total_images || 0
1415
+ const includeImages = workflow?.settings?.include_images
1416
+
1417
+ // Fetch WordPress categories for intelligent assignment
1418
+ let categoriesSection = ''
1419
+ if (hasCredential('wordpress')) {
1420
+ const wpCategories = await fetchWordPressCategories()
1421
+ if (wpCategories && wpCategories.length > 0) {
1422
+ const categoryList = wpCategories
1423
+ .slice(0, 15) // Show top 15 by post count
1424
+ .map(c => `- **${c.name}** (${c.count} posts)${c.description ? `: ${c.description}` : ''}`)
1425
+ .join('\n')
1426
+ categoriesSection = `\n## WordPress Categories Available
1427
+ Pick the most relevant category when publishing:
1428
+ ${categoryList}
1429
+
1430
+ When calling \`publish_content\`, include the \`category\` parameter with your choice.\n`
1431
+ }
1432
+ }
1433
+
1434
+ return {
1435
+ content: [{
1436
+ type: 'text',
1437
+ text: `# ✅ Content Saved to Session
1438
+
1439
+ **Title:** ${title}
1440
+ **Word Count:** ${wordCount} words ${targetWordCount ? (wordCountOk ? '✅' : `⚠️ (target: ${targetWordCount})`) : '(no target set)'}
1441
+ **Meta Description:** ${meta_description ? `${meta_description.length} chars ✅` : '❌ Missing!'}
1442
+ **Keywords:** ${keywords.join(', ') || 'none specified'}
1443
+
1444
+ ${targetWordCount && !wordCountOk ? `⚠️ **Warning:** Article is ${targetWordCount - wordCount} words short of the ${targetWordCount} word target.\n` : ''}
1445
+ ${!meta_description ? '⚠️ **Warning:** Meta description is missing. Add it for better SEO.\n' : ''}
1446
+ ${categoriesSection}
1447
+ ## Next Step${includeImages && imageStep ? ': Generate Images' : ': Publish'}
1448
+ ${includeImages && imageStep ? `Generate **${totalImages} images** (1 cover + ${totalImages - 1} inline images).
1449
+
1450
+ Call \`generate_image\` ${totalImages} times with prompts based on your article sections.` : 'Proceed to publish with \`publish_content\`.'}`
1451
+ }]
1452
+ }
1453
+ }
1454
+
1455
+ case 'publish_content': {
1456
+ const { platforms = ['all'], status = 'draft', category = '' } = args
1457
+
1458
+ if (!sessionState.article || !sessionState.title) {
1459
+ return {
1460
+ content: [{
1461
+ type: 'text',
1462
+ text: '❌ No content saved. Please use save_content first to save your article.'
1463
+ }]
1464
+ }
1465
+ }
1466
+
1467
+ // Inject inline images into content (replace [IMAGE: ...] placeholders)
1468
+ let contentWithImages = sessionState.article
1469
+ let imageIndex = 0
1470
+ contentWithImages = contentWithImages.replace(/\[IMAGE:\s*([^\]]+)\]/gi, (match, description) => {
1471
+ if (imageIndex < sessionState.inlineImages.length) {
1472
+ const imgUrl = sessionState.inlineImages[imageIndex]
1473
+ imageIndex++
1474
+ return `![${description.trim()}](${imgUrl})`
1475
+ }
1476
+ return match // Keep placeholder if no image available
1477
+ })
1478
+
1479
+ const results = []
1480
+ const hasGhost = hasCredential('ghost')
1481
+ const hasWordPress = hasCredential('wordpress')
1482
+
1483
+ const shouldPublishGhost = hasGhost && (platforms.includes('all') || platforms.includes('ghost'))
1484
+ const shouldPublishWordPress = hasWordPress && (platforms.includes('all') || platforms.includes('wordpress'))
1485
+
1486
+ // Publish to Ghost
1487
+ if (shouldPublishGhost) {
1488
+ try {
1489
+ const ghostResult = await executeGhostPublish({
1490
+ title: sessionState.title,
1491
+ content: contentWithImages,
1492
+ status: status,
1493
+ tags: sessionState.keywords || [],
1494
+ featured_image_url: sessionState.imageUrl
1495
+ })
1496
+ results.push({ platform: 'Ghost', success: true, result: ghostResult })
1497
+ } catch (e) {
1498
+ results.push({ platform: 'Ghost', success: false, error: e.message })
1499
+ }
1500
+ }
1501
+
1502
+ // Publish to WordPress with intelligent category assignment
1503
+ if (shouldPublishWordPress) {
1504
+ try {
1505
+ // Use selected category or empty array
1506
+ const categories = category ? [category] : []
1507
+ log(`Publishing to WordPress with category: ${category || '(none selected)'}`)
1508
+
1509
+ const wpResult = await executeWordPressPublish({
1510
+ title: sessionState.title,
1511
+ content: contentWithImages,
1512
+ status: status,
1513
+ categories: categories,
1514
+ tags: sessionState.keywords || [],
1515
+ featured_image_url: sessionState.imageUrl
1516
+ })
1517
+ results.push({ platform: 'WordPress', success: true, result: wpResult, category: category || null })
1518
+ } catch (e) {
1519
+ results.push({ platform: 'WordPress', success: false, error: e.message })
1520
+ }
1521
+ }
1522
+
1523
+ // Format response
1524
+ const wordCount = contentWithImages.split(/\s+/).length
1525
+ const inlineImagesUsed = imageIndex
1526
+
1527
+ let response = `# 📤 Publishing Results
1528
+
1529
+ ## Content Summary
1530
+ - **Title:** ${sessionState.title}
1531
+ - **Word Count:** ${wordCount} words
1532
+ - **Meta Description:** ${sessionState.metaDescription ? '✅ Included' : '❌ Missing'}
1533
+ - **Cover Image:** ${sessionState.imageUrl ? '✅ Set' : '❌ Missing'}
1534
+ - **Inline Images:** ${inlineImagesUsed} injected into content
1535
+ - **Keywords/Tags:** ${sessionState.keywords?.join(', ') || 'None'}
1536
+ - **Category:** ${category || 'Not specified'}
1537
+
1538
+ `
1539
+
1540
+ for (const r of results) {
1541
+ if (r.success) {
1542
+ response += `## ✅ ${r.platform}\n${r.result.content[0].text}\n\n`
1543
+ } else {
1544
+ response += `## ❌ ${r.platform}\nError: ${r.error}\n\n`
1545
+ }
1546
+ }
1547
+
1548
+ if (results.length === 0) {
1549
+ response += `No platforms configured or selected for publishing.\n`
1550
+ response += `Available: Ghost (${hasGhost ? 'yes' : 'no'}), WordPress (${hasWordPress ? 'yes' : 'no'})`
1551
+ }
1552
+
1553
+ // Clear session after successful publish (at least one success)
1554
+ const hasSuccessfulPublish = results.some(r => r.success)
1555
+ if (hasSuccessfulPublish) {
1556
+ clearSessionFile()
1557
+ resetSession()
1558
+ response += '\n---\n✅ Session cleared. Ready for new content.'
1559
+ }
1560
+
1561
+ return {
1562
+ content: [{
1563
+ type: 'text',
1564
+ text: response
1565
+ }]
1566
+ }
1567
+ }
1568
+
1569
+ case 'get_session': {
1570
+ const totalImagesNeeded = sessionState.currentWorkflow?.settings?.total_images || 0
1571
+ const imagesGenerated = (sessionState.imageUrl ? 1 : 0) + sessionState.inlineImages.length
1572
+ const workflow = sessionState.currentWorkflow
1573
+
1574
+ return {
1575
+ content: [{
1576
+ type: 'text',
1577
+ text: `# 📋 Current Session State
1578
+
1579
+ **Workflow:** ${workflow?.workflow_id || 'None active'}
1580
+
1581
+ ## Content
1582
+ **Title:** ${sessionState.title || 'Not set'}
1583
+ **Article:** ${sessionState.article ? `${sessionState.article.split(/\s+/).length} words` : 'Not saved'}
1584
+ **Meta Description:** ${sessionState.metaDescription || 'Not set'}
1585
+ **Keywords:** ${sessionState.keywords?.join(', ') || 'None'}
1586
+
1587
+ ## Images (${imagesGenerated}/${totalImagesNeeded})
1588
+ **Cover Image:** ${sessionState.imageUrl || 'Not generated'}
1589
+ **Inline Images:** ${sessionState.inlineImages.length > 0 ? sessionState.inlineImages.map((url, i) => `\n ${i+1}. ${url}`).join('') : 'None'}
1590
+
1591
+ ${workflow ? `
1592
+ ## Project Settings (from database)
1593
+ - **Project:** ${workflow.project_info?.name || 'Unknown'}
1594
+ - **Niche:** ${workflow.project_info?.niche || 'Unknown'}
1595
+ - **Word Count Target:** ${workflow.settings?.target_word_count || 'Not set'}
1596
+ - **Reading Level:** ${workflow.settings?.reading_level_display || 'Not set'}
1597
+ - **Brand Voice:** ${workflow.settings?.brand_voice || 'Not set'}
1598
+ - **Visual Style:** ${workflow.settings?.visual_style || 'Not set'}
1599
+ - **Include Images:** ${workflow.settings?.include_images ? 'Yes' : 'No'}
1600
+ - **Total Images:** ${totalImagesNeeded}
1601
+ ` : ''}`
1602
+ }]
1603
+ }
1604
+ }
1605
+
1606
+ default:
1607
+ throw new Error(`Unknown orchestrator tool: ${toolName}`)
1608
+ }
1609
+ }
1610
+
1611
+ /**
1612
+ * Execute an action tool locally using credentials
1613
+ */
1614
+ async function executeActionTool(toolName, args) {
1615
+ switch (toolName) {
1616
+ case 'generate_image':
1617
+ return await executeImageGeneration(args)
1618
+ case 'publish_wordpress':
1619
+ return await executeWordPressPublish(args)
1620
+ case 'publish_ghost':
1621
+ return await executeGhostPublish(args)
1622
+ case 'send_webhook':
1623
+ return await executeSendWebhook(args)
1624
+ default:
1625
+ throw new Error(`Unknown action tool: ${toolName}`)
1626
+ }
1627
+ }
1628
+
1629
+ /**
1630
+ * Generate image using configured provider
1631
+ */
1632
+ async function executeImageGeneration(args) {
1633
+ const provider = localCredentials.image_provider
1634
+ const config = localCredentials[provider]
1635
+
1636
+ if (!config?.api_key) {
1637
+ throw new Error(`${provider} API key not configured`)
1638
+ }
1639
+
1640
+ progress('Image', `Generating with ${provider}...`)
1641
+
1642
+ const { prompt, style, aspect_ratio = '16:9' } = args
1643
+ const fullPrompt = style ? `${prompt}, ${style}` : prompt
1644
+
1645
+ log(`Generating image with ${provider}: ${fullPrompt.substring(0, 50)}...`)
1646
+
1647
+ switch (provider) {
1648
+ case 'fal': {
1649
+ // fal.ai Nano Banana Pro (gemini-3-pro-image)
1650
+ const response = await fetchWithRetry('https://fal.run/fal-ai/nano-banana-pro', {
1651
+ method: 'POST',
1652
+ headers: {
1653
+ 'Authorization': `Key ${config.api_key}`,
1654
+ 'Content-Type': 'application/json'
1655
+ },
1656
+ body: JSON.stringify({
1657
+ prompt: fullPrompt,
1658
+ aspect_ratio: aspect_ratio,
1659
+ output_format: 'png',
1660
+ resolution: '1K',
1661
+ num_images: 1
1662
+ })
1663
+ }, 2, 60000) // 2 retries, 60s timeout for image generation
1664
+
1665
+ if (!response.ok) {
1666
+ const error = await response.text()
1667
+ throw new Error(`fal.ai error: ${error}`)
1668
+ }
1669
+
1670
+ const result = await response.json()
1671
+ const imageUrl = result.images?.[0]?.url
1672
+
1673
+ // Store in session for orchestrated workflows
1674
+ // First image is cover, subsequent are inline
1675
+ if (!sessionState.imageUrl) {
1676
+ sessionState.imageUrl = imageUrl
1677
+ } else {
1678
+ sessionState.inlineImages.push(imageUrl)
1679
+ }
1680
+
1681
+ // Persist session to file
1682
+ saveSession()
1683
+
1684
+ const imageNumber = 1 + sessionState.inlineImages.length
1685
+ const totalImages = sessionState.currentWorkflow?.settings?.total_images || 1
1686
+ const imageType = imageNumber === 1 ? 'Cover Image' : `Inline Image ${imageNumber - 1}`
1687
+
1688
+ return {
1689
+ content: [{
1690
+ type: 'text',
1691
+ text: `# ✅ ${imageType} Generated (${imageNumber}/${totalImages})
1692
+
1693
+ **URL:** ${imageUrl}
1694
+
1695
+ **Prompt:** ${fullPrompt}
1696
+ **Provider:** fal.ai (nano-banana-pro)
1697
+ **Aspect Ratio:** ${aspect_ratio}
1698
+
1699
+ ${imageNumber < totalImages ? `\n**Next:** Generate ${totalImages - imageNumber} more image(s).` : '\n**All images generated!** Proceed to publish.'}`
1700
+ }]
1701
+ }
1702
+ }
1703
+
1704
+ case 'gemini': {
1705
+ // Google Gemini 3 Pro Image (Nano Banana Pro) - generateContent API
1706
+ const model = config.model || 'gemini-3-pro-image-preview'
1707
+ const response = await fetch(
1708
+ `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
1709
+ {
1710
+ method: 'POST',
1711
+ headers: {
1712
+ 'Content-Type': 'application/json',
1713
+ 'x-goog-api-key': config.api_key
1714
+ },
1715
+ body: JSON.stringify({
1716
+ contents: [{
1717
+ parts: [{ text: fullPrompt }]
1718
+ }],
1719
+ generationConfig: {
1720
+ responseModalities: ['IMAGE'],
1721
+ imageConfig: {
1722
+ aspectRatio: aspect_ratio,
1723
+ imageSize: '1K'
1724
+ }
1725
+ }
1726
+ })
1727
+ }
1728
+ )
1729
+
1730
+ if (!response.ok) {
1731
+ const error = await response.text()
1732
+ throw new Error(`Gemini error: ${error}`)
1733
+ }
1734
+
1735
+ const result = await response.json()
1736
+ const imagePart = result.candidates?.[0]?.content?.parts?.find(p => p.inlineData)
1737
+ const imageData = imagePart?.inlineData?.data
1738
+ const mimeType = imagePart?.inlineData?.mimeType || 'image/png'
1739
+
1740
+ if (!imageData) {
1741
+ throw new Error('No image data in Gemini response')
1742
+ }
1743
+
1744
+ // Return base64 data URI
1745
+ const dataUri = `data:${mimeType};base64,${imageData}`
1746
+
1747
+ return {
1748
+ content: [{
1749
+ type: 'text',
1750
+ text: `Image generated successfully!\n\n**Format:** Base64 Data URI\n**Prompt:** ${fullPrompt}\n**Provider:** Google Gemini (${model})\n**Aspect Ratio:** ${aspect_ratio}\n\n**Data URI:** ${dataUri.substring(0, 100)}...\n\n[Full base64 data: ${imageData.length} chars]`
1751
+ }]
1752
+ }
1753
+ }
1754
+
1755
+ case 'wiro': {
1756
+ // wiro.ai API with HMAC signature authentication
1757
+ const crypto = await import('crypto')
1758
+ const apiKey = config.api_key
1759
+ const apiSecret = config.api_secret
1760
+
1761
+ if (!apiSecret) {
1762
+ throw new Error('Wiro API secret not configured. Add api_secret to wiro config in ~/.suparank/credentials.json')
1763
+ }
1764
+
1765
+ // Generate nonce and signature
1766
+ const nonce = Math.floor(Date.now() / 1000).toString()
1767
+ const signatureData = `${apiSecret}${nonce}`
1768
+ const signature = crypto.createHmac('sha256', apiKey)
1769
+ .update(signatureData)
1770
+ .digest('hex')
1771
+
1772
+ const model = config.model || 'google/nano-banana-pro'
1773
+
1774
+ // Submit task
1775
+ log(`Submitting wiro.ai task for model: ${model}`)
1776
+ const submitResponse = await fetch(`https://api.wiro.ai/v1/Run/${model}`, {
1777
+ method: 'POST',
1778
+ headers: {
1779
+ 'Content-Type': 'application/json',
1780
+ 'x-api-key': apiKey,
1781
+ 'x-nonce': nonce,
1782
+ 'x-signature': signature
1783
+ },
1784
+ body: JSON.stringify({
1785
+ prompt: fullPrompt,
1786
+ aspectRatio: aspect_ratio,
1787
+ resolution: '1K',
1788
+ safetySetting: 'BLOCK_ONLY_HIGH'
1789
+ })
1790
+ })
1791
+
1792
+ if (!submitResponse.ok) {
1793
+ const error = await submitResponse.text()
1794
+ throw new Error(`wiro.ai submit error: ${error}`)
1795
+ }
1796
+
1797
+ const submitResult = await submitResponse.json()
1798
+ if (!submitResult.result || !submitResult.taskid) {
1799
+ throw new Error(`wiro.ai task submission failed: ${JSON.stringify(submitResult.errors)}`)
1800
+ }
1801
+
1802
+ const taskId = submitResult.taskid
1803
+ log(`wiro.ai task submitted: ${taskId}`)
1804
+
1805
+ // Poll for completion
1806
+ const maxAttempts = 60 // 60 seconds max
1807
+ const pollInterval = 2000 // 2 seconds
1808
+
1809
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1810
+ await new Promise(resolve => setTimeout(resolve, pollInterval))
1811
+
1812
+ // Generate new signature for poll request
1813
+ const pollNonce = Math.floor(Date.now() / 1000).toString()
1814
+ const pollSignatureData = `${apiSecret}${pollNonce}`
1815
+ const pollSignature = crypto.createHmac('sha256', apiKey)
1816
+ .update(pollSignatureData)
1817
+ .digest('hex')
1818
+
1819
+ const pollResponse = await fetch('https://api.wiro.ai/v1/Task/Detail', {
1820
+ method: 'POST',
1821
+ headers: {
1822
+ 'Content-Type': 'application/json',
1823
+ 'x-api-key': apiKey,
1824
+ 'x-nonce': pollNonce,
1825
+ 'x-signature': pollSignature
1826
+ },
1827
+ body: JSON.stringify({ taskid: taskId })
1828
+ })
1829
+
1830
+ if (!pollResponse.ok) {
1831
+ log(`wiro.ai poll error: ${await pollResponse.text()}`)
1832
+ continue
1833
+ }
1834
+
1835
+ const pollResult = await pollResponse.json()
1836
+ const task = pollResult.tasklist?.[0]
1837
+
1838
+ if (!task) continue
1839
+
1840
+ const status = task.status
1841
+ log(`wiro.ai task status: ${status}`)
1842
+
1843
+ // Check for completion
1844
+ if (status === 'task_postprocess_end') {
1845
+ const imageUrl = task.outputs?.[0]?.url
1846
+ if (!imageUrl) {
1847
+ throw new Error('wiro.ai task completed but no output URL')
1848
+ }
1849
+
1850
+ // Store in session for orchestrated workflows
1851
+ // First image is cover, subsequent are inline
1852
+ if (!sessionState.imageUrl) {
1853
+ sessionState.imageUrl = imageUrl
1854
+ } else {
1855
+ sessionState.inlineImages.push(imageUrl)
1856
+ }
1857
+
1858
+ // Persist session to file
1859
+ saveSession()
1860
+
1861
+ const imageNumber = 1 + sessionState.inlineImages.length
1862
+ const totalImages = sessionState.currentWorkflow?.settings?.total_images || 1
1863
+ const imageType = imageNumber === 1 ? 'Cover Image' : `Inline Image ${imageNumber - 1}`
1864
+
1865
+ return {
1866
+ content: [{
1867
+ type: 'text',
1868
+ text: `# ✅ ${imageType} Generated (${imageNumber}/${totalImages})
1869
+
1870
+ **URL:** ${imageUrl}
1871
+
1872
+ **Prompt:** ${fullPrompt}
1873
+ **Provider:** wiro.ai (${model})
1874
+ **Aspect Ratio:** ${aspect_ratio}
1875
+ **Processing Time:** ${task.elapsedseconds}s
1876
+
1877
+ ${imageNumber < totalImages ? `\n**Next:** Generate ${totalImages - imageNumber} more image(s).` : '\n**All images generated!** Proceed to publish.'}`
1878
+ }]
1879
+ }
1880
+ }
1881
+
1882
+ // Check for failure
1883
+ if (status === 'task_cancel') {
1884
+ throw new Error('wiro.ai task was cancelled')
1885
+ }
1886
+ }
1887
+
1888
+ throw new Error('wiro.ai task timed out after 60 seconds')
1889
+ }
1890
+
1891
+ default:
1892
+ throw new Error(`Unknown image provider: ${provider}`)
1893
+ }
1894
+ }
1895
+
1896
+ /**
1897
+ * Convert aspect ratio string to fal.ai image size
1898
+ */
1899
+ function aspectRatioToSize(ratio) {
1900
+ const sizes = {
1901
+ '1:1': 'square',
1902
+ '16:9': 'landscape_16_9',
1903
+ '9:16': 'portrait_16_9',
1904
+ '4:3': 'landscape_4_3',
1905
+ '3:4': 'portrait_4_3'
1906
+ }
1907
+ return sizes[ratio] || 'landscape_16_9'
1908
+ }
1909
+
1910
+ /**
1911
+ * Convert markdown to HTML using marked library
1912
+ * Configured for WordPress/Ghost CMS compatibility
1913
+ */
1914
+ function markdownToHtml(markdown) {
1915
+ // Configure marked for CMS compatibility
1916
+ marked.setOptions({
1917
+ gfm: true, // GitHub Flavored Markdown
1918
+ breaks: true, // Convert line breaks to <br>
1919
+ pedantic: false,
1920
+ silent: true // Don't throw on errors
1921
+ })
1922
+
1923
+ try {
1924
+ return marked.parse(markdown)
1925
+ } catch (error) {
1926
+ log(`Markdown conversion error: ${error.message}`)
1927
+ // Fallback: return markdown wrapped in <p> tags
1928
+ return `<p>${markdown.replace(/\n\n+/g, '</p><p>')}</p>`
1929
+ }
1930
+ }
1931
+
1932
+ /**
1933
+ * Fetch available categories from WordPress
1934
+ */
1935
+ async function fetchWordPressCategories() {
1936
+ const wpConfig = localCredentials?.wordpress
1937
+ if (!wpConfig?.secret_key || !wpConfig?.site_url) {
1938
+ return null
1939
+ }
1940
+
1941
+ try {
1942
+ log('Fetching WordPress categories...')
1943
+
1944
+ // Try new Suparank endpoint first, then fall back to legacy
1945
+ const endpoints = [
1946
+ { url: `${wpConfig.site_url}/wp-json/suparank/v1/categories`, header: 'X-Suparank-Key' },
1947
+ { url: `${wpConfig.site_url}/wp-json/writer-mcp/v1/categories`, header: 'X-Writer-MCP-Key' }
1948
+ ]
1949
+
1950
+ for (const endpoint of endpoints) {
1951
+ try {
1952
+ const response = await fetchWithTimeout(endpoint.url, {
1953
+ method: 'GET',
1954
+ headers: {
1955
+ [endpoint.header]: wpConfig.secret_key
1956
+ }
1957
+ }, 10000) // 10s timeout
1958
+
1959
+ if (response.ok) {
1960
+ const result = await response.json()
1961
+ if (result.success && result.categories) {
1962
+ log(`Found ${result.categories.length} WordPress categories`)
1963
+ return result.categories
1964
+ }
1965
+ }
1966
+ } catch (e) {
1967
+ // Try next endpoint
1968
+ }
1969
+ }
1970
+
1971
+ log('Failed to fetch categories from any endpoint')
1972
+ return null
1973
+ } catch (error) {
1974
+ log(`Error fetching categories: ${error.message}`)
1975
+ return null
1976
+ }
1977
+ }
1978
+
1979
+ /**
1980
+ * Publish to WordPress using REST API or custom plugin
1981
+ */
1982
+ async function executeWordPressPublish(args) {
1983
+ const wpConfig = localCredentials.wordpress
1984
+ const { title, content, status = 'draft', categories = [], tags = [], featured_image_url } = args
1985
+
1986
+ progress('Publish', `Publishing to WordPress: "${title}"`)
1987
+ log(`Publishing to WordPress: ${title}`)
1988
+
1989
+ // Convert markdown to HTML for WordPress
1990
+ const htmlContent = markdownToHtml(content)
1991
+
1992
+ // Method 1: Use Suparank Connector plugin (secret_key auth)
1993
+ if (wpConfig.secret_key) {
1994
+ log('Using Suparank/Writer MCP Connector plugin')
1995
+
1996
+ // Try new Suparank endpoint first, then fall back to legacy
1997
+ const endpoints = [
1998
+ { url: `${wpConfig.site_url}/wp-json/suparank/v1/publish`, header: 'X-Suparank-Key' },
1999
+ { url: `${wpConfig.site_url}/wp-json/writer-mcp/v1/publish`, header: 'X-Writer-MCP-Key' }
2000
+ ]
2001
+
2002
+ const postBody = JSON.stringify({
2003
+ title,
2004
+ content: htmlContent,
2005
+ status,
2006
+ categories,
2007
+ tags,
2008
+ featured_image_url,
2009
+ excerpt: sessionState.metaDescription || ''
2010
+ })
2011
+
2012
+ let lastError = null
2013
+ for (const endpoint of endpoints) {
2014
+ try {
2015
+ const response = await fetchWithRetry(endpoint.url, {
2016
+ method: 'POST',
2017
+ headers: {
2018
+ 'Content-Type': 'application/json',
2019
+ [endpoint.header]: wpConfig.secret_key
2020
+ },
2021
+ body: postBody
2022
+ }, 2, 30000) // 2 retries, 30s timeout
2023
+
2024
+ if (response.ok) {
2025
+ const result = await response.json()
2026
+
2027
+ if (result.success) {
2028
+ const categoriesInfo = result.post.categories?.length
2029
+ ? `\n**Categories:** ${result.post.categories.join(', ')}`
2030
+ : ''
2031
+ const tagsInfo = result.post.tags?.length
2032
+ ? `\n**Tags:** ${result.post.tags.join(', ')}`
2033
+ : ''
2034
+ const imageInfo = result.post.featured_image
2035
+ ? `\n**Featured Image:** ✅ Uploaded`
2036
+ : ''
2037
+
2038
+ return {
2039
+ content: [{
2040
+ type: 'text',
2041
+ text: `Post published to WordPress!\n\n**Title:** ${result.post.title}\n**Status:** ${result.post.status}\n**URL:** ${result.post.url}\n**Edit:** ${result.post.edit_url}\n**ID:** ${result.post.id}${categoriesInfo}${tagsInfo}${imageInfo}\n\n${status === 'draft' ? 'The post is saved as a draft. Edit and publish from WordPress dashboard.' : 'The post is now live!'}`
2042
+ }]
2043
+ }
2044
+ }
2045
+ }
2046
+ lastError = await response.text()
2047
+ } catch (e) {
2048
+ lastError = e.message
2049
+ }
2050
+ }
2051
+
2052
+ throw new Error(`WordPress error: ${lastError}`)
2053
+ }
2054
+
2055
+ // Method 2: Use standard REST API with application password
2056
+ if (wpConfig.app_password && wpConfig.username) {
2057
+ log('Using WordPress REST API with application password')
2058
+
2059
+ const auth = Buffer.from(`${wpConfig.username}:${wpConfig.app_password}`).toString('base64')
2060
+ const postData = {
2061
+ title,
2062
+ content: htmlContent,
2063
+ status,
2064
+ categories: [],
2065
+ tags: []
2066
+ }
2067
+
2068
+ const response = await fetch(`${wpConfig.site_url}/wp-json/wp/v2/posts`, {
2069
+ method: 'POST',
2070
+ headers: {
2071
+ 'Authorization': `Basic ${auth}`,
2072
+ 'Content-Type': 'application/json'
2073
+ },
2074
+ body: JSON.stringify(postData)
2075
+ })
2076
+
2077
+ if (!response.ok) {
2078
+ const error = await response.text()
2079
+ throw new Error(`WordPress error: ${error}`)
2080
+ }
2081
+
2082
+ const post = await response.json()
2083
+
2084
+ return {
2085
+ content: [{
2086
+ type: 'text',
2087
+ text: `Post published to WordPress!\n\n**Title:** ${post.title.rendered}\n**Status:** ${post.status}\n**URL:** ${post.link}\n**ID:** ${post.id}\n\n${status === 'draft' ? 'The post is saved as a draft. Edit and publish from WordPress dashboard.' : 'The post is now live!'}`
2088
+ }]
2089
+ }
2090
+ }
2091
+
2092
+ throw new Error('WordPress credentials not configured. Add either secret_key (with plugin) or username + app_password to ~/.suparank/credentials.json')
2093
+ }
2094
+
2095
+ /**
2096
+ * Publish to Ghost using Admin API
2097
+ */
2098
+ async function executeGhostPublish(args) {
2099
+ const { api_url, admin_api_key } = localCredentials.ghost
2100
+ const { title, content, status = 'draft', tags = [], featured_image_url } = args
2101
+
2102
+ progress('Publish', `Publishing to Ghost: "${title}"`)
2103
+ log(`Publishing to Ghost: ${title}`)
2104
+
2105
+ // Create JWT for Ghost Admin API
2106
+ const [id, secret] = admin_api_key.split(':')
2107
+ const token = await createGhostJWT(id, secret)
2108
+
2109
+ // Convert markdown to HTML for proper element separation
2110
+ const htmlContent = markdownToHtml(content)
2111
+
2112
+ // Use HTML card for proper rendering (each element separate)
2113
+ const mobiledoc = JSON.stringify({
2114
+ version: '0.3.1',
2115
+ atoms: [],
2116
+ cards: [['html', { html: htmlContent }]],
2117
+ markups: [],
2118
+ sections: [[10, 0]]
2119
+ })
2120
+
2121
+ const postData = {
2122
+ posts: [{
2123
+ title,
2124
+ mobiledoc,
2125
+ status,
2126
+ tags: tags.map(name => ({ name })),
2127
+ feature_image: featured_image_url
2128
+ }]
2129
+ }
2130
+
2131
+ const response = await fetchWithRetry(`${api_url}/ghost/api/admin/posts/`, {
2132
+ method: 'POST',
2133
+ headers: {
2134
+ 'Authorization': `Ghost ${token}`,
2135
+ 'Content-Type': 'application/json'
2136
+ },
2137
+ body: JSON.stringify(postData)
2138
+ }, 2, 30000) // 2 retries, 30s timeout
2139
+
2140
+ if (!response.ok) {
2141
+ const error = await response.text()
2142
+ throw new Error(`Ghost error: ${error}`)
2143
+ }
2144
+
2145
+ const result = await response.json()
2146
+ const post = result.posts[0]
2147
+
2148
+ return {
2149
+ content: [{
2150
+ type: 'text',
2151
+ text: `Post published to Ghost!\n\n**Title:** ${post.title}\n**Status:** ${post.status}\n**URL:** ${post.url}\n**ID:** ${post.id}\n\n${status === 'draft' ? 'The post is saved as a draft. Edit and publish from Ghost dashboard.' : 'The post is now live!'}`
2152
+ }]
2153
+ }
2154
+ }
2155
+
2156
+ /**
2157
+ * Create JWT for Ghost Admin API
2158
+ */
2159
+ async function createGhostJWT(id, secret) {
2160
+ // Simple JWT creation for Ghost
2161
+ const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT', kid: id })).toString('base64url')
2162
+ const now = Math.floor(Date.now() / 1000)
2163
+ const payload = Buffer.from(JSON.stringify({
2164
+ iat: now,
2165
+ exp: now + 300, // 5 minutes
2166
+ aud: '/admin/'
2167
+ })).toString('base64url')
2168
+
2169
+ // Create signature using crypto
2170
+ const crypto = await import('crypto')
2171
+ const key = Buffer.from(secret, 'hex')
2172
+ const signature = crypto.createHmac('sha256', key)
2173
+ .update(`${header}.${payload}`)
2174
+ .digest('base64url')
2175
+
2176
+ return `${header}.${payload}.${signature}`
2177
+ }
2178
+
2179
+ /**
2180
+ * Send data to webhook
2181
+ */
2182
+ async function executeSendWebhook(args) {
2183
+ const { webhook_type = 'default', payload = {}, message } = args
2184
+ const webhooks = localCredentials.webhooks
2185
+
2186
+ // Get webhook URL
2187
+ const urlMap = {
2188
+ default: webhooks.default_url,
2189
+ make: webhooks.make_url,
2190
+ n8n: webhooks.n8n_url,
2191
+ zapier: webhooks.zapier_url,
2192
+ slack: webhooks.slack_url
2193
+ }
2194
+
2195
+ const url = urlMap[webhook_type]
2196
+ if (!url) {
2197
+ throw new Error(`No ${webhook_type} webhook URL configured`)
2198
+ }
2199
+
2200
+ log(`Sending webhook to ${webhook_type}: ${url}`)
2201
+
2202
+ // Format payload based on type
2203
+ let body
2204
+ let headers = { 'Content-Type': 'application/json' }
2205
+
2206
+ if (webhook_type === 'slack') {
2207
+ body = JSON.stringify({
2208
+ text: message || 'Message from Writer MCP',
2209
+ ...payload
2210
+ })
2211
+ } else {
2212
+ body = JSON.stringify({
2213
+ source: 'suparank',
2214
+ timestamp: new Date().toISOString(),
2215
+ project: projectSlug,
2216
+ data: payload,
2217
+ message
2218
+ })
2219
+ }
2220
+
2221
+ const response = await fetch(url, {
2222
+ method: 'POST',
2223
+ headers,
2224
+ body
2225
+ })
2226
+
2227
+ if (!response.ok) {
2228
+ const error = await response.text()
2229
+ throw new Error(`Webhook error (${response.status}): ${error}`)
2230
+ }
2231
+
2232
+ return {
2233
+ content: [{
2234
+ type: 'text',
2235
+ text: `Webhook sent successfully!\n\n**Type:** ${webhook_type}\n**URL:** ${url.substring(0, 50)}...\n**Status:** ${response.status}\n\nThe data has been sent to your ${webhook_type} webhook.`
2236
+ }]
2237
+ }
2238
+ }
2239
+
2240
+ /**
2241
+ * Get all available tools based on configured credentials
2242
+ */
2243
+ function getAvailableTools() {
2244
+ const tools = [...TOOLS]
2245
+
2246
+ // Add orchestrator tools (always available)
2247
+ for (const tool of ORCHESTRATOR_TOOLS) {
2248
+ tools.push({
2249
+ name: tool.name,
2250
+ description: tool.description,
2251
+ inputSchema: tool.inputSchema
2252
+ })
2253
+ }
2254
+
2255
+ // Add action tools only if credentials are configured
2256
+ for (const tool of ACTION_TOOLS) {
2257
+ if (hasCredential(tool.requiresCredential)) {
2258
+ tools.push({
2259
+ name: tool.name,
2260
+ description: tool.description,
2261
+ inputSchema: tool.inputSchema
2262
+ })
2263
+ } else {
2264
+ // Add disabled version with note
2265
+ tools.push({
2266
+ name: tool.name,
2267
+ description: `[DISABLED - requires ${tool.requiresCredential} credentials] ${tool.description}`,
2268
+ inputSchema: tool.inputSchema
2269
+ })
2270
+ }
2271
+ }
2272
+
2273
+ return tools
2274
+ }
2275
+
2276
+ // Main function
2277
+ async function main() {
2278
+ log(`Starting MCP client for project: ${projectSlug}`)
2279
+ log(`API URL: ${apiUrl}`)
2280
+
2281
+ // Load local credentials
2282
+ localCredentials = loadLocalCredentials()
2283
+ if (localCredentials) {
2284
+ const configured = []
2285
+ if (hasCredential('wordpress')) configured.push('wordpress')
2286
+ if (hasCredential('ghost')) configured.push('ghost')
2287
+ if (hasCredential('image')) configured.push(`image:${localCredentials.image_provider}`)
2288
+ if (hasCredential('webhooks')) configured.push('webhooks')
2289
+ if (localCredentials.external_mcps?.length) {
2290
+ configured.push(`mcps:${localCredentials.external_mcps.map(m => m.name).join(',')}`)
2291
+ }
2292
+ if (configured.length > 0) {
2293
+ log(`Configured integrations: ${configured.join(', ')}`)
2294
+ }
2295
+ }
2296
+
2297
+ // Restore session state from previous run
2298
+ if (loadSession()) {
2299
+ progress('Session', 'Restored previous workflow state')
2300
+ }
2301
+
2302
+ // Fetch project configuration
2303
+ progress('Init', 'Connecting to platform...')
2304
+ let project
2305
+ try {
2306
+ project = await fetchProjectConfig()
2307
+ progress('Init', `Connected to project: ${project.name}`)
2308
+ } catch (error) {
2309
+ log('Failed to load project config. Exiting.')
2310
+ process.exit(1)
2311
+ }
2312
+
2313
+ // Create MCP server
2314
+ const server = new Server(
2315
+ {
2316
+ name: 'suparank',
2317
+ version: '1.0.0'
2318
+ },
2319
+ {
2320
+ capabilities: {
2321
+ tools: {}
2322
+ }
2323
+ }
2324
+ )
2325
+
2326
+ // Handle initialization
2327
+ server.setRequestHandler(InitializeRequestSchema, async (request) => {
2328
+ log('Received initialize request')
2329
+ return {
2330
+ protocolVersion: '2024-11-05',
2331
+ capabilities: {
2332
+ tools: {}
2333
+ },
2334
+ serverInfo: {
2335
+ name: 'suparank',
2336
+ version: '1.0.0'
2337
+ }
2338
+ }
2339
+ })
2340
+
2341
+ // Handle tools list
2342
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
2343
+ log('Received list tools request')
2344
+ const tools = getAvailableTools()
2345
+ log(`Returning ${tools.length} tools (${ACTION_TOOLS.length} action tools)`)
2346
+ return { tools }
2347
+ })
2348
+
2349
+ // Handle tool calls
2350
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2351
+ const { name, arguments: args } = request.params
2352
+ progress('Tool', `Executing ${name}`)
2353
+ log(`Executing tool: ${name}`)
2354
+
2355
+ // Check if this is an orchestrator tool
2356
+ const orchestratorTool = ORCHESTRATOR_TOOLS.find(t => t.name === name)
2357
+
2358
+ if (orchestratorTool) {
2359
+ try {
2360
+ const result = await executeOrchestratorTool(name, args || {}, project)
2361
+ log(`Orchestrator tool ${name} completed successfully`)
2362
+ return result
2363
+ } catch (error) {
2364
+ log(`Orchestrator tool ${name} failed:`, error.message)
2365
+ return {
2366
+ content: [{
2367
+ type: 'text',
2368
+ text: `Error executing ${name}: ${error.message}`
2369
+ }]
2370
+ }
2371
+ }
2372
+ }
2373
+
2374
+ // Check if this is an action tool
2375
+ const actionTool = ACTION_TOOLS.find(t => t.name === name)
2376
+
2377
+ if (actionTool) {
2378
+ // Check credentials
2379
+ if (!hasCredential(actionTool.requiresCredential)) {
2380
+ return {
2381
+ content: [{
2382
+ type: 'text',
2383
+ text: `Error: ${name} requires ${actionTool.requiresCredential} credentials.\n\nTo enable this tool:\n1. Run: npx suparank setup\n2. Add your ${actionTool.requiresCredential} credentials to ~/.suparank/credentials.json\n3. Restart the MCP server\n\nSee dashboard Settings > Credentials for setup instructions.`
2384
+ }]
2385
+ }
2386
+ }
2387
+
2388
+ // Execute action tool locally
2389
+ try {
2390
+ const result = await executeActionTool(name, args || {})
2391
+ log(`Action tool ${name} completed successfully`)
2392
+ return result
2393
+ } catch (error) {
2394
+ log(`Action tool ${name} failed:`, error.message)
2395
+ return {
2396
+ content: [{
2397
+ type: 'text',
2398
+ text: `Error executing ${name}: ${error.message}`
2399
+ }]
2400
+ }
2401
+ }
2402
+ }
2403
+
2404
+ // Regular tool - call backend
2405
+ try {
2406
+ // Add composition hints if configured
2407
+ const hints = getCompositionHints(name)
2408
+ const externalMcps = getExternalMCPs()
2409
+
2410
+ const result = await callBackendTool(name, args || {})
2411
+
2412
+ // Inject composition hints into response if available
2413
+ if (hints && result.content && result.content[0]?.text) {
2414
+ const mcpList = externalMcps.length > 0
2415
+ ? `\n\n## External MCPs Available\n${externalMcps.map(m => `- **${m.name}**: ${m.available_tools.join(', ')}`).join('\n')}`
2416
+ : ''
2417
+
2418
+ result.content[0].text = result.content[0].text +
2419
+ `\n\n---\n## Integration Hints\n${hints}${mcpList}`
2420
+ }
2421
+
2422
+ log(`Tool ${name} completed successfully`)
2423
+ return result
2424
+ } catch (error) {
2425
+ log(`Tool ${name} failed:`, error.message)
2426
+ throw error
2427
+ }
2428
+ })
2429
+
2430
+ // Error handler
2431
+ server.onerror = (error) => {
2432
+ log('Server error:', error)
2433
+ }
2434
+
2435
+ // Connect to stdio transport
2436
+ const transport = new StdioServerTransport()
2437
+ await server.connect(transport)
2438
+
2439
+ log('MCP server ready and listening on stdio')
2440
+ }
2441
+
2442
+ // Run
2443
+ main().catch((error) => {
2444
+ log('Fatal error:', error)
2445
+ process.exit(1)
2446
+ })