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.
- package/mcp-client/config.js +37 -0
- package/mcp-client/index.js +22 -0
- package/mcp-client/services/api.js +101 -0
- package/mcp-client/services/credentials.js +127 -0
- package/mcp-client/services/index.js +10 -0
- package/mcp-client/services/session-state.js +201 -0
- package/mcp-client/services/stats.js +50 -0
- package/mcp-client/utils/index.js +8 -0
- package/mcp-client/utils/logging.js +38 -0
- package/mcp-client/utils/paths.js +134 -0
- package/mcp-client.js +121 -9
- package/package.json +2 -1
|
@@ -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,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,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
|
-
|
|
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
|
-
|
|
2562
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
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"
|