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/LICENSE +21 -0
- package/README.md +224 -0
- package/bin/suparank.js +611 -0
- package/credentials.example.json +34 -0
- package/mcp-client.js +2446 -0
- package/package.json +54 -0
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 ``
|
|
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
|
+
})
|