@wickedevolutions/abilities-mcp 1.3.1 → 1.5.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 +61 -0
- package/README.md +88 -17
- package/abilities-mcp.js +182 -113
- package/lib/auth/bridge-identity-provider.js +34 -0
- package/lib/auth/browser-launcher.js +67 -0
- package/lib/auth/config-migration.js +322 -0
- package/lib/auth/dcr-client.js +123 -0
- package/lib/auth/discovery-client.js +273 -0
- package/lib/auth/errors.js +114 -0
- package/lib/auth/events.js +55 -0
- package/lib/auth/fresh-each-time-identity.js +101 -0
- package/lib/auth/http-json.js +151 -0
- package/lib/auth/index.js +88 -0
- package/lib/auth/keychain-secret-store.js +98 -0
- package/lib/auth/loopback-server.js +249 -0
- package/lib/auth/memory-secret-store.js +0 -0
- package/lib/auth/oauth-client.js +357 -0
- package/lib/auth/pkce.js +93 -0
- package/lib/auth/schema-v2.js +110 -0
- package/lib/auth/secret-store.js +78 -0
- package/lib/auth/token-manager.js +378 -0
- package/lib/cli/commands/add-site.js +226 -0
- package/lib/cli/commands/force-downgrade.js +93 -0
- package/lib/cli/commands/list-sites.js +93 -0
- package/lib/cli/commands/reauth.js +108 -0
- package/lib/cli/commands/revoke.js +127 -0
- package/lib/cli/commands/self-check.js +158 -0
- package/lib/cli/commands/test.js +174 -0
- package/lib/cli/commands/upgrade-auth.js +259 -0
- package/lib/cli/config-store.js +161 -0
- package/lib/cli/context.js +102 -0
- package/lib/cli/errors.js +227 -0
- package/lib/cli/index.js +173 -0
- package/lib/cli/output.js +175 -0
- package/lib/cli/parse-args.js +80 -0
- package/lib/config.js +248 -19
- package/lib/connection-pool.js +214 -11
- package/lib/router.js +29 -11
- package/lib/transports/oauth-http-transport.js +601 -0
- package/package.json +7 -2
package/lib/config.js
CHANGED
|
@@ -1,24 +1,78 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const {
|
|
3
|
+
const { exec } = require('child_process');
|
|
4
|
+
const { promisify } = require('util');
|
|
4
5
|
const fs = require('fs');
|
|
6
|
+
const fsp = require('fs').promises;
|
|
5
7
|
const path = require('path');
|
|
6
8
|
const os = require('os');
|
|
7
9
|
|
|
10
|
+
const execAsync = promisify(exec);
|
|
11
|
+
|
|
8
12
|
/**
|
|
9
|
-
* Load configuration from wp-sites.json
|
|
13
|
+
* Load configuration from wp-sites.json, environment variables, or CLI args.
|
|
10
14
|
*
|
|
11
|
-
* Search order
|
|
15
|
+
* Search order:
|
|
12
16
|
* 1. --config=<path> explicit path
|
|
13
17
|
* 2. Same directory as abilities-mcp.js
|
|
14
18
|
* 3. ~/.abilities-mcp/wp-sites.json
|
|
19
|
+
* 4. ABILITIES_MCP_URL/USERNAME/PASSWORD env vars (single-site, .mcpb path)
|
|
20
|
+
* 5. --host/--path CLI args (legacy SSH single-site)
|
|
15
21
|
*
|
|
16
|
-
*
|
|
22
|
+
* Per Issue #5 / Phase E.2 (Stretch to Stable sprint): the startup chain
|
|
23
|
+
* — `resolveConfigFilePath`, `loadConfig`, `loadConfigFile`,
|
|
24
|
+
* `validateSiteConfig`, `resolvePassword` — is async so file reads and
|
|
25
|
+
* `passwordCommand` shell-outs do not block the event loop during boot.
|
|
17
26
|
*
|
|
18
27
|
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
19
28
|
* @license GPL-2.0-or-later
|
|
20
29
|
*/
|
|
21
|
-
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Async non-throwing existence check. Returns true iff the path resolves
|
|
33
|
+
* via fs.access(); any error (ENOENT, EACCES, …) yields false. Used by the
|
|
34
|
+
* search-order helpers which only care whether a candidate file is readable.
|
|
35
|
+
*/
|
|
36
|
+
async function _exists(p) {
|
|
37
|
+
try {
|
|
38
|
+
await fsp.access(p, fs.constants.F_OK);
|
|
39
|
+
return true;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the on-disk wp-sites.json path that `loadConfig` would consume,
|
|
47
|
+
* without reading or validating it. Returns `null` when no file path applies
|
|
48
|
+
* (env-var single-site, legacy --host/--path, or no config at all).
|
|
49
|
+
*
|
|
50
|
+
* Used by the v1→v2 migration shim in `abilities-mcp.js` so the migration can
|
|
51
|
+
* run before `loadConfig` parses + validates a stale v1 file.
|
|
52
|
+
*
|
|
53
|
+
* Mirrors steps 1-3 of `loadConfig`'s search order. Steps 4-5 (env / legacy
|
|
54
|
+
* CLI) intentionally return `null` — there is no on-disk file to migrate.
|
|
55
|
+
*
|
|
56
|
+
* @param {object} args
|
|
57
|
+
* @returns {Promise<string|null>} Absolute path of the resolved wp-sites.json, or null.
|
|
58
|
+
*/
|
|
59
|
+
async function resolveConfigFilePath(args) {
|
|
60
|
+
if (args && args.config) {
|
|
61
|
+
return path.resolve(args.config);
|
|
62
|
+
}
|
|
63
|
+
const scriptDir = path.resolve(__dirname, '..');
|
|
64
|
+
const scriptConfig = path.join(scriptDir, 'wp-sites.json');
|
|
65
|
+
if (await _exists(scriptConfig)) {
|
|
66
|
+
return scriptConfig;
|
|
67
|
+
}
|
|
68
|
+
const homeConfig = path.join(os.homedir(), '.abilities-mcp', 'wp-sites.json');
|
|
69
|
+
if (await _exists(homeConfig)) {
|
|
70
|
+
return homeConfig;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function loadConfig(args) {
|
|
22
76
|
// Explicit config path
|
|
23
77
|
if (args.config) {
|
|
24
78
|
return loadConfigFile(args.config);
|
|
@@ -27,33 +81,111 @@ function loadConfig(args) {
|
|
|
27
81
|
// Check alongside script (lib/ → package root)
|
|
28
82
|
const scriptDir = path.resolve(__dirname, '..');
|
|
29
83
|
const scriptConfig = path.join(scriptDir, 'wp-sites.json');
|
|
30
|
-
if (
|
|
84
|
+
if (await _exists(scriptConfig)) {
|
|
31
85
|
return loadConfigFile(scriptConfig);
|
|
32
86
|
}
|
|
33
87
|
|
|
34
88
|
// Check home directory
|
|
35
89
|
const homeConfig = path.join(os.homedir(), '.abilities-mcp', 'wp-sites.json');
|
|
36
|
-
if (
|
|
90
|
+
if (await _exists(homeConfig)) {
|
|
37
91
|
return loadConfigFile(homeConfig);
|
|
38
92
|
}
|
|
39
93
|
|
|
94
|
+
// Env-var single-site config — covers the .mcpb install path and any
|
|
95
|
+
// env-var-based MCP client configuration (claude mcp add, Docker, etc.)
|
|
96
|
+
if (process.env.ABILITIES_MCP_URL) {
|
|
97
|
+
return buildEnvConfig(process.env);
|
|
98
|
+
}
|
|
99
|
+
|
|
40
100
|
// Legacy CLI mode — single site from --host/--path
|
|
41
101
|
if (args.host && args.path) {
|
|
42
102
|
return buildLegacyConfig(args);
|
|
43
103
|
}
|
|
44
104
|
|
|
45
105
|
throw new Error(
|
|
46
|
-
'No
|
|
47
|
-
'
|
|
106
|
+
'No configuration found.\n' +
|
|
107
|
+
'Provide one of: wp-sites.json, ABILITIES_MCP_URL+USERNAME+PASSWORD env vars, or --host/--path.'
|
|
48
108
|
);
|
|
49
109
|
}
|
|
50
110
|
|
|
51
|
-
|
|
52
|
-
|
|
111
|
+
/**
|
|
112
|
+
* Build single-site config from env vars (ABILITIES_MCP_URL/USERNAME/PASSWORD).
|
|
113
|
+
*
|
|
114
|
+
* Auto-derives the MCP adapter endpoint from the site URL:
|
|
115
|
+
* https://example.com → https://example.com/wp-json/mcp/mcp-adapter-default-server
|
|
116
|
+
*
|
|
117
|
+
* This is the path used by .mcpb bundles installed in Claude Desktop and any
|
|
118
|
+
* other env-var-based MCP client configuration.
|
|
119
|
+
*/
|
|
120
|
+
function buildEnvConfig(env) {
|
|
121
|
+
const rawUrl = env.ABILITIES_MCP_URL;
|
|
122
|
+
const username = env.ABILITIES_MCP_USERNAME;
|
|
123
|
+
const password = env.ABILITIES_MCP_PASSWORD;
|
|
124
|
+
|
|
125
|
+
if (!username) {
|
|
126
|
+
throw new Error('ABILITIES_MCP_URL is set but ABILITIES_MCP_USERNAME is missing');
|
|
127
|
+
}
|
|
128
|
+
if (!password) {
|
|
129
|
+
throw new Error('ABILITIES_MCP_URL is set but ABILITIES_MCP_PASSWORD is missing');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let parsedUrl;
|
|
133
|
+
try {
|
|
134
|
+
parsedUrl = new URL(rawUrl);
|
|
135
|
+
} catch (e) {
|
|
136
|
+
throw new Error(`ABILITIES_MCP_URL is not a valid URL: ${rawUrl}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const isHttps = parsedUrl.protocol === 'https:';
|
|
140
|
+
const isHttp = parsedUrl.protocol === 'http:';
|
|
141
|
+
if (!isHttps && !isHttp) {
|
|
142
|
+
throw new Error(`ABILITIES_MCP_URL must be http or https: ${rawUrl}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Strip trailing slash from origin+path, then append the adapter route.
|
|
146
|
+
const base = (parsedUrl.origin + parsedUrl.pathname).replace(/\/+$/, '');
|
|
147
|
+
const endpoint = `${base}/wp-json/mcp/mcp-adapter-default-server`;
|
|
148
|
+
|
|
149
|
+
const siteConfig = {
|
|
150
|
+
label: parsedUrl.hostname,
|
|
151
|
+
url: parsedUrl.origin,
|
|
152
|
+
transport: 'http',
|
|
153
|
+
http: {
|
|
154
|
+
endpoint,
|
|
155
|
+
username,
|
|
156
|
+
password,
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// Allow plain HTTP only when the operator explicitly opts in. The .mcpb path
|
|
161
|
+
// expects HTTPS by default; localhost dev gets a narrow exception.
|
|
162
|
+
if (isHttp) {
|
|
163
|
+
const isLocal = parsedUrl.hostname === 'localhost' || parsedUrl.hostname === '127.0.0.1';
|
|
164
|
+
if (!isLocal && env.ABILITIES_MCP_ALLOW_INSECURE !== 'true') {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`ABILITIES_MCP_URL is HTTP (not HTTPS): ${rawUrl}\n` +
|
|
167
|
+
`Set ABILITIES_MCP_ALLOW_INSECURE=true to allow plain HTTP.`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
siteConfig.allowInsecure = true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
defaultSite: 'default',
|
|
175
|
+
_isMultiSite: false,
|
|
176
|
+
_configSource: 'env',
|
|
177
|
+
sites: {
|
|
178
|
+
default: siteConfig,
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function loadConfigFile(filePath) {
|
|
184
|
+
const raw = await fsp.readFile(filePath, 'utf8');
|
|
53
185
|
|
|
54
186
|
// Warn if config file is readable by group or world
|
|
55
187
|
try {
|
|
56
|
-
const stat =
|
|
188
|
+
const stat = await fsp.stat(filePath);
|
|
57
189
|
if (stat.mode & 0o077) {
|
|
58
190
|
process.stderr.write(
|
|
59
191
|
`WARNING: ${filePath} is readable by group/world (mode ${(stat.mode & 0o777).toString(8)}). ` +
|
|
@@ -78,7 +210,7 @@ function loadConfigFile(filePath) {
|
|
|
78
210
|
|
|
79
211
|
// Validate each site
|
|
80
212
|
for (const [key, site] of Object.entries(config.sites)) {
|
|
81
|
-
validateSiteConfig(key, site);
|
|
213
|
+
await validateSiteConfig(key, site);
|
|
82
214
|
}
|
|
83
215
|
|
|
84
216
|
config._isMultiSite = Object.keys(config.sites).length > 1 ||
|
|
@@ -88,7 +220,57 @@ function loadConfigFile(filePath) {
|
|
|
88
220
|
return config;
|
|
89
221
|
}
|
|
90
222
|
|
|
91
|
-
function validateSiteConfig(key, site) {
|
|
223
|
+
async function validateSiteConfig(key, site) {
|
|
224
|
+
// v2 OAuth sites carry no transport block (Appendix F.5 + add-site flow).
|
|
225
|
+
// The runtime treats them as HTTP — endpoint comes from auth.mcp_resource
|
|
226
|
+
// or site.mcp_resource (resolved by the OAuth-aware transport).
|
|
227
|
+
if (site.auth && site.auth.method === 'oauth') {
|
|
228
|
+
if (!site.url) {
|
|
229
|
+
throw new Error(`Site "${key}" (oauth): requires "url"`);
|
|
230
|
+
}
|
|
231
|
+
if (!site.auth.access_token_ref || !site.auth.refresh_token_ref) {
|
|
232
|
+
throw new Error(`Site "${key}" (oauth): requires auth.access_token_ref and auth.refresh_token_ref`);
|
|
233
|
+
}
|
|
234
|
+
if (!site.mcp_resource) {
|
|
235
|
+
throw new Error(`Site "${key}" (oauth): requires mcp_resource (set during add-site / reauth)`);
|
|
236
|
+
}
|
|
237
|
+
if (!site.mcp_resource.startsWith('https://') && !site.allowInsecure) {
|
|
238
|
+
throw new Error(`Site "${key}" (oauth): mcp_resource is not HTTPS. Set "allowInsecure": true on the site to allow HTTP`);
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// v2 App-Password sites — produced by config-migration from v1 transport-style
|
|
244
|
+
// configs. Secret lives in keychain (auth.password_ref); the legacy http.password*
|
|
245
|
+
// fields are stripped during migration so the keychain is the sole source of
|
|
246
|
+
// truth. Carrier transport (http or ssh) is preserved for the runtime.
|
|
247
|
+
if (site.auth && site.auth.method === 'apppassword') {
|
|
248
|
+
if (!site.auth.username) {
|
|
249
|
+
throw new Error(`Site "${key}" (apppassword): requires auth.username`);
|
|
250
|
+
}
|
|
251
|
+
if (!site.auth.password_ref) {
|
|
252
|
+
throw new Error(`Site "${key}" (apppassword): requires auth.password_ref`);
|
|
253
|
+
}
|
|
254
|
+
if (!site.transport) {
|
|
255
|
+
throw new Error(`Site "${key}" (apppassword): missing "transport" (ssh or http)`);
|
|
256
|
+
}
|
|
257
|
+
if (site.transport === 'ssh') {
|
|
258
|
+
if (!site.ssh || !site.ssh.host || !site.ssh.path) {
|
|
259
|
+
throw new Error(`Site "${key}" (apppassword/ssh): requires ssh.host and ssh.path`);
|
|
260
|
+
}
|
|
261
|
+
} else if (site.transport === 'http') {
|
|
262
|
+
if (!site.http || !site.http.endpoint) {
|
|
263
|
+
throw new Error(`Site "${key}" (apppassword/http): requires http.endpoint`);
|
|
264
|
+
}
|
|
265
|
+
if (!site.http.endpoint.startsWith('https://') && !site.allowInsecure) {
|
|
266
|
+
throw new Error(`Site "${key}" (apppassword/http): endpoint is not HTTPS. Set "allowInsecure": true on the site to allow HTTP`);
|
|
267
|
+
}
|
|
268
|
+
} else {
|
|
269
|
+
throw new Error(`Site "${key}" (apppassword): unknown transport "${site.transport}" (use ssh or http)`);
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
92
274
|
if (!site.transport) {
|
|
93
275
|
throw new Error(`Site "${key}": missing "transport" (ssh or http)`);
|
|
94
276
|
}
|
|
@@ -137,6 +319,7 @@ function buildLegacyConfig(args) {
|
|
|
137
319
|
|
|
138
320
|
/**
|
|
139
321
|
* Resolve a composite site key like "wicked.community" into config + subsite URL.
|
|
322
|
+
* Pure in-memory dispatch — kept synchronous since it has no I/O.
|
|
140
323
|
*/
|
|
141
324
|
function resolveSiteKey(config, compositeKey) {
|
|
142
325
|
// Direct match — "helena" or "wicked"
|
|
@@ -189,17 +372,23 @@ function buildSiteKeyEnum(config) {
|
|
|
189
372
|
}
|
|
190
373
|
|
|
191
374
|
/**
|
|
192
|
-
* Resolve the password for an HTTP transport config, supporting
|
|
193
|
-
*
|
|
375
|
+
* Resolve the password for an HTTP transport config, supporting plaintext
|
|
376
|
+
* password, environment variable, or shell command.
|
|
377
|
+
*
|
|
378
|
+
* `passwordCommand` is dispatched through `util.promisify(exec)` so the shell
|
|
379
|
+
* interprets the command string the same way the previous `execSync` did
|
|
380
|
+
* (operators rely on pipes, redirects, and command chaining — e.g.
|
|
381
|
+
* `op read 'op://Vault/foo' | tr -d '\n'` — which `execFile` cannot run).
|
|
194
382
|
*/
|
|
195
|
-
function resolvePassword(httpConfig) {
|
|
383
|
+
async function resolvePassword(httpConfig) {
|
|
196
384
|
if (httpConfig.passwordEnv) {
|
|
197
385
|
const val = process.env[httpConfig.passwordEnv];
|
|
198
386
|
if (!val) throw new Error(`Environment variable ${httpConfig.passwordEnv} is not set`);
|
|
199
387
|
return val;
|
|
200
388
|
}
|
|
201
389
|
if (httpConfig.passwordCommand) {
|
|
202
|
-
|
|
390
|
+
const { stdout } = await execAsync(httpConfig.passwordCommand, { encoding: 'utf8' });
|
|
391
|
+
return stdout.trim();
|
|
203
392
|
}
|
|
204
393
|
if (httpConfig.password) {
|
|
205
394
|
return httpConfig.password;
|
|
@@ -207,4 +396,44 @@ function resolvePassword(httpConfig) {
|
|
|
207
396
|
throw new Error('No password, passwordEnv, or passwordCommand configured');
|
|
208
397
|
}
|
|
209
398
|
|
|
210
|
-
|
|
399
|
+
/**
|
|
400
|
+
* Resolve a site's HTTP password, dispatching on schema shape.
|
|
401
|
+
*
|
|
402
|
+
* v2 App-Password sites (auth.method === 'apppassword' + auth.password_ref)
|
|
403
|
+
* read the secret from the keychain via the SecretStore. v1 carriers without
|
|
404
|
+
* a ref fall back to the `resolvePassword(http)` resolver.
|
|
405
|
+
*
|
|
406
|
+
* @param {object} site
|
|
407
|
+
* @param {object} [secretStore] SecretStore instance. Lazily defaults to
|
|
408
|
+
* KeychainSecretStore when an apppassword path
|
|
409
|
+
* runs and no store was supplied — preserves
|
|
410
|
+
* the "no keytar for SSH-only / v1-only setups"
|
|
411
|
+
* property by deferring the require to the
|
|
412
|
+
* point of need.
|
|
413
|
+
* @returns {Promise<string>}
|
|
414
|
+
*/
|
|
415
|
+
async function resolveSitePassword(site, secretStore) {
|
|
416
|
+
if (site && site.auth && site.auth.method === 'apppassword' && site.auth.password_ref) {
|
|
417
|
+
let store = secretStore;
|
|
418
|
+
if (!store) {
|
|
419
|
+
const { KeychainSecretStore } = require('./auth/keychain-secret-store');
|
|
420
|
+
store = new KeychainSecretStore();
|
|
421
|
+
}
|
|
422
|
+
const { resolveRef } = require('./auth/secret-store');
|
|
423
|
+
return resolveRef(store, site.auth.password_ref);
|
|
424
|
+
}
|
|
425
|
+
if (site && site.http) {
|
|
426
|
+
return resolvePassword(site.http);
|
|
427
|
+
}
|
|
428
|
+
throw new Error('No password source configured for site');
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
module.exports = {
|
|
432
|
+
loadConfig,
|
|
433
|
+
resolveConfigFilePath,
|
|
434
|
+
resolvePassword,
|
|
435
|
+
resolveSitePassword,
|
|
436
|
+
resolveSiteKey,
|
|
437
|
+
buildSiteKeyEnum,
|
|
438
|
+
buildEnvConfig,
|
|
439
|
+
};
|
package/lib/connection-pool.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { SshTransport } = require('./transports/ssh-transport');
|
|
4
|
-
const { resolveSiteKey,
|
|
4
|
+
const { resolveSiteKey, resolveSitePassword } = require('./config');
|
|
5
5
|
|
|
6
6
|
// Incrementing counter for synthetic handshake IDs.
|
|
7
7
|
// Avoids integer overflow from Date.now() (13-digit ms timestamps exceed
|
|
@@ -9,6 +9,23 @@ const { resolveSiteKey, resolvePassword } = require('./config');
|
|
|
9
9
|
// Starting at 1000 to avoid collision with real request IDs (typically 1+).
|
|
10
10
|
let _synthIdCounter = 1000;
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Build a TokenManager-shaped siteAuth object from a v2 OAuth site block.
|
|
14
|
+
* The OAuthHttpTransport and TokenManager use this shape.
|
|
15
|
+
*/
|
|
16
|
+
function _siteAuthFromConfig(siteId, siteConfig, asMetadata) {
|
|
17
|
+
return {
|
|
18
|
+
siteId,
|
|
19
|
+
tokenEndpoint: asMetadata && asMetadata.token_endpoint,
|
|
20
|
+
clientId: siteConfig.auth.client_id,
|
|
21
|
+
accessTokenRef: siteConfig.auth.access_token_ref,
|
|
22
|
+
refreshTokenRef: siteConfig.auth.refresh_token_ref,
|
|
23
|
+
accessTokenExpiresAt: siteConfig.auth.access_token_expires_at,
|
|
24
|
+
refreshTokenExpiresAt: siteConfig.auth.refresh_token_expires_at,
|
|
25
|
+
authStatus: siteConfig.auth_status || 'active',
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
12
29
|
/**
|
|
13
30
|
* Connection Pool — manages one transport per site, lazily instantiated.
|
|
14
31
|
*
|
|
@@ -21,12 +38,38 @@ let _synthIdCounter = 1000;
|
|
|
21
38
|
*/
|
|
22
39
|
class ConnectionPool {
|
|
23
40
|
|
|
24
|
-
|
|
41
|
+
/**
|
|
42
|
+
* @param {object} config
|
|
43
|
+
* @param {function} logger
|
|
44
|
+
* @param {object} [deps] Optional injection seam for tests
|
|
45
|
+
* @param {object} [deps.secretStore] Defaults to KeychainSecretStore
|
|
46
|
+
* @param {object} [deps.tokenManager] Defaults to a TokenManager built from secretStore
|
|
47
|
+
* @param {function} [deps.discover] Defaults to lib/auth/discovery-client.discover
|
|
48
|
+
* @param {function} [deps.persistAuthStatus] (siteId, newStatus) => void
|
|
49
|
+
* Persists to wp-sites.json. Defaults to
|
|
50
|
+
* atomic write via config-migration._atomicWrite
|
|
51
|
+
* when config._configPath is set.
|
|
52
|
+
* @param {boolean} [deps.allowInsecure] For local-dev OAuth over HTTP
|
|
53
|
+
*/
|
|
54
|
+
constructor(config, logger, deps = {}) {
|
|
25
55
|
this.config = config;
|
|
26
56
|
this.log = logger;
|
|
27
57
|
this.transports = new Map(); // compositeKey -> Transport
|
|
28
58
|
this.connecting = new Map(); // compositeKey -> Promise<Transport>
|
|
29
59
|
|
|
60
|
+
// OAuth-runtime deps. Built lazily so SSH-only / App-Password-only setups
|
|
61
|
+
// never load keytar or the auth modules at all.
|
|
62
|
+
this._deps = deps;
|
|
63
|
+
this._secretStore = deps.secretStore || null;
|
|
64
|
+
this._tokenManager = deps.tokenManager || null;
|
|
65
|
+
this._discover = deps.discover || null;
|
|
66
|
+
this._allowInsecure = !!deps.allowInsecure;
|
|
67
|
+
this._persistAuthStatus = deps.persistAuthStatus || null;
|
|
68
|
+
|
|
69
|
+
// Cache of OAuth AS metadata per site URL — avoids re-probing .well-known
|
|
70
|
+
// on every transport rebuild. Refreshed when the transport is recreated.
|
|
71
|
+
this._asMetadataCache = new Map(); // siteUrl -> { asMetadata, prMetadata }
|
|
72
|
+
|
|
30
73
|
// Handshake cache — set after the default site completes init
|
|
31
74
|
this.cachedInitRequest = null;
|
|
32
75
|
this.cachedInitNotification = null;
|
|
@@ -98,7 +141,7 @@ class ConnectionPool {
|
|
|
98
141
|
*/
|
|
99
142
|
async connectDefault(onMessage) {
|
|
100
143
|
const key = this.config.defaultSite;
|
|
101
|
-
const transport = this._createTransport(key, null);
|
|
144
|
+
const transport = await this._createTransport(key, null);
|
|
102
145
|
transport.onMessage = onMessage;
|
|
103
146
|
await transport.connect();
|
|
104
147
|
this.transports.set(key, transport);
|
|
@@ -145,6 +188,28 @@ class ConnectionPool {
|
|
|
145
188
|
try {
|
|
146
189
|
const { siteConfig } = resolveSiteKey(this.config, compositeKey);
|
|
147
190
|
|
|
191
|
+
// v2 OAuth site — probe the resource URL with HEAD. We do NOT mint a
|
|
192
|
+
// token here; this is just a reachability check, the real auth happens
|
|
193
|
+
// on first MCP request via OAuthHttpTransport.
|
|
194
|
+
if (siteConfig.auth && siteConfig.auth.method === 'oauth') {
|
|
195
|
+
const target = siteConfig.mcp_resource;
|
|
196
|
+
if (!target) {
|
|
197
|
+
return { status: 'unreachable', latencyMs: Date.now() - start, error: 'no mcp_resource configured' };
|
|
198
|
+
}
|
|
199
|
+
const mod = target.startsWith('https://') ? require('https') : require('http');
|
|
200
|
+
const url = new URL(target);
|
|
201
|
+
await new Promise((resolve, reject) => {
|
|
202
|
+
const req = mod.request({
|
|
203
|
+
hostname: url.hostname, port: url.port,
|
|
204
|
+
path: url.pathname, method: 'HEAD', timeout: 10000,
|
|
205
|
+
}, (res) => resolve(res.statusCode));
|
|
206
|
+
req.on('error', reject);
|
|
207
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
|
208
|
+
req.end();
|
|
209
|
+
});
|
|
210
|
+
return { status: 'reachable', latencyMs: Date.now() - start };
|
|
211
|
+
}
|
|
212
|
+
|
|
148
213
|
if (siteConfig.transport === 'ssh') {
|
|
149
214
|
const { execFileSync } = require('child_process');
|
|
150
215
|
execFileSync('ssh', [
|
|
@@ -199,7 +264,7 @@ class ConnectionPool {
|
|
|
199
264
|
|
|
200
265
|
this.log(`Lazy-connecting to site: ${compositeKey}`);
|
|
201
266
|
|
|
202
|
-
const transport = this._createTransport(compositeKey, subsiteUrl);
|
|
267
|
+
const transport = await this._createTransport(compositeKey, subsiteUrl);
|
|
203
268
|
|
|
204
269
|
// Set up message callback — route responses back to main
|
|
205
270
|
// The main entry will set this after getting the transport
|
|
@@ -220,10 +285,18 @@ class ConnectionPool {
|
|
|
220
285
|
return transport;
|
|
221
286
|
}
|
|
222
287
|
|
|
223
|
-
_createTransport(compositeKey, subsiteUrl) {
|
|
288
|
+
async _createTransport(compositeKey, subsiteUrl) {
|
|
224
289
|
const { siteConfig, subsiteUrl: resolvedSubsiteUrl, resolvedEndpoint } = resolveSiteKey(this.config, compositeKey);
|
|
225
290
|
const finalSubsiteUrl = subsiteUrl || resolvedSubsiteUrl;
|
|
226
291
|
|
|
292
|
+
// v2 OAuth dispatch — single branch per Issue #17 acceptance criteria.
|
|
293
|
+
// Sites with auth.method === 'oauth' use the OAuth-aware HTTP transport;
|
|
294
|
+
// every other site (App Password, SSH carrier-only) keeps the existing
|
|
295
|
+
// legacy code paths untouched.
|
|
296
|
+
if (siteConfig.auth && siteConfig.auth.method === 'oauth') {
|
|
297
|
+
return this._createOAuthHttpTransport(compositeKey, siteConfig);
|
|
298
|
+
}
|
|
299
|
+
|
|
227
300
|
if (siteConfig.transport === 'ssh') {
|
|
228
301
|
return new SshTransport({
|
|
229
302
|
host: siteConfig.ssh.host,
|
|
@@ -236,13 +309,25 @@ class ConnectionPool {
|
|
|
236
309
|
}
|
|
237
310
|
|
|
238
311
|
if (siteConfig.transport === 'http') {
|
|
239
|
-
// HTTP transport — loaded lazily to avoid requiring it when only SSH is used
|
|
312
|
+
// HTTP transport — loaded lazily to avoid requiring it when only SSH is used.
|
|
313
|
+
// v2 apppassword sites resolve the secret from keychain via auth.password_ref;
|
|
314
|
+
// v1 sites fall through to the legacy synchronous resolver. KeychainSecretStore
|
|
315
|
+
// is only constructed when an apppassword path actually runs, preserving the
|
|
316
|
+
// "no keytar for SSH-only / v1-only setups" property.
|
|
240
317
|
const { HttpTransport } = require('./transports/http-transport');
|
|
241
|
-
const
|
|
318
|
+
const isV2AppPassword = siteConfig.auth
|
|
319
|
+
&& siteConfig.auth.method === 'apppassword'
|
|
320
|
+
&& siteConfig.auth.password_ref;
|
|
321
|
+
if (isV2AppPassword && !this._secretStore) {
|
|
322
|
+
const { KeychainSecretStore } = require('./auth/keychain-secret-store');
|
|
323
|
+
this._secretStore = new KeychainSecretStore();
|
|
324
|
+
}
|
|
325
|
+
const password = await resolveSitePassword(siteConfig, this._secretStore);
|
|
326
|
+
const username = (siteConfig.auth && siteConfig.auth.username) || siteConfig.http.username;
|
|
242
327
|
return new HttpTransport({
|
|
243
328
|
endpoint: resolvedEndpoint || siteConfig.http.endpoint,
|
|
244
|
-
username
|
|
245
|
-
password
|
|
329
|
+
username,
|
|
330
|
+
password,
|
|
246
331
|
logger: this.log,
|
|
247
332
|
});
|
|
248
333
|
}
|
|
@@ -250,15 +335,133 @@ class ConnectionPool {
|
|
|
250
335
|
throw new Error(`Unknown transport: ${siteConfig.transport}`);
|
|
251
336
|
}
|
|
252
337
|
|
|
338
|
+
/**
|
|
339
|
+
* Build an OAuthHttpTransport for a v2 OAuth site. Lazily resolves AS
|
|
340
|
+
* metadata via the discovery client (passing capability-pin state per
|
|
341
|
+
* Appendix H.2.3 — pinned-then-404 throws CapabilityPinningError, which
|
|
342
|
+
* we let propagate so the bridge fails loud rather than silently
|
|
343
|
+
* downgrading to App Password).
|
|
344
|
+
*/
|
|
345
|
+
async _createOAuthHttpTransport(compositeKey, siteConfig) {
|
|
346
|
+
const { OAuthHttpTransport } = require('./transports/oauth-http-transport');
|
|
347
|
+
const { TokenManager } = require('./auth/token-manager');
|
|
348
|
+
const { discover } = require('./auth/discovery-client');
|
|
349
|
+
|
|
350
|
+
if (!siteConfig.mcp_resource) {
|
|
351
|
+
throw new Error(
|
|
352
|
+
`Site "${compositeKey}" (oauth): no mcp_resource configured — ` +
|
|
353
|
+
`re-run \`abilities-mcp reauth ${compositeKey}\` to repopulate it`
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
if (!siteConfig.url) {
|
|
357
|
+
throw new Error(`Site "${compositeKey}" (oauth): no url configured`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (!this._secretStore) {
|
|
361
|
+
const { KeychainSecretStore } = require('./auth/keychain-secret-store');
|
|
362
|
+
this._secretStore = new KeychainSecretStore();
|
|
363
|
+
}
|
|
364
|
+
if (!this._tokenManager) {
|
|
365
|
+
this._tokenManager = new TokenManager({
|
|
366
|
+
secretStore: this._secretStore,
|
|
367
|
+
allowInsecure: this._allowInsecure,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
const discoverFn = this._discover || discover;
|
|
371
|
+
|
|
372
|
+
let asMetadata = (this._asMetadataCache.get(siteConfig.url) || {}).asMetadata;
|
|
373
|
+
if (!asMetadata) {
|
|
374
|
+
const result = await discoverFn(siteConfig.url, {
|
|
375
|
+
pinned: !!siteConfig.oauth_capability_pinned,
|
|
376
|
+
pinnedFirstSeenAt: siteConfig.oauth_capability_pinned
|
|
377
|
+
&& siteConfig.oauth_capability_pinned.first_seen_at,
|
|
378
|
+
allowInsecure: this._allowInsecure,
|
|
379
|
+
});
|
|
380
|
+
asMetadata = result.asMetadata;
|
|
381
|
+
this._asMetadataCache.set(siteConfig.url, {
|
|
382
|
+
asMetadata: result.asMetadata,
|
|
383
|
+
prMetadata: result.prMetadata,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (!asMetadata || !asMetadata.token_endpoint) {
|
|
388
|
+
throw new Error(
|
|
389
|
+
`Site "${compositeKey}" (oauth): discovery did not yield a token_endpoint`
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const siteAuth = _siteAuthFromConfig(compositeKey, siteConfig, asMetadata);
|
|
394
|
+
|
|
395
|
+
return new OAuthHttpTransport({
|
|
396
|
+
endpoint: siteConfig.mcp_resource,
|
|
397
|
+
tokenManager: this._tokenManager,
|
|
398
|
+
siteAuth,
|
|
399
|
+
onAuthStatusChange: (newStatus, info) => {
|
|
400
|
+
// In-memory update first so subsequent transport rebuilds see it.
|
|
401
|
+
try {
|
|
402
|
+
siteConfig.auth_status = newStatus;
|
|
403
|
+
} catch { /* siteConfig may be frozen in tests — ignore */ }
|
|
404
|
+
this.log(
|
|
405
|
+
`OAuth auth_status change: ${compositeKey} → ${newStatus} (${info && info.reason || 'unknown'})`
|
|
406
|
+
);
|
|
407
|
+
// Persist to wp-sites.json best-effort. A failure to write should
|
|
408
|
+
// not break the request path that triggered this change.
|
|
409
|
+
if (this._persistAuthStatus) {
|
|
410
|
+
Promise.resolve()
|
|
411
|
+
.then(() => this._persistAuthStatus(compositeKey, newStatus, info))
|
|
412
|
+
.catch((err) => this.log(`OAuth auth_status persist failed: ${err.message}`));
|
|
413
|
+
} else if (this.config && this.config._configPath) {
|
|
414
|
+
this._defaultPersistAuthStatus(compositeKey, newStatus)
|
|
415
|
+
.catch((err) => this.log(`OAuth auth_status persist failed: ${err.message}`));
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
logger: this.log,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Default auth_status persistor — atomic rewrite of wp-sites.json. Used
|
|
424
|
+
* when the pool was constructed without a custom persistAuthStatus.
|
|
425
|
+
*/
|
|
426
|
+
async _defaultPersistAuthStatus(siteId, newStatus) {
|
|
427
|
+
const { _atomicWrite } = require('./auth/config-migration');
|
|
428
|
+
const fs = require('node:fs');
|
|
429
|
+
const filePath = this.config._configPath;
|
|
430
|
+
if (!filePath) return;
|
|
431
|
+
let raw;
|
|
432
|
+
try {
|
|
433
|
+
raw = await fs.promises.readFile(filePath, 'utf8');
|
|
434
|
+
} catch (err) {
|
|
435
|
+
throw new Error(`read ${filePath}: ${err.message}`);
|
|
436
|
+
}
|
|
437
|
+
let parsed;
|
|
438
|
+
try { parsed = JSON.parse(raw); }
|
|
439
|
+
catch (err) {
|
|
440
|
+
throw new Error(`parse ${filePath}: ${err.message}`);
|
|
441
|
+
}
|
|
442
|
+
if (parsed && parsed.sites && parsed.sites[siteId]) {
|
|
443
|
+
parsed.sites[siteId].auth_status = newStatus;
|
|
444
|
+
await _atomicWrite(filePath, parsed);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
253
448
|
/**
|
|
254
449
|
* Check if a composite key resolves to the same HTTP endpoint as an
|
|
255
450
|
* already-connected transport. Returns { key, transport } or null.
|
|
451
|
+
*
|
|
452
|
+
* Covers both v1 App-Password HTTP sites and v2 OAuth sites — the dedup
|
|
453
|
+
* target is whichever URL the eventual transport will POST to.
|
|
256
454
|
*/
|
|
257
455
|
_findExistingHttpTransport(compositeKey) {
|
|
258
456
|
const { siteConfig, resolvedEndpoint } = resolveSiteKey(this.config, compositeKey);
|
|
259
|
-
if (siteConfig.transport !== 'http') return null;
|
|
260
457
|
|
|
261
|
-
|
|
458
|
+
let targetEndpoint = null;
|
|
459
|
+
if (siteConfig.auth && siteConfig.auth.method === 'oauth') {
|
|
460
|
+
targetEndpoint = siteConfig.mcp_resource;
|
|
461
|
+
} else if (siteConfig.transport === 'http') {
|
|
462
|
+
targetEndpoint = resolvedEndpoint || (siteConfig.http && siteConfig.http.endpoint);
|
|
463
|
+
}
|
|
464
|
+
if (!targetEndpoint) return null;
|
|
262
465
|
|
|
263
466
|
for (const [key, transport] of this.transports) {
|
|
264
467
|
if (transport.endpoint === targetEndpoint) {
|