@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.
- package/CHANGELOG.md +111 -0
- package/README.md +88 -17
- package/abilities-mcp.js +191 -114
- 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 +265 -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 +328 -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-source-line.js +85 -0
- package/lib/config.js +282 -22
- 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 +8 -2
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) {
|
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
|
-
* -
|
|
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 —
|
|
122
|
+
// resources/list — forward to WordPress, inject bridge resources on response
|
|
119
123
|
if (msg.method === 'resources/list') {
|
|
120
|
-
|
|
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
|
-
|
|
392
|
-
|
|
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) {
|