@wickedevolutions/abilities-mcp 1.3.1

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/lib/config.js ADDED
@@ -0,0 +1,210 @@
1
+ 'use strict';
2
+
3
+ const { execSync } = require('child_process');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+
8
+ /**
9
+ * Load configuration from wp-sites.json or build from CLI args.
10
+ *
11
+ * Search order for wp-sites.json:
12
+ * 1. --config=<path> explicit path
13
+ * 2. Same directory as abilities-mcp.js
14
+ * 3. ~/.abilities-mcp/wp-sites.json
15
+ *
16
+ * If no config file and --host/--path provided, builds a single-site legacy config.
17
+ *
18
+ * Copyright (C) 2026 Influencentricity | Wicked Evolutions
19
+ * @license GPL-2.0-or-later
20
+ */
21
+ function loadConfig(args) {
22
+ // Explicit config path
23
+ if (args.config) {
24
+ return loadConfigFile(args.config);
25
+ }
26
+
27
+ // Check alongside script (lib/ → package root)
28
+ const scriptDir = path.resolve(__dirname, '..');
29
+ const scriptConfig = path.join(scriptDir, 'wp-sites.json');
30
+ if (fs.existsSync(scriptConfig)) {
31
+ return loadConfigFile(scriptConfig);
32
+ }
33
+
34
+ // Check home directory
35
+ const homeConfig = path.join(os.homedir(), '.abilities-mcp', 'wp-sites.json');
36
+ if (fs.existsSync(homeConfig)) {
37
+ return loadConfigFile(homeConfig);
38
+ }
39
+
40
+ // Legacy CLI mode — single site from --host/--path
41
+ if (args.host && args.path) {
42
+ return buildLegacyConfig(args);
43
+ }
44
+
45
+ throw new Error(
46
+ 'No wp-sites.json found and no --host/--path provided.\n' +
47
+ 'Create wp-sites.json or use: --host=<ssh-host> --path=<wp-path>'
48
+ );
49
+ }
50
+
51
+ function loadConfigFile(filePath) {
52
+ const raw = fs.readFileSync(filePath, 'utf8');
53
+
54
+ // Warn if config file is readable by group or world
55
+ try {
56
+ const stat = fs.statSync(filePath);
57
+ if (stat.mode & 0o077) {
58
+ process.stderr.write(
59
+ `WARNING: ${filePath} is readable by group/world (mode ${(stat.mode & 0o777).toString(8)}). ` +
60
+ `Consider running: chmod 600 ${filePath}\n`
61
+ );
62
+ }
63
+ } catch (e) { /* stat failed — skip check */ }
64
+
65
+ const config = JSON.parse(raw);
66
+
67
+ if (!config.sites || typeof config.sites !== 'object') {
68
+ throw new Error(`Invalid wp-sites.json: missing "sites" object`);
69
+ }
70
+
71
+ if (!config.defaultSite) {
72
+ config.defaultSite = Object.keys(config.sites)[0];
73
+ }
74
+
75
+ if (!config.sites[config.defaultSite]) {
76
+ throw new Error(`Default site "${config.defaultSite}" not found in sites`);
77
+ }
78
+
79
+ // Validate each site
80
+ for (const [key, site] of Object.entries(config.sites)) {
81
+ validateSiteConfig(key, site);
82
+ }
83
+
84
+ config._isMultiSite = Object.keys(config.sites).length > 1 ||
85
+ Object.values(config.sites).some(s => s.multisite);
86
+ config._configPath = filePath;
87
+
88
+ return config;
89
+ }
90
+
91
+ function validateSiteConfig(key, site) {
92
+ if (!site.transport) {
93
+ throw new Error(`Site "${key}": missing "transport" (ssh or http)`);
94
+ }
95
+
96
+ if (site.transport === 'ssh') {
97
+ if (!site.ssh || !site.ssh.host || !site.ssh.path) {
98
+ throw new Error(`Site "${key}" (ssh): requires ssh.host and ssh.path`);
99
+ }
100
+ } else if (site.transport === 'http') {
101
+ if (!site.http || !site.http.endpoint || !site.http.username) {
102
+ throw new Error(`Site "${key}" (http): requires http.endpoint and http.username`);
103
+ }
104
+ if (!site.http.password && !site.http.passwordEnv && !site.http.passwordCommand) {
105
+ throw new Error(`Site "${key}" (http): requires one of http.password, http.passwordEnv, or http.passwordCommand`);
106
+ }
107
+ if (!site.http.endpoint.startsWith('https://') && !site.allowInsecure) {
108
+ throw new Error(`Site "${key}" (http): endpoint is not HTTPS. Set "allowInsecure": true on the site to allow HTTP`);
109
+ }
110
+ } else {
111
+ throw new Error(`Site "${key}": unknown transport "${site.transport}" (use ssh or http)`);
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Build single-site config from legacy CLI args (backward compat with mcp-ssh-bridge).
117
+ */
118
+ function buildLegacyConfig(args) {
119
+ return {
120
+ defaultSite: 'default',
121
+ _isMultiSite: false,
122
+ sites: {
123
+ default: {
124
+ label: args.host,
125
+ url: `ssh://${args.host}`,
126
+ transport: 'ssh',
127
+ ssh: {
128
+ host: args.host,
129
+ path: args.path,
130
+ user: args.user || '',
131
+ },
132
+ mcpServer: args.server || 'mcp-adapter-default-server',
133
+ }
134
+ }
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Resolve a composite site key like "wicked.community" into config + subsite URL.
140
+ */
141
+ function resolveSiteKey(config, compositeKey) {
142
+ // Direct match — "helena" or "wicked"
143
+ if (config.sites[compositeKey]) {
144
+ return { siteConfig: config.sites[compositeKey], subsiteUrl: null, resolvedEndpoint: null };
145
+ }
146
+
147
+ // Dot notation — "wicked.community"
148
+ const dotIdx = compositeKey.indexOf('.');
149
+ if (dotIdx > 0) {
150
+ const siteKey = compositeKey.substring(0, dotIdx);
151
+ const subsiteKey = compositeKey.substring(dotIdx + 1);
152
+ const site = config.sites[siteKey];
153
+ if (site && site.multisite && site.multisite[subsiteKey]) {
154
+ const subsiteUrl = site.multisite[subsiteKey];
155
+ let resolvedEndpoint = null;
156
+
157
+ // For HTTP transport: build subsite endpoint from subsite URL + parent endpoint path.
158
+ // This makes WordPress boot natively into the correct blog context —
159
+ // community.wickedevolutions.com/wp-json/mcp/... boots into blog 2,
160
+ // not wickedevolutions.com/wp-json/mcp/... which boots into blog 1.
161
+ if (site.transport === 'http' && site.http && site.http.endpoint) {
162
+ const parentUrl = new URL(site.http.endpoint);
163
+ const subsiteOrigin = new URL(subsiteUrl).origin;
164
+ resolvedEndpoint = subsiteOrigin + parentUrl.pathname;
165
+ }
166
+
167
+ return { siteConfig: site, subsiteUrl, resolvedEndpoint };
168
+ }
169
+ }
170
+
171
+ throw new Error(`Unknown site: "${compositeKey}". Available: ${Object.keys(config.sites).join(', ')}`);
172
+ }
173
+
174
+ /**
175
+ * Build full list of site keys including multisite composites.
176
+ * e.g. ["helena", "wicked", "wicked.main", "wicked.community"]
177
+ */
178
+ function buildSiteKeyEnum(config) {
179
+ const keys = [];
180
+ for (const [key, site] of Object.entries(config.sites)) {
181
+ keys.push(key);
182
+ if (site.multisite) {
183
+ for (const subKey of Object.keys(site.multisite)) {
184
+ keys.push(`${key}.${subKey}`);
185
+ }
186
+ }
187
+ }
188
+ return keys;
189
+ }
190
+
191
+ /**
192
+ * Resolve the password for an HTTP transport config, supporting
193
+ * plaintext password, environment variable, or shell command.
194
+ */
195
+ function resolvePassword(httpConfig) {
196
+ if (httpConfig.passwordEnv) {
197
+ const val = process.env[httpConfig.passwordEnv];
198
+ if (!val) throw new Error(`Environment variable ${httpConfig.passwordEnv} is not set`);
199
+ return val;
200
+ }
201
+ if (httpConfig.passwordCommand) {
202
+ return execSync(httpConfig.passwordCommand, { encoding: 'utf8' }).trim();
203
+ }
204
+ if (httpConfig.password) {
205
+ return httpConfig.password;
206
+ }
207
+ throw new Error('No password, passwordEnv, or passwordCommand configured');
208
+ }
209
+
210
+ module.exports = { loadConfig, resolvePassword, resolveSiteKey, buildSiteKeyEnum };
@@ -0,0 +1,272 @@
1
+ 'use strict';
2
+
3
+ const { SshTransport } = require('./transports/ssh-transport');
4
+ const { resolveSiteKey, resolvePassword } = require('./config');
5
+
6
+ // Incrementing counter for synthetic handshake IDs.
7
+ // Avoids integer overflow from Date.now() (13-digit ms timestamps exceed
8
+ // 32-bit int max, causing TypeError in PHP strict_types=1 environments).
9
+ // Starting at 1000 to avoid collision with real request IDs (typically 1+).
10
+ let _synthIdCounter = 1000;
11
+
12
+ /**
13
+ * Connection Pool — manages one transport per site, lazily instantiated.
14
+ *
15
+ * Each site gets its own independent SSH (or HTTP) connection with its own
16
+ * reconnection state. The pool caches the MCP handshake messages so it can
17
+ * replay them when connecting to a new site mid-session.
18
+ *
19
+ * Copyright (C) 2026 Influencentricity | Wicked Evolutions
20
+ * @license GPL-2.0-or-later
21
+ */
22
+ class ConnectionPool {
23
+
24
+ constructor(config, logger) {
25
+ this.config = config;
26
+ this.log = logger;
27
+ this.transports = new Map(); // compositeKey -> Transport
28
+ this.connecting = new Map(); // compositeKey -> Promise<Transport>
29
+
30
+ // Handshake cache — set after the default site completes init
31
+ this.cachedInitRequest = null;
32
+ this.cachedInitNotification = null;
33
+ this.clientProtocolVersion = null;
34
+ }
35
+
36
+ /**
37
+ * Cache the client's handshake messages for replay to other sites.
38
+ */
39
+ setHandshakeCache(initRequest, initNotification, protocolVersion) {
40
+ this.cachedInitRequest = initRequest;
41
+ this.cachedInitNotification = initNotification;
42
+ this.clientProtocolVersion = protocolVersion;
43
+ }
44
+
45
+ /**
46
+ * Get or lazily create a transport for a composite site key.
47
+ * Handles "helena", "wicked", "wicked.community" etc.
48
+ *
49
+ * For HTTP transport, multisite subsites (e.g. "wicked.community") share the
50
+ * same endpoint as their parent site ("wicked"). Creating a second transport
51
+ * to the same endpoint causes session contention. Instead, we reuse the
52
+ * existing transport — the WordPress MCP adapter handles subsite routing
53
+ * internally.
54
+ */
55
+ async getTransport(compositeKey) {
56
+ // Return existing
57
+ if (this.transports.has(compositeKey)) {
58
+ return this.transports.get(compositeKey);
59
+ }
60
+
61
+ // For HTTP multisite subsites, reuse the parent site's transport if it
62
+ // connects to the same endpoint. This avoids two transports competing
63
+ // for sessions on the same WordPress install.
64
+ const existing = this._findExistingHttpTransport(compositeKey);
65
+ if (existing) {
66
+ this.log(`Reusing transport for ${compositeKey} (same endpoint as ${existing.key})`);
67
+ this.transports.set(compositeKey, existing.transport);
68
+ return existing.transport;
69
+ }
70
+
71
+ // Prevent concurrent creation
72
+ if (this.connecting.has(compositeKey)) {
73
+ return this.connecting.get(compositeKey);
74
+ }
75
+
76
+ const promise = this._create(compositeKey);
77
+ this.connecting.set(compositeKey, promise);
78
+ try {
79
+ const transport = await promise;
80
+ this.connecting.delete(compositeKey);
81
+ return transport;
82
+ } catch (err) {
83
+ this.connecting.delete(compositeKey);
84
+ throw err;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Get the default site's transport (must already exist).
90
+ */
91
+ getDefaultTransport() {
92
+ return this.transports.get(this.config.defaultSite) || null;
93
+ }
94
+
95
+ /**
96
+ * Create and connect transport for the default site (no handshake replay).
97
+ * Called once at startup — the client handles the handshake directly.
98
+ */
99
+ async connectDefault(onMessage) {
100
+ const key = this.config.defaultSite;
101
+ const transport = this._createTransport(key, null);
102
+ transport.onMessage = onMessage;
103
+ await transport.connect();
104
+ this.transports.set(key, transport);
105
+ return transport;
106
+ }
107
+
108
+ /**
109
+ * Get list of currently connected composite keys.
110
+ */
111
+ getConnectedKeys() {
112
+ return Array.from(this.transports.keys());
113
+ }
114
+
115
+ /**
116
+ * Check if a composite key has an active, ready transport.
117
+ */
118
+ isConnected(compositeKey) {
119
+ const transport = this.transports.get(compositeKey);
120
+ return !!(transport && transport.isReady());
121
+ }
122
+
123
+ /**
124
+ * Probe connectivity to a site. If already connected, checks transport state.
125
+ * If not connected, does a lightweight SSH or HTTP reachability test.
126
+ * Returns { status, latencyMs, error? }
127
+ */
128
+ async healthCheck(compositeKey) {
129
+ const start = Date.now();
130
+
131
+ // Already connected — check transport state
132
+ const transport = this.transports.get(compositeKey);
133
+ if (transport) {
134
+ if (transport.isReady()) {
135
+ return { status: 'connected', latencyMs: Date.now() - start };
136
+ }
137
+ if (transport.reconnecting) {
138
+ return { status: 'reconnecting', latencyMs: Date.now() - start };
139
+ }
140
+ return { status: 'stale', latencyMs: Date.now() - start };
141
+ }
142
+
143
+ // Not connected — lightweight probe
144
+ const { resolveSiteKey } = require('./config');
145
+ try {
146
+ const { siteConfig } = resolveSiteKey(this.config, compositeKey);
147
+
148
+ if (siteConfig.transport === 'ssh') {
149
+ const { execFileSync } = require('child_process');
150
+ execFileSync('ssh', [
151
+ '-o', 'BatchMode=yes',
152
+ '-o', 'ConnectTimeout=5',
153
+ siteConfig.ssh.host,
154
+ 'echo ok',
155
+ ], { timeout: 10000, encoding: 'utf8' });
156
+ return { status: 'reachable', latencyMs: Date.now() - start };
157
+ }
158
+
159
+ if (siteConfig.transport === 'http') {
160
+ const mod = siteConfig.http.endpoint.startsWith('https://') ? require('https') : require('http');
161
+ const url = new URL(siteConfig.http.endpoint);
162
+ await new Promise((resolve, reject) => {
163
+ const req = mod.request({
164
+ hostname: url.hostname, port: url.port,
165
+ path: url.pathname, method: 'HEAD', timeout: 10000,
166
+ }, (res) => resolve(res.statusCode));
167
+ req.on('error', reject);
168
+ req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
169
+ req.end();
170
+ });
171
+ return { status: 'reachable', latencyMs: Date.now() - start };
172
+ }
173
+
174
+ return { status: 'unknown_transport', latencyMs: Date.now() - start };
175
+ } catch (err) {
176
+ return { status: 'unreachable', latencyMs: Date.now() - start, error: err.message };
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Shut down all transports.
182
+ */
183
+ async shutdownAll() {
184
+ const promises = [];
185
+ for (const [key, transport] of this.transports) {
186
+ this.log(`Shutting down transport: ${key}`);
187
+ promises.push(transport.shutdown());
188
+ }
189
+ await Promise.allSettled(promises);
190
+ this.transports.clear();
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Internal
195
+ // ---------------------------------------------------------------------------
196
+
197
+ async _create(compositeKey) {
198
+ const { subsiteUrl } = resolveSiteKey(this.config, compositeKey);
199
+
200
+ this.log(`Lazy-connecting to site: ${compositeKey}`);
201
+
202
+ const transport = this._createTransport(compositeKey, subsiteUrl);
203
+
204
+ // Set up message callback — route responses back to main
205
+ // The main entry will set this after getting the transport
206
+ transport.onMessage = null; // Caller must set this
207
+
208
+ await transport.connect();
209
+
210
+ // Replay handshake if we have cached init messages
211
+ if (this.cachedInitRequest) {
212
+ // Use a numeric ID — WordPress MCP adapter's InitializeHandler declares
213
+ // strict_types=1 and expects int $request_id. String IDs cause TypeError → 500.
214
+ const synthInit = { ...this.cachedInitRequest, id: _synthIdCounter++ };
215
+ await transport.performHandshake(synthInit, this.cachedInitNotification);
216
+ this.log(`Handshake replayed for ${compositeKey}`);
217
+ }
218
+
219
+ this.transports.set(compositeKey, transport);
220
+ return transport;
221
+ }
222
+
223
+ _createTransport(compositeKey, subsiteUrl) {
224
+ const { siteConfig, subsiteUrl: resolvedSubsiteUrl, resolvedEndpoint } = resolveSiteKey(this.config, compositeKey);
225
+ const finalSubsiteUrl = subsiteUrl || resolvedSubsiteUrl;
226
+
227
+ if (siteConfig.transport === 'ssh') {
228
+ return new SshTransport({
229
+ host: siteConfig.ssh.host,
230
+ path: siteConfig.ssh.path,
231
+ user: siteConfig.ssh.user,
232
+ mcpServer: siteConfig.mcpServer || 'mcp-adapter-default-server',
233
+ subsiteUrl: finalSubsiteUrl,
234
+ logger: this.log,
235
+ });
236
+ }
237
+
238
+ if (siteConfig.transport === 'http') {
239
+ // HTTP transport — loaded lazily to avoid requiring it when only SSH is used
240
+ const { HttpTransport } = require('./transports/http-transport');
241
+ const password = resolvePassword(siteConfig.http);
242
+ return new HttpTransport({
243
+ endpoint: resolvedEndpoint || siteConfig.http.endpoint,
244
+ username: siteConfig.http.username,
245
+ password: password,
246
+ logger: this.log,
247
+ });
248
+ }
249
+
250
+ throw new Error(`Unknown transport: ${siteConfig.transport}`);
251
+ }
252
+
253
+ /**
254
+ * Check if a composite key resolves to the same HTTP endpoint as an
255
+ * already-connected transport. Returns { key, transport } or null.
256
+ */
257
+ _findExistingHttpTransport(compositeKey) {
258
+ const { siteConfig, resolvedEndpoint } = resolveSiteKey(this.config, compositeKey);
259
+ if (siteConfig.transport !== 'http') return null;
260
+
261
+ const targetEndpoint = resolvedEndpoint || siteConfig.http.endpoint;
262
+
263
+ for (const [key, transport] of this.transports) {
264
+ if (transport.endpoint === targetEndpoint) {
265
+ return { key, transport };
266
+ }
267
+ }
268
+ return null;
269
+ }
270
+ }
271
+
272
+ module.exports = { ConnectionPool };
package/lib/logger.js ADDED
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ /**
8
+ * Create a file-based debug logger (opt-in via --debug flag).
9
+ * Writes to ~/.abilities-mcp/logs/abilities-mcp.log when enabled, no-op otherwise.
10
+ *
11
+ * Copyright (C) 2026 Influencentricity | Wicked Evolutions
12
+ * @license GPL-2.0-or-later
13
+ */
14
+ function createLogger(enabled, logPath) {
15
+ if (!enabled) return function noop() {};
16
+
17
+ const defaultDir = path.join(os.homedir(), '.abilities-mcp', 'logs');
18
+ const logFile = logPath || path.join(defaultDir, 'abilities-mcp.log');
19
+ const logDir = path.dirname(logFile);
20
+
21
+ if (!fs.existsSync(logDir)) {
22
+ fs.mkdirSync(logDir, { recursive: true, mode: 0o700 });
23
+ }
24
+
25
+ try {
26
+ const stat = fs.lstatSync(logFile);
27
+ if (stat.isSymbolicLink()) {
28
+ process.stderr.write(`abilities-mcp: refusing to write to symlink: ${logFile}\n`);
29
+ return function noop() {};
30
+ }
31
+ } catch (e) {
32
+ // File doesn't exist yet — fine
33
+ }
34
+
35
+ let fd = null;
36
+
37
+ return function log(msg) {
38
+ if (!fd) fd = fs.openSync(logFile, 'a', 0o600);
39
+ fs.writeSync(fd, `[${new Date().toISOString()}] ${msg}\n`);
40
+ };
41
+ }
42
+
43
+ module.exports = { createLogger };
@@ -0,0 +1,65 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ /**
8
+ * Register abilities-mcp as a server in Claude Desktop's config.
9
+ *
10
+ * @param {object} opts
11
+ * @param {string} [opts.name='wordpress'] - Server name in config
12
+ * @param {string} [opts.configPath] - Explicit wp-sites.json path
13
+ *
14
+ * Copyright (C) 2026 Influencentricity | Wicked Evolutions
15
+ * @license GPL-2.0-or-later
16
+ */
17
+ function registerClaudeDesktop(opts = {}) {
18
+ const name = opts.name || 'wordpress';
19
+
20
+ // Determine Claude Desktop config path per platform
21
+ let configDir;
22
+ if (process.platform === 'darwin') {
23
+ configDir = path.join(os.homedir(), 'Library', 'Application Support', 'Claude');
24
+ } else if (process.platform === 'win32') {
25
+ configDir = path.join(process.env.APPDATA || '', 'Claude');
26
+ } else {
27
+ configDir = path.join(os.homedir(), '.config', 'claude');
28
+ }
29
+
30
+ const configPath = path.join(configDir, 'claude_desktop_config.json');
31
+ const bridgePath = path.resolve(path.join(__dirname, '..', 'abilities-mcp.js'));
32
+
33
+ // Build server entry
34
+ const entryArgs = [bridgePath];
35
+ if (opts.configPath) {
36
+ entryArgs.push(`--config=${opts.configPath}`);
37
+ }
38
+
39
+ const entry = {
40
+ command: 'node',
41
+ args: entryArgs,
42
+ };
43
+
44
+ // Read or create config
45
+ let config = { mcpServers: {} };
46
+ if (fs.existsSync(configPath)) {
47
+ try {
48
+ config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
49
+ if (!config.mcpServers) config.mcpServers = {};
50
+ } catch (e) {
51
+ process.stderr.write(`Warning: could not parse ${configPath}, creating new config\n`);
52
+ config = { mcpServers: {} };
53
+ }
54
+ } else {
55
+ fs.mkdirSync(configDir, { recursive: true });
56
+ }
57
+
58
+ const existed = !!config.mcpServers[name];
59
+ config.mcpServers[name] = entry;
60
+
61
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
62
+ process.stderr.write(`${existed ? 'Updated' : 'Added'} MCP server "${name}" in ${configPath}\n`);
63
+ }
64
+
65
+ module.exports = { registerClaudeDesktop };