@wickedevolutions/abilities-mcp 1.3.1 → 1.5.3

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/README.md +88 -17
  3. package/abilities-mcp.js +191 -114
  4. package/lib/auth/bridge-identity-provider.js +34 -0
  5. package/lib/auth/browser-launcher.js +67 -0
  6. package/lib/auth/config-migration.js +322 -0
  7. package/lib/auth/dcr-client.js +123 -0
  8. package/lib/auth/discovery-client.js +273 -0
  9. package/lib/auth/errors.js +114 -0
  10. package/lib/auth/events.js +55 -0
  11. package/lib/auth/fresh-each-time-identity.js +101 -0
  12. package/lib/auth/http-json.js +151 -0
  13. package/lib/auth/index.js +88 -0
  14. package/lib/auth/keychain-secret-store.js +265 -0
  15. package/lib/auth/loopback-server.js +249 -0
  16. package/lib/auth/memory-secret-store.js +0 -0
  17. package/lib/auth/oauth-client.js +357 -0
  18. package/lib/auth/pkce.js +93 -0
  19. package/lib/auth/schema-v2.js +110 -0
  20. package/lib/auth/secret-store.js +78 -0
  21. package/lib/auth/token-manager.js +378 -0
  22. package/lib/cli/commands/add-site.js +226 -0
  23. package/lib/cli/commands/force-downgrade.js +93 -0
  24. package/lib/cli/commands/list-sites.js +93 -0
  25. package/lib/cli/commands/reauth.js +108 -0
  26. package/lib/cli/commands/revoke.js +127 -0
  27. package/lib/cli/commands/self-check.js +158 -0
  28. package/lib/cli/commands/test.js +174 -0
  29. package/lib/cli/commands/upgrade-auth.js +259 -0
  30. package/lib/cli/config-store.js +328 -0
  31. package/lib/cli/context.js +102 -0
  32. package/lib/cli/errors.js +227 -0
  33. package/lib/cli/index.js +173 -0
  34. package/lib/cli/output.js +175 -0
  35. package/lib/cli/parse-args.js +80 -0
  36. package/lib/config-source-line.js +85 -0
  37. package/lib/config.js +282 -22
  38. package/lib/connection-pool.js +214 -11
  39. package/lib/router.js +29 -11
  40. package/lib/transports/oauth-http-transport.js +601 -0
  41. package/package.json +8 -2
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { SshTransport } = require('./transports/ssh-transport');
4
- const { resolveSiteKey, resolvePassword } = require('./config');
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
- constructor(config, logger) {
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 password = resolvePassword(siteConfig.http);
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: siteConfig.http.username,
245
- password: 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
- const targetEndpoint = resolvedEndpoint || siteConfig.http.endpoint;
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) {
package/lib/router.js CHANGED
@@ -11,7 +11,8 @@ const { isBridgeTool, injectBridgeTools } = require('./bridge-tools');
11
11
  * - Route client messages to the correct transport
12
12
  * - Handle bridge tools locally (health, browse, load)
13
13
  * - Sanitize and enrich tools/list responses (annotations, site param, bridge tools)
14
- * - Handle resources/list and resources/read locally
14
+ * - Forward resources/list to WordPress and inject bridge resources
15
+ * - Handle resources/read locally for bridge URIs, forward others to WordPress
15
16
  * - Route tools/call to the correct site transport
16
17
  *
17
18
  * Copyright (C) 2026 Influencentricity | Wicked Evolutions
@@ -44,6 +45,9 @@ class McpRouter {
44
45
  this.clientProtocolVersion = null;
45
46
  this.initHandshakeComplete = false;
46
47
 
48
+ // Track pending resources/list request IDs for response interception
49
+ this.pendingResourcesListIds = new Set();
50
+
47
51
  // Default transport
48
52
  this.defaultTransport = null;
49
53
 
@@ -115,9 +119,12 @@ class McpRouter {
115
119
  return;
116
120
  }
117
121
 
118
- // resources/list — handle locally
122
+ // resources/list — forward to WordPress, inject bridge resources on response
119
123
  if (msg.method === 'resources/list') {
120
- this._handleResourcesList(msg);
124
+ if (msg.id !== undefined) {
125
+ this.pendingResourcesListIds.add(msg.id);
126
+ }
127
+ this._forwardToDefault(line);
121
128
  return;
122
129
  }
123
130
 
@@ -166,6 +173,16 @@ class McpRouter {
166
173
  return;
167
174
  }
168
175
 
176
+ // Inject bridge resources into resources/list responses
177
+ if (parsedMsg.id !== undefined && this.pendingResourcesListIds.has(parsedMsg.id)) {
178
+ this.pendingResourcesListIds.delete(parsedMsg.id);
179
+ if (!parsedMsg.error) {
180
+ this._injectBridgeResources(parsedMsg);
181
+ }
182
+ this.sendToClient(JSON.stringify(parsedMsg));
183
+ return;
184
+ }
185
+
169
186
  // Detect execute-ability error responses and convert to isError format
170
187
  if (parsedMsg.result && parsedMsg.result.content) {
171
188
  const content = parsedMsg.result.content;
@@ -388,22 +405,23 @@ class McpRouter {
388
405
  // Internal — resources
389
406
  // ---------------------------------------------------------------------------
390
407
 
391
- _handleResourcesList(msg) {
392
- const resources = [];
408
+ /**
409
+ * Inject bridge-owned resources into a resources/list response from WordPress.
410
+ * @param {object} msg - Parsed JSON-RPC response
411
+ */
412
+ _injectBridgeResources(msg) {
413
+ // Ensure result.resources array exists (WordPress may return empty or error)
414
+ if (!msg.result) msg.result = {};
415
+ if (!Array.isArray(msg.result.resources)) msg.result.resources = [];
393
416
 
394
417
  if (this.isMultiSite) {
395
- resources.push({
418
+ msg.result.resources.push({
396
419
  uri: 'wp-abilities://sites',
397
420
  name: 'WordPress Sites',
398
421
  description: `Available WordPress sites: ${this.siteKeys.join(', ')}`,
399
422
  mimeType: 'application/json',
400
423
  });
401
424
  }
402
-
403
- this.sendToClient(JSON.stringify({
404
- jsonrpc: '2.0', id: msg.id,
405
- result: { resources },
406
- }));
407
425
  }
408
426
 
409
427
  _handleResourcesReadSites(msg) {