@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/CHANGELOG.md +81 -0
- package/LICENSE +12 -0
- package/README.md +321 -0
- package/abilities-mcp.js +169 -0
- package/lib/bridge-tools.js +67 -0
- package/lib/config.js +210 -0
- package/lib/connection-pool.js +272 -0
- package/lib/logger.js +43 -0
- package/lib/register.js +65 -0
- package/lib/router.js +436 -0
- package/lib/sanitizer.js +111 -0
- package/lib/tool-catalog.js +157 -0
- package/lib/tool-injector.js +51 -0
- package/lib/transports/http-transport.js +558 -0
- package/lib/transports/ssh-transport.js +595 -0
- package/package.json +23 -0
- package/wp-sites.example.json +49 -0
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 };
|
package/lib/register.js
ADDED
|
@@ -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 };
|