suparank 1.2.4 → 1.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Suparank MCP - Configuration
3
+ *
4
+ * Centralized configuration and constants
5
+ */
6
+
7
+ // Parse command line arguments
8
+ export const projectSlug = process.argv[2]
9
+ export const apiKey = process.argv[3]
10
+ export const apiUrl = process.env.SUPARANK_API_URL || 'https://api.suparank.io'
11
+
12
+ // External API endpoints - configurable via environment variables
13
+ export const API_ENDPOINTS = {
14
+ fal: process.env.FAL_API_URL || 'https://fal.run/fal-ai/nano-banana-pro',
15
+ gemini: process.env.GEMINI_API_URL || 'https://generativelanguage.googleapis.com/v1beta/models',
16
+ wiro: process.env.WIRO_API_URL || 'https://api.wiro.ai/v1',
17
+ wiroTaskDetail: process.env.WIRO_TASK_URL || 'https://api.wiro.ai/v1/Task/Detail'
18
+ }
19
+
20
+ // Session expiration (24 hours)
21
+ export const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000
22
+
23
+ // Tools that are visible in the MCP tool list
24
+ export const VISIBLE_TOOLS = [
25
+ // Essential (5) - Main workflow
26
+ 'create_content', 'keyword_research', 'generate_image', 'publish_content', 'get_session',
27
+ // Session Management (5) - Content lifecycle
28
+ 'save_content', 'list_content', 'load_content', 'remove_article', 'clear_session'
29
+ ]
30
+
31
+ // Default stats object
32
+ export const DEFAULT_STATS = {
33
+ tool_calls: 0,
34
+ images_generated: 0,
35
+ articles_created: 0,
36
+ words_written: 0
37
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Suparank MCP - Main Entry Point
3
+ *
4
+ * Modular MCP server for AI-powered SEO content creation
5
+ *
6
+ * This file re-exports from modular components and provides the main entry point.
7
+ * For now, the main server logic is still in ../mcp-client.js but will be
8
+ * migrated incrementally.
9
+ */
10
+
11
+ // Re-export config
12
+ export * from './config.js'
13
+
14
+ // Re-export utils
15
+ export * from './utils/index.js'
16
+
17
+ // Re-export services
18
+ export * from './services/index.js'
19
+
20
+ // Main entry - for now, delegate to the original mcp-client.js
21
+ // This will be replaced with modular server setup in future versions
22
+ import '../mcp-client.js'
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Suparank MCP - API Service
3
+ *
4
+ * HTTP request utilities with retry and timeout handling
5
+ */
6
+
7
+ import { log } from '../utils/logging.js'
8
+
9
+ /**
10
+ * Fetch with timeout - prevents hanging requests
11
+ * @param {string} url - URL to fetch
12
+ * @param {object} options - Fetch options
13
+ * @param {number} timeoutMs - Timeout in milliseconds
14
+ * @returns {Promise<Response>}
15
+ */
16
+ export async function fetchWithTimeout(url, options = {}, timeoutMs = 30000) {
17
+ const controller = new AbortController()
18
+ const timeout = setTimeout(() => controller.abort(), timeoutMs)
19
+
20
+ try {
21
+ const response = await fetch(url, {
22
+ ...options,
23
+ signal: controller.signal
24
+ })
25
+ return response
26
+ } finally {
27
+ clearTimeout(timeout)
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Fetch with retry - handles transient failures
33
+ * @param {string} url - URL to fetch
34
+ * @param {object} options - Fetch options
35
+ * @param {number} maxRetries - Maximum retry attempts
36
+ * @param {number} timeoutMs - Timeout per request in milliseconds
37
+ * @returns {Promise<Response>}
38
+ */
39
+ export async function fetchWithRetry(url, options = {}, maxRetries = 3, timeoutMs = 30000) {
40
+ let lastError
41
+
42
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
43
+ try {
44
+ const response = await fetchWithTimeout(url, options, timeoutMs)
45
+
46
+ // Retry on 5xx errors or rate limiting
47
+ if (response.status >= 500 || response.status === 429) {
48
+ const retryAfter = response.headers.get('retry-after')
49
+ const delay = retryAfter ? parseInt(retryAfter) * 1000 : Math.pow(2, attempt) * 1000
50
+
51
+ if (attempt < maxRetries) {
52
+ log(`Request failed (${response.status}), retrying in ${delay}ms... (attempt ${attempt}/${maxRetries})`)
53
+ await new Promise(resolve => setTimeout(resolve, delay))
54
+ continue
55
+ }
56
+ }
57
+
58
+ return response
59
+ } catch (error) {
60
+ lastError = error
61
+
62
+ // Don't retry on abort (timeout)
63
+ if (error.name === 'AbortError') {
64
+ throw new Error(`Request timeout after ${timeoutMs}ms: ${url}`)
65
+ }
66
+
67
+ // Retry on network errors
68
+ if (attempt < maxRetries) {
69
+ const delay = Math.pow(2, attempt) * 1000
70
+ log(`Network error, retrying in ${delay}ms... (attempt ${attempt}/${maxRetries}): ${error.message}`)
71
+ await new Promise(resolve => setTimeout(resolve, delay))
72
+ continue
73
+ }
74
+ }
75
+ }
76
+
77
+ throw lastError || new Error(`Request failed after ${maxRetries} attempts: ${url}`)
78
+ }
79
+
80
+ /**
81
+ * Make an authenticated request to the Suparank API
82
+ * @param {string} apiUrl - Base API URL
83
+ * @param {string} apiKey - API key
84
+ * @param {string} endpoint - API endpoint
85
+ * @param {object} options - Additional fetch options
86
+ * @returns {Promise<Response>}
87
+ */
88
+ export async function apiRequest(apiUrl, apiKey, endpoint, options = {}) {
89
+ const url = `${apiUrl}${endpoint}`
90
+
91
+ const headers = {
92
+ 'Authorization': `Bearer ${apiKey}`,
93
+ 'Content-Type': 'application/json',
94
+ ...options.headers
95
+ }
96
+
97
+ return fetchWithRetry(url, {
98
+ ...options,
99
+ headers
100
+ })
101
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Suparank MCP - Credentials Service
3
+ *
4
+ * Local credentials management for CMS and API integrations
5
+ */
6
+
7
+ import * as fs from 'fs'
8
+ import { getCredentialsFilePath } from '../utils/paths.js'
9
+ import { log } from '../utils/logging.js'
10
+
11
+ // Cached credentials
12
+ let localCredentials = null
13
+
14
+ /**
15
+ * Load local credentials from ~/.suparank/credentials.json
16
+ * @returns {object|null} Credentials object or null
17
+ */
18
+ export function loadCredentials() {
19
+ if (localCredentials !== null) {
20
+ return localCredentials
21
+ }
22
+
23
+ const credentialsPath = getCredentialsFilePath()
24
+
25
+ try {
26
+ if (fs.existsSync(credentialsPath)) {
27
+ const content = fs.readFileSync(credentialsPath, 'utf-8')
28
+ localCredentials = JSON.parse(content)
29
+ log(`Loaded credentials from ${credentialsPath}`)
30
+
31
+ // Log which integrations are configured (without exposing keys)
32
+ const configured = []
33
+ if (localCredentials.wordpress?.secret_key || localCredentials.wordpress?.app_password) {
34
+ configured.push('WordPress')
35
+ }
36
+ if (localCredentials.ghost?.admin_api_key) {
37
+ configured.push('Ghost')
38
+ }
39
+ if (localCredentials.image_provider && localCredentials[localCredentials.image_provider]?.api_key) {
40
+ configured.push(`Images (${localCredentials.image_provider})`)
41
+ }
42
+ if (localCredentials.webhooks && Object.values(localCredentials.webhooks).some(Boolean)) {
43
+ configured.push('Webhooks')
44
+ }
45
+ if (localCredentials.external_mcps?.length) {
46
+ configured.push(`External MCPs (${localCredentials.external_mcps.length})`)
47
+ }
48
+
49
+ if (configured.length > 0) {
50
+ log(`Configured integrations: ${configured.join(', ')}`)
51
+ }
52
+
53
+ return localCredentials
54
+ }
55
+ } catch (error) {
56
+ log(`Warning: Could not load credentials: ${error.message}`)
57
+ }
58
+
59
+ localCredentials = {}
60
+ return localCredentials
61
+ }
62
+
63
+ /**
64
+ * Get the current credentials object
65
+ * @returns {object} Credentials object
66
+ */
67
+ export function getCredentials() {
68
+ if (localCredentials === null) {
69
+ loadCredentials()
70
+ }
71
+ return localCredentials || {}
72
+ }
73
+
74
+ /**
75
+ * Check if a specific credential type is available
76
+ * @param {string} type - Credential type (wordpress, ghost, fal, gemini, wiro, image, webhooks)
77
+ * @returns {boolean} Whether the credential is available
78
+ */
79
+ export function hasCredential(type) {
80
+ const creds = getCredentials()
81
+
82
+ switch (type) {
83
+ case 'wordpress':
84
+ return !!(creds.wordpress?.secret_key || creds.wordpress?.app_password)
85
+ case 'ghost':
86
+ return !!creds.ghost?.admin_api_key
87
+ case 'fal':
88
+ return !!creds.fal?.api_key
89
+ case 'gemini':
90
+ return !!creds.gemini?.api_key
91
+ case 'wiro':
92
+ return !!creds.wiro?.api_key
93
+ case 'image':
94
+ const provider = creds.image_provider
95
+ return provider && hasCredential(provider)
96
+ case 'webhooks':
97
+ return !!(creds.webhooks && Object.values(creds.webhooks).some(Boolean))
98
+ default:
99
+ return false
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Get the configured image provider
105
+ * @returns {string|null} Image provider name or null
106
+ */
107
+ export function getImageProvider() {
108
+ const creds = getCredentials()
109
+ return creds.image_provider || null
110
+ }
111
+
112
+ /**
113
+ * Get configuration for a specific provider
114
+ * @param {string} provider - Provider name
115
+ * @returns {object|null} Provider config or null
116
+ */
117
+ export function getProviderConfig(provider) {
118
+ const creds = getCredentials()
119
+ return creds[provider] || null
120
+ }
121
+
122
+ /**
123
+ * Clear cached credentials (for testing)
124
+ */
125
+ export function clearCredentialsCache() {
126
+ localCredentials = null
127
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Suparank MCP - Services Index
3
+ *
4
+ * Re-exports all service modules
5
+ */
6
+
7
+ export * from './api.js'
8
+ export * from './credentials.js'
9
+ export * from './session-state.js'
10
+ export * from './stats.js'
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Suparank MCP - Session State Service
3
+ *
4
+ * Session state management with persistence and mutex protection
5
+ */
6
+
7
+ import * as fs from 'fs'
8
+ import {
9
+ getSessionFilePath,
10
+ ensureSuparankDir,
11
+ atomicWriteSync
12
+ } from '../utils/paths.js'
13
+ import { log, progress } from '../utils/logging.js'
14
+ import { SESSION_EXPIRY_MS } from '../config.js'
15
+
16
+ // Session state object
17
+ export const sessionState = {
18
+ currentWorkflow: null,
19
+ stepResults: {},
20
+ articles: [],
21
+ article: null,
22
+ title: null,
23
+ imageUrl: null,
24
+ inlineImages: [],
25
+ keywords: null,
26
+ metadata: null,
27
+ metaTitle: null,
28
+ metaDescription: null,
29
+ contentFolder: null
30
+ }
31
+
32
+ // Session mutex for concurrent access protection
33
+ let sessionLock = false
34
+ const sessionLockQueue = []
35
+
36
+ /**
37
+ * Acquire session lock for safe concurrent access
38
+ * @returns {Promise<void>}
39
+ */
40
+ export async function acquireSessionLock() {
41
+ if (!sessionLock) {
42
+ sessionLock = true
43
+ return
44
+ }
45
+ return new Promise(resolve => {
46
+ sessionLockQueue.push(resolve)
47
+ })
48
+ }
49
+
50
+ /**
51
+ * Release session lock
52
+ */
53
+ export function releaseSessionLock() {
54
+ if (sessionLockQueue.length > 0) {
55
+ const next = sessionLockQueue.shift()
56
+ next()
57
+ } else {
58
+ sessionLock = false
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Generate a unique article ID
64
+ * @returns {string} Unique ID
65
+ */
66
+ export function generateArticleId() {
67
+ return `article-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`
68
+ }
69
+
70
+ /**
71
+ * Check if session has expired
72
+ * @param {string} savedAt - ISO timestamp when session was saved
73
+ * @returns {boolean} Whether session has expired
74
+ */
75
+ export function isSessionExpired(savedAt) {
76
+ if (!savedAt) return true
77
+ const savedTime = new Date(savedAt).getTime()
78
+ const now = Date.now()
79
+ return (now - savedTime) > SESSION_EXPIRY_MS
80
+ }
81
+
82
+ /**
83
+ * Save session state to file
84
+ * Uses atomic write to prevent corruption
85
+ */
86
+ export function saveSession() {
87
+ try {
88
+ ensureSuparankDir()
89
+ const sessionFile = getSessionFilePath()
90
+
91
+ const toSave = {
92
+ currentWorkflow: sessionState.currentWorkflow,
93
+ stepResults: sessionState.stepResults,
94
+ articles: sessionState.articles,
95
+ article: sessionState.article,
96
+ title: sessionState.title,
97
+ imageUrl: sessionState.imageUrl,
98
+ inlineImages: sessionState.inlineImages,
99
+ keywords: sessionState.keywords,
100
+ metadata: sessionState.metadata,
101
+ metaTitle: sessionState.metaTitle,
102
+ metaDescription: sessionState.metaDescription,
103
+ contentFolder: sessionState.contentFolder,
104
+ savedAt: new Date().toISOString()
105
+ }
106
+
107
+ atomicWriteSync(sessionFile, JSON.stringify(toSave, null, 2))
108
+ progress('Session', `Saved to ${sessionFile} (${sessionState.articles.length} articles)`)
109
+ } catch (error) {
110
+ log(`Warning: Failed to save session: ${error.message}`)
111
+ progress('Session', `FAILED to save: ${error.message}`)
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Safe session save with mutex
117
+ * @returns {Promise<void>}
118
+ */
119
+ export async function saveSessionSafe() {
120
+ await acquireSessionLock()
121
+ try {
122
+ saveSession()
123
+ } finally {
124
+ releaseSessionLock()
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Load session state from file
130
+ * @returns {boolean} Whether session was restored
131
+ */
132
+ export function restoreSession() {
133
+ const sessionFile = getSessionFilePath()
134
+
135
+ try {
136
+ if (fs.existsSync(sessionFile)) {
137
+ const content = fs.readFileSync(sessionFile, 'utf-8')
138
+ const saved = JSON.parse(content)
139
+
140
+ // Check if session is expired (24 hour max)
141
+ if (isSessionExpired(saved.savedAt)) {
142
+ log('Session expired, starting fresh')
143
+ return false
144
+ }
145
+
146
+ // Restore state
147
+ sessionState.currentWorkflow = saved.currentWorkflow || null
148
+ sessionState.stepResults = saved.stepResults || {}
149
+ sessionState.articles = saved.articles || []
150
+ sessionState.article = saved.article || null
151
+ sessionState.title = saved.title || null
152
+ sessionState.imageUrl = saved.imageUrl || null
153
+ sessionState.inlineImages = saved.inlineImages || []
154
+ sessionState.keywords = saved.keywords || null
155
+ sessionState.metadata = saved.metadata || null
156
+ sessionState.metaTitle = saved.metaTitle || null
157
+ sessionState.metaDescription = saved.metaDescription || null
158
+ sessionState.contentFolder = saved.contentFolder || null
159
+
160
+ log(`Session restored: ${sessionState.articles.length} articles, workflow: ${sessionState.currentWorkflow?.workflow_id || 'none'}`)
161
+ return true
162
+ }
163
+ } catch (error) {
164
+ log(`Warning: Could not restore session: ${error.message}`)
165
+ }
166
+
167
+ return false
168
+ }
169
+
170
+ /**
171
+ * Reset session state to initial values
172
+ */
173
+ export function resetSession() {
174
+ sessionState.currentWorkflow = null
175
+ sessionState.stepResults = {}
176
+ sessionState.articles = []
177
+ sessionState.article = null
178
+ sessionState.title = null
179
+ sessionState.imageUrl = null
180
+ sessionState.inlineImages = []
181
+ sessionState.keywords = null
182
+ sessionState.metadata = null
183
+ sessionState.metaTitle = null
184
+ sessionState.metaDescription = null
185
+ sessionState.contentFolder = null
186
+ }
187
+
188
+ /**
189
+ * Clear session file from disk
190
+ */
191
+ export function clearSessionFile() {
192
+ const sessionFile = getSessionFilePath()
193
+ try {
194
+ if (fs.existsSync(sessionFile)) {
195
+ fs.unlinkSync(sessionFile)
196
+ log('Session file cleared')
197
+ }
198
+ } catch (error) {
199
+ log(`Warning: Could not clear session file: ${error.message}`)
200
+ }
201
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Suparank MCP - Stats Service
3
+ *
4
+ * Usage statistics tracking
5
+ */
6
+
7
+ import * as fs from 'fs'
8
+ import { getStatsFilePath, ensureSuparankDir } from '../utils/paths.js'
9
+ import { log } from '../utils/logging.js'
10
+ import { DEFAULT_STATS } from '../config.js'
11
+
12
+ /**
13
+ * Load usage stats from file
14
+ * @returns {object} Stats object
15
+ */
16
+ export function loadStats() {
17
+ try {
18
+ const file = getStatsFilePath()
19
+ if (fs.existsSync(file)) {
20
+ return JSON.parse(fs.readFileSync(file, 'utf-8'))
21
+ }
22
+ } catch (e) {
23
+ log(`Warning: Could not load stats: ${e.message}`)
24
+ }
25
+ return { ...DEFAULT_STATS }
26
+ }
27
+
28
+ /**
29
+ * Save usage stats to file
30
+ * @param {object} stats - Stats object to save
31
+ */
32
+ export function saveStats(stats) {
33
+ try {
34
+ ensureSuparankDir()
35
+ fs.writeFileSync(getStatsFilePath(), JSON.stringify(stats, null, 2))
36
+ } catch (e) {
37
+ log(`Error saving stats: ${e.message}`)
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Increment a stat counter
43
+ * @param {string} key - Stat key to increment
44
+ * @param {number} amount - Amount to add (default: 1)
45
+ */
46
+ export function incrementStat(key, amount = 1) {
47
+ const stats = loadStats()
48
+ stats[key] = (stats[key] || 0) + amount
49
+ saveStats(stats)
50
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Suparank MCP - Utils Index
3
+ *
4
+ * Re-exports all utility modules
5
+ */
6
+
7
+ export * from './logging.js'
8
+ export * from './paths.js'
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Suparank MCP - Logging Utilities
3
+ *
4
+ * Standardized logging to stderr (stdout is for MCP protocol)
5
+ */
6
+
7
+ /**
8
+ * Log message to stderr with [suparank] prefix
9
+ * @param {...any} args - Arguments to log
10
+ */
11
+ export function log(...args) {
12
+ console.error('[suparank]', ...args)
13
+ }
14
+
15
+ /**
16
+ * Structured progress logging for user visibility
17
+ * @param {string} step - Current step name
18
+ * @param {string} message - Progress message
19
+ */
20
+ export function progress(step, message) {
21
+ console.error(`[suparank] ${step}: ${message}`)
22
+ }
23
+
24
+ /**
25
+ * Log warning message
26
+ * @param {...any} args - Arguments to log
27
+ */
28
+ export function warn(...args) {
29
+ console.error('[suparank] WARNING:', ...args)
30
+ }
31
+
32
+ /**
33
+ * Log error message
34
+ * @param {...any} args - Arguments to log
35
+ */
36
+ export function error(...args) {
37
+ console.error('[suparank] ERROR:', ...args)
38
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Suparank MCP - Path Utilities
3
+ *
4
+ * Safe path handling and directory management
5
+ */
6
+
7
+ import * as fs from 'fs'
8
+ import * as path from 'path'
9
+ import * as os from 'os'
10
+
11
+ /**
12
+ * Get the Suparank configuration directory
13
+ * @returns {string} Path to ~/.suparank
14
+ */
15
+ export function getSuparankDir() {
16
+ return path.join(os.homedir(), '.suparank')
17
+ }
18
+
19
+ /**
20
+ * Get the content storage directory
21
+ * @returns {string} Path to ~/.suparank/content
22
+ */
23
+ export function getContentDir() {
24
+ return path.join(getSuparankDir(), 'content')
25
+ }
26
+
27
+ /**
28
+ * Get session file path
29
+ * @returns {string} Path to ~/.suparank/session.json
30
+ */
31
+ export function getSessionFilePath() {
32
+ return path.join(getSuparankDir(), 'session.json')
33
+ }
34
+
35
+ /**
36
+ * Get credentials file path
37
+ * @returns {string} Path to ~/.suparank/credentials.json
38
+ */
39
+ export function getCredentialsFilePath() {
40
+ return path.join(getSuparankDir(), 'credentials.json')
41
+ }
42
+
43
+ /**
44
+ * Get stats file path
45
+ * @returns {string} Path to ~/.suparank/stats.json
46
+ */
47
+ export function getStatsFilePath() {
48
+ return path.join(getSuparankDir(), 'stats.json')
49
+ }
50
+
51
+ /**
52
+ * Ensure the Suparank directory exists
53
+ */
54
+ export function ensureSuparankDir() {
55
+ const dir = getSuparankDir()
56
+ if (!fs.existsSync(dir)) {
57
+ fs.mkdirSync(dir, { recursive: true })
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Ensure the content directory exists
63
+ */
64
+ export function ensureContentDir() {
65
+ const dir = getContentDir()
66
+ if (!fs.existsSync(dir)) {
67
+ fs.mkdirSync(dir, { recursive: true })
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Sanitize and validate a path to prevent traversal attacks
73
+ * @param {string} userPath - User-provided path segment
74
+ * @param {string} allowedBase - Base directory that paths must stay within
75
+ * @returns {string} Resolved safe path
76
+ * @throws {Error} If path would escape the allowed base
77
+ */
78
+ export function sanitizePath(userPath, allowedBase) {
79
+ // Remove any null bytes (common attack vector)
80
+ const cleanPath = userPath.replace(/\0/g, '')
81
+
82
+ // Resolve to absolute path
83
+ const resolved = path.resolve(allowedBase, cleanPath)
84
+
85
+ // Ensure the resolved path starts with the allowed base
86
+ const normalizedBase = path.normalize(allowedBase + path.sep)
87
+ const normalizedResolved = path.normalize(resolved + path.sep)
88
+
89
+ if (!normalizedResolved.startsWith(normalizedBase)) {
90
+ throw new Error(`Path traversal detected: "${userPath}" would escape allowed directory`)
91
+ }
92
+
93
+ return resolved
94
+ }
95
+
96
+ /**
97
+ * Get content folder path safely
98
+ * @param {string} folderName - Folder name (article ID or title slug)
99
+ * @returns {string} Safe path to content folder
100
+ */
101
+ export function getContentFolderSafe(folderName) {
102
+ const contentDir = getContentDir()
103
+ return sanitizePath(folderName, contentDir)
104
+ }
105
+
106
+ /**
107
+ * Generate a slug from title for folder naming
108
+ * @param {string} text - Text to slugify
109
+ * @returns {string} URL-safe slug
110
+ */
111
+ export function slugify(text) {
112
+ return text
113
+ .toLowerCase()
114
+ .replace(/[^a-z0-9]+/g, '-')
115
+ .replace(/^-|-$/g, '')
116
+ .substring(0, 50)
117
+ }
118
+
119
+ /**
120
+ * Atomic file write - prevents corruption on concurrent writes
121
+ * @param {string} filePath - Target file path
122
+ * @param {string} data - Data to write
123
+ */
124
+ export function atomicWriteSync(filePath, data) {
125
+ const tmpFile = filePath + '.tmp.' + process.pid
126
+ try {
127
+ fs.writeFileSync(tmpFile, data)
128
+ fs.renameSync(tmpFile, filePath) // Atomic on POSIX
129
+ } catch (error) {
130
+ // Clean up temp file if rename failed
131
+ try { fs.unlinkSync(tmpFile) } catch (e) { /* ignore */ }
132
+ throw error
133
+ }
134
+ }
package/mcp-client.js CHANGED
@@ -35,6 +35,17 @@ const projectSlug = process.argv[2]
35
35
  const apiKey = process.argv[3]
36
36
  const apiUrl = process.env.SUPARANK_API_URL || 'https://api.suparank.io'
37
37
 
38
+ // ============================================================================
39
+ // EXTERNAL API ENDPOINTS - Configurable via environment variables
40
+ // ============================================================================
41
+ const API_ENDPOINTS = {
42
+ // Image generation providers
43
+ fal: process.env.FAL_API_URL || 'https://fal.run/fal-ai/nano-banana-pro',
44
+ gemini: process.env.GEMINI_API_URL || 'https://generativelanguage.googleapis.com/v1beta/models',
45
+ wiro: process.env.WIRO_API_URL || 'https://api.wiro.ai/v1',
46
+ wiroTaskDetail: process.env.WIRO_TASK_URL || 'https://api.wiro.ai/v1/Task/Detail'
47
+ }
48
+
38
49
  if (!projectSlug) {
39
50
  console.error('Error: Project slug is required')
40
51
  console.error('Usage: node mcp-client.js <project-slug> <api-key>')
@@ -160,7 +171,9 @@ function loadStats() {
160
171
  if (fs.existsSync(file)) {
161
172
  return JSON.parse(fs.readFileSync(file, 'utf-8'))
162
173
  }
163
- } catch (e) {}
174
+ } catch (e) {
175
+ log(`Warning: Could not load stats: ${e.message}`)
176
+ }
164
177
  return { tool_calls: 0, images_generated: 0, articles_created: 0, words_written: 0 }
165
178
  }
166
179
 
@@ -196,6 +209,46 @@ function slugify(text) {
196
209
  .substring(0, 50)
197
210
  }
198
211
 
212
+ // ============================================================================
213
+ // PATH SANITIZATION - Prevents path traversal attacks
214
+ // ============================================================================
215
+
216
+ /**
217
+ * Sanitize and validate a path to prevent traversal attacks
218
+ * @param {string} userPath - User-provided path segment
219
+ * @param {string} allowedBase - Base directory that paths must stay within
220
+ * @returns {string} - Resolved safe path
221
+ * @throws {Error} - If path would escape the allowed base
222
+ */
223
+ function sanitizePath(userPath, allowedBase) {
224
+ // Remove any null bytes (common attack vector)
225
+ const cleanPath = userPath.replace(/\0/g, '')
226
+
227
+ // Resolve to absolute path
228
+ const resolved = path.resolve(allowedBase, cleanPath)
229
+
230
+ // Ensure the resolved path starts with the allowed base
231
+ // Adding path.sep ensures we don't match partial directory names
232
+ const normalizedBase = path.normalize(allowedBase + path.sep)
233
+ const normalizedResolved = path.normalize(resolved + path.sep)
234
+
235
+ if (!normalizedResolved.startsWith(normalizedBase)) {
236
+ throw new Error(`Path traversal detected: "${userPath}" would escape allowed directory`)
237
+ }
238
+
239
+ return resolved
240
+ }
241
+
242
+ /**
243
+ * Get content folder path safely
244
+ * @param {string} folderName - Folder name (article ID or title slug)
245
+ * @returns {string} - Safe path to content folder
246
+ */
247
+ function getContentFolderSafe(folderName) {
248
+ const contentDir = path.join(getSuparankDir(), 'content')
249
+ return sanitizePath(folderName, contentDir)
250
+ }
251
+
199
252
  /**
200
253
  * Atomic file write - prevents corruption on concurrent writes
201
254
  */
@@ -356,9 +409,56 @@ function loadSession() {
356
409
  return false
357
410
  }
358
411
 
412
+ // ============================================================================
413
+ // SESSION MUTEX - Prevents race conditions on concurrent session writes
414
+ // ============================================================================
415
+
416
+ let sessionLock = false
417
+ const sessionLockQueue = []
418
+
419
+ /**
420
+ * Acquire session lock for safe concurrent access
421
+ * @returns {Promise<void>}
422
+ */
423
+ async function acquireSessionLock() {
424
+ if (!sessionLock) {
425
+ sessionLock = true
426
+ return
427
+ }
428
+ return new Promise(resolve => {
429
+ sessionLockQueue.push(resolve)
430
+ })
431
+ }
432
+
433
+ /**
434
+ * Release session lock, allowing next queued operation
435
+ */
436
+ function releaseSessionLock() {
437
+ if (sessionLockQueue.length > 0) {
438
+ const next = sessionLockQueue.shift()
439
+ next()
440
+ } else {
441
+ sessionLock = false
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Safe session save with mutex - use this for concurrent operations
447
+ * @returns {Promise<void>}
448
+ */
449
+ async function saveSessionSafe() {
450
+ await acquireSessionLock()
451
+ try {
452
+ saveSession()
453
+ } finally {
454
+ releaseSessionLock()
455
+ }
456
+ }
457
+
359
458
  /**
360
459
  * Save session state to file (persists across MCP restarts)
361
460
  * Uses atomic write to prevent corruption
461
+ * NOTE: For concurrent operations, use saveSessionSafe() instead
362
462
  */
363
463
  function saveSession() {
364
464
  try {
@@ -457,11 +557,13 @@ function saveContentToFolder() {
457
557
  try {
458
558
  ensureContentDir()
459
559
 
460
- // Create folder name: YYYY-MM-DD-slug
560
+ // Create folder name: YYYY-MM-DD-slug (slugify removes dangerous characters)
461
561
  const date = new Date().toISOString().split('T')[0]
462
562
  const slug = slugify(sessionState.title)
463
563
  const folderName = `${date}-${slug}`
464
- const folderPath = path.join(getContentDir(), folderName)
564
+
565
+ // Use safe path function to prevent any path traversal
566
+ const folderPath = getContentFolderSafe(folderName)
465
567
 
466
568
  // Create folder if doesn't exist
467
569
  if (!fs.existsSync(folderPath)) {
@@ -2558,8 +2660,18 @@ Once loaded, you can run optimization tools:
2558
2660
  }
2559
2661
  }
2560
2662
 
2561
- const contentDir = getContentDir()
2562
- const folderPath = path.join(contentDir, folder_name)
2663
+ // Sanitize folder_name to prevent path traversal attacks
2664
+ let folderPath
2665
+ try {
2666
+ folderPath = getContentFolderSafe(folder_name)
2667
+ } catch (error) {
2668
+ return {
2669
+ content: [{
2670
+ type: 'text',
2671
+ text: `❌ Invalid folder name: ${error.message}`
2672
+ }]
2673
+ }
2674
+ }
2563
2675
 
2564
2676
  if (!fs.existsSync(folderPath)) {
2565
2677
  return {
@@ -2705,7 +2817,7 @@ async function executeImageGeneration(args) {
2705
2817
  switch (provider) {
2706
2818
  case 'fal': {
2707
2819
  // fal.ai Nano Banana Pro (gemini-3-pro-image)
2708
- const response = await fetchWithRetry('https://fal.run/fal-ai/nano-banana-pro', {
2820
+ const response = await fetchWithRetry(API_ENDPOINTS.fal, {
2709
2821
  method: 'POST',
2710
2822
  headers: {
2711
2823
  'Authorization': `Key ${config.api_key}`,
@@ -2766,7 +2878,7 @@ ${imageNumber < totalImages ? `\n**Next:** Generate ${totalImages - imageNumber}
2766
2878
  // Google Gemini 3 Pro Image (Nano Banana Pro) - generateContent API
2767
2879
  const model = config.model || 'gemini-3-pro-image-preview'
2768
2880
  const response = await fetch(
2769
- `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
2881
+ `${API_ENDPOINTS.gemini}/${model}:generateContent`,
2770
2882
  {
2771
2883
  method: 'POST',
2772
2884
  headers: {
@@ -2837,7 +2949,7 @@ ${imageNumber < totalImages ? `\n**Next:** Generate ${totalImages - imageNumber}
2837
2949
 
2838
2950
  // Submit task
2839
2951
  log(`Submitting wiro.ai task for model: ${model}`)
2840
- const submitResponse = await fetch(`https://api.wiro.ai/v1/Run/${model}`, {
2952
+ const submitResponse = await fetch(`${API_ENDPOINTS.wiro}/Run/${model}`, {
2841
2953
  method: 'POST',
2842
2954
  headers: {
2843
2955
  'Content-Type': 'application/json',
@@ -2880,7 +2992,7 @@ ${imageNumber < totalImages ? `\n**Next:** Generate ${totalImages - imageNumber}
2880
2992
  .update(pollSignatureData)
2881
2993
  .digest('hex')
2882
2994
 
2883
- const pollResponse = await fetch('https://api.wiro.ai/v1/Task/Detail', {
2995
+ const pollResponse = await fetch(API_ENDPOINTS.wiroTaskDetail, {
2884
2996
  method: 'POST',
2885
2997
  headers: {
2886
2998
  'Content-Type': 'application/json',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "suparank",
3
- "version": "1.2.4",
3
+ "version": "1.2.6",
4
4
  "description": "AI-powered SEO content creation MCP - generate and publish optimized blog posts with Claude, ChatGPT, or Cursor",
5
5
  "main": "mcp-client.js",
6
6
  "type": "module",
@@ -10,6 +10,7 @@
10
10
  "files": [
11
11
  "bin/",
12
12
  "mcp-client.js",
13
+ "mcp-client/",
13
14
  "credentials.example.json",
14
15
  "README.md",
15
16
  "LICENSE"