suparank 1.2.5 → 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/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/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"
|