@wickedevolutions/abilities-mcp 1.5.4 → 1.6.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 +72 -0
- package/README.md +149 -47
- package/lib/auth/keychain-secret-store.js +174 -31
- package/lib/cli/commands/reauth.js +24 -2
- package/lib/cli/commands/upgrade-auth.js +15 -9
- package/lib/cli/index.js +10 -0
- package/lib/cli/multisite-probe.js +112 -6
- package/lib/cli/scope-mutation.js +177 -0
- package/lib/config.js +16 -6
- package/lib/connection-pool.js +20 -4
- package/lib/transports/oauth-http-transport.js +29 -1
- package/package.json +1 -1
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Scope-mutation helpers for `reauth` (Issue #50).
|
|
5
|
+
*
|
|
6
|
+
* The `reauth` CLI used to accept a single `--scope` flag that REPLACED the
|
|
7
|
+
* persisted scope array — a UX trap, since an operator (or AI assistant)
|
|
8
|
+
* adding new scopes via `--scope='<new only>'` would silently drop every
|
|
9
|
+
* existing scope. The locked design adds `--add-scope` and `--remove-scope`
|
|
10
|
+
* for the merge / remove cases and keeps `--scope` as the explicit-replace
|
|
11
|
+
* escape hatch with a stderr warning when the supplied set is a strict
|
|
12
|
+
* subset of the existing scopes (i.e., the replace would drop scopes).
|
|
13
|
+
*
|
|
14
|
+
* All three flags are mutually exclusive — combining any two is a typed
|
|
15
|
+
* error. Mirrors `git remote add` / `git remote remove` conventions: each
|
|
16
|
+
* flag has one job.
|
|
17
|
+
*
|
|
18
|
+
* This module is pure — no I/O, no logging. The reauth command imports it
|
|
19
|
+
* and threads warnings through the run-contract's `errLines`.
|
|
20
|
+
*
|
|
21
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
22
|
+
* @license GPL-2.0-or-later
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse a CLI scope-list value into a deduped array, preserving order of
|
|
27
|
+
* first occurrence. Accepts:
|
|
28
|
+
* - already-parsed string array (passes through with dedupe)
|
|
29
|
+
* - comma- or whitespace-delimited string ('a,b c d' → ['a','b','c','d'])
|
|
30
|
+
* - empty / nullish input → []
|
|
31
|
+
*/
|
|
32
|
+
function parseScopeList(input) {
|
|
33
|
+
if (input == null || input === '') return [];
|
|
34
|
+
const tokens = Array.isArray(input)
|
|
35
|
+
? input
|
|
36
|
+
: String(input).split(/[,\s]+/);
|
|
37
|
+
const out = [];
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
for (const t of tokens) {
|
|
40
|
+
const tok = String(t).trim();
|
|
41
|
+
if (!tok || seen.has(tok)) continue;
|
|
42
|
+
seen.add(tok);
|
|
43
|
+
out.push(tok);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Return existing ∪ additions, deduped, preserving the order
|
|
50
|
+
* "existing first, new additions appended in input order."
|
|
51
|
+
*/
|
|
52
|
+
function mergeScopes(existing, additions) {
|
|
53
|
+
const e = parseScopeList(existing);
|
|
54
|
+
const a = parseScopeList(additions);
|
|
55
|
+
const seen = new Set(e);
|
|
56
|
+
const out = e.slice();
|
|
57
|
+
for (const tok of a) {
|
|
58
|
+
if (!seen.has(tok)) {
|
|
59
|
+
seen.add(tok);
|
|
60
|
+
out.push(tok);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Return { result, missing } where:
|
|
68
|
+
* result = existing minus removals (preserves order of existing)
|
|
69
|
+
* missing = removals that weren't in existing (no-op, warn-not-error)
|
|
70
|
+
*/
|
|
71
|
+
function removeScopes(existing, removals) {
|
|
72
|
+
const e = parseScopeList(existing);
|
|
73
|
+
const r = parseScopeList(removals);
|
|
74
|
+
const removeSet = new Set(r);
|
|
75
|
+
const existingSet = new Set(e);
|
|
76
|
+
const result = e.filter((tok) => !removeSet.has(tok));
|
|
77
|
+
const missing = r.filter((tok) => !existingSet.has(tok));
|
|
78
|
+
return { result, missing };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* True iff replacement ⊊ existing (every replacement element is in existing
|
|
83
|
+
* AND existing has at least one element not in replacement). Used to detect
|
|
84
|
+
* the --scope replace footgun: supplied set is missing scopes the operator
|
|
85
|
+
* almost certainly didn't mean to drop.
|
|
86
|
+
*
|
|
87
|
+
* Equal sets return false (no warning needed — replace is a no-op).
|
|
88
|
+
* Replacement that adds new scopes (not subset) returns false — operator
|
|
89
|
+
* is intentionally swapping the set.
|
|
90
|
+
*/
|
|
91
|
+
function isStrictSubset(replacement, existing) {
|
|
92
|
+
const r = parseScopeList(replacement);
|
|
93
|
+
const e = parseScopeList(existing);
|
|
94
|
+
if (r.length >= e.length) return false; // strict subset must be smaller
|
|
95
|
+
const existingSet = new Set(e);
|
|
96
|
+
for (const tok of r) if (!existingSet.has(tok)) return false;
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolve the final scope list for an OAuthClient invocation given the
|
|
102
|
+
* three mutually-exclusive CLI flags + the persisted scope array.
|
|
103
|
+
*
|
|
104
|
+
* @param {object} args
|
|
105
|
+
* @param {string|string[]|undefined} args.scope --scope: replace existing
|
|
106
|
+
* @param {string|string[]|undefined} args.addScope --add-scope: merge into existing
|
|
107
|
+
* @param {string|string[]|undefined} args.removeScope --remove-scope: remove from existing
|
|
108
|
+
* @param {string[]|undefined} args.existing Persisted scope array (or DEFAULT_SCOPE fallback)
|
|
109
|
+
*
|
|
110
|
+
* @returns {{ scopes: string[], warnings: string[], errorCode?: string, errorMessage?: string }}
|
|
111
|
+
* scopes — the array to pass into OAuthClient
|
|
112
|
+
* warnings — stderr advisories (subset replace, no-op removals, etc.)
|
|
113
|
+
* errorCode — set when mutual-exclusion violated; caller should throw CliError
|
|
114
|
+
* errorMessage — human-readable message for the typed error
|
|
115
|
+
*/
|
|
116
|
+
function computeScopeMutation(args) {
|
|
117
|
+
const { scope, addScope, removeScope, existing } = args;
|
|
118
|
+
const set = (v) => v != null && v !== '' && !(Array.isArray(v) && v.length === 0);
|
|
119
|
+
const flags = [
|
|
120
|
+
set(scope) ? '--scope' : null,
|
|
121
|
+
set(addScope) ? '--add-scope' : null,
|
|
122
|
+
set(removeScope) ? '--remove-scope' : null,
|
|
123
|
+
].filter(Boolean);
|
|
124
|
+
|
|
125
|
+
if (flags.length > 1) {
|
|
126
|
+
return {
|
|
127
|
+
scopes: parseScopeList(existing),
|
|
128
|
+
warnings: [],
|
|
129
|
+
errorCode: 'reauth_scope_flag_conflict',
|
|
130
|
+
errorMessage:
|
|
131
|
+
`reauth: ${flags.join(', ')} are mutually exclusive — each flag does one job. ` +
|
|
132
|
+
`Use --add-scope to merge new scopes into the existing set, --remove-scope to ` +
|
|
133
|
+
`drop scopes by exact match, or --scope to replace the entire set.`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const existingArr = parseScopeList(existing);
|
|
138
|
+
|
|
139
|
+
if (set(addScope)) {
|
|
140
|
+
return { scopes: mergeScopes(existingArr, addScope), warnings: [] };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (set(removeScope)) {
|
|
144
|
+
const { result, missing } = removeScopes(existingArr, removeScope);
|
|
145
|
+
const warnings = [];
|
|
146
|
+
for (const m of missing) {
|
|
147
|
+
warnings.push(`reauth: --remove-scope: "${m}" was not in the existing scope set (no-op).`);
|
|
148
|
+
}
|
|
149
|
+
return { scopes: result, warnings };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (set(scope)) {
|
|
153
|
+
const replacement = parseScopeList(scope);
|
|
154
|
+
const warnings = [];
|
|
155
|
+
if (existingArr.length > 0 && isStrictSubset(replacement, existingArr)) {
|
|
156
|
+
const dropped = existingArr.filter((s) => !replacement.includes(s));
|
|
157
|
+
warnings.push(
|
|
158
|
+
`reauth: --scope replaces ${existingArr.length} existing scopes with ${replacement.length} new ones — ` +
|
|
159
|
+
`this drops ${dropped.length} scope${dropped.length === 1 ? '' : 's'} (${dropped.slice(0, 3).join(', ')}${dropped.length > 3 ? ', ...' : ''}). ` +
|
|
160
|
+
`Use --add-scope to merge instead.`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
return { scopes: replacement, warnings };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// No scope flag — fall back to existing (caller defaults to DEFAULT_SCOPE
|
|
167
|
+
// when existing is empty / not present).
|
|
168
|
+
return { scopes: existingArr, warnings: [] };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = {
|
|
172
|
+
parseScopeList,
|
|
173
|
+
mergeScopes,
|
|
174
|
+
removeScopes,
|
|
175
|
+
isStrictSubset,
|
|
176
|
+
computeScopeMutation,
|
|
177
|
+
};
|
package/lib/config.js
CHANGED
|
@@ -356,12 +356,22 @@ function resolveSiteKey(config, compositeKey) {
|
|
|
356
356
|
const subsiteUrl = site.multisite[subsiteKey];
|
|
357
357
|
let resolvedEndpoint = null;
|
|
358
358
|
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
// community.wickedevolutions.com/wp-json/mcp/... boots into
|
|
362
|
-
// not
|
|
363
|
-
|
|
364
|
-
|
|
359
|
+
// Build subsite endpoint from subsite URL + parent endpoint path.
|
|
360
|
+
// WordPress multisite (subdomain-style) routes by URL host, so posting
|
|
361
|
+
// to community.wickedevolutions.com/wp-json/mcp/... boots into the
|
|
362
|
+
// community blog, not the network root. Without this substitution the
|
|
363
|
+
// request lands on blog 1 regardless of which subsite was targeted.
|
|
364
|
+
//
|
|
365
|
+
// Source endpoint differs by auth method:
|
|
366
|
+
// - HTTP / App Password sites carry it on `http.endpoint`.
|
|
367
|
+
// - OAuth sites carry it on `mcp_resource` (set during OAuth
|
|
368
|
+
// discovery; there's no `http.endpoint` for OAuth sites).
|
|
369
|
+
const parentEndpoint =
|
|
370
|
+
(site.http && site.http.endpoint)
|
|
371
|
+
|| (site.auth && site.auth.method === 'oauth' && site.mcp_resource)
|
|
372
|
+
|| null;
|
|
373
|
+
if (parentEndpoint) {
|
|
374
|
+
const parentUrl = new URL(parentEndpoint);
|
|
365
375
|
const subsiteOrigin = new URL(subsiteUrl).origin;
|
|
366
376
|
resolvedEndpoint = subsiteOrigin + parentUrl.pathname;
|
|
367
377
|
}
|
package/lib/connection-pool.js
CHANGED
|
@@ -293,8 +293,16 @@ class ConnectionPool {
|
|
|
293
293
|
// Sites with auth.method === 'oauth' use the OAuth-aware HTTP transport;
|
|
294
294
|
// every other site (App Password, SSH carrier-only) keeps the existing
|
|
295
295
|
// legacy code paths untouched.
|
|
296
|
+
//
|
|
297
|
+
// Pass the resolved subsite endpoint and URL through. For multisite OAuth
|
|
298
|
+
// (Issue #48) the OAuth branch must POST to the subsite host, not the
|
|
299
|
+
// network root — mirroring the HTTP branch's `resolvedEndpoint || ...`
|
|
300
|
+
// pattern below.
|
|
296
301
|
if (siteConfig.auth && siteConfig.auth.method === 'oauth') {
|
|
297
|
-
return this._createOAuthHttpTransport(compositeKey, siteConfig
|
|
302
|
+
return this._createOAuthHttpTransport(compositeKey, siteConfig, {
|
|
303
|
+
resolvedEndpoint,
|
|
304
|
+
subsiteUrl: finalSubsiteUrl,
|
|
305
|
+
});
|
|
298
306
|
}
|
|
299
307
|
|
|
300
308
|
if (siteConfig.transport === 'ssh') {
|
|
@@ -342,7 +350,7 @@ class ConnectionPool {
|
|
|
342
350
|
* we let propagate so the bridge fails loud rather than silently
|
|
343
351
|
* downgrading to App Password).
|
|
344
352
|
*/
|
|
345
|
-
async _createOAuthHttpTransport(compositeKey, siteConfig) {
|
|
353
|
+
async _createOAuthHttpTransport(compositeKey, siteConfig, { resolvedEndpoint, subsiteUrl } = {}) {
|
|
346
354
|
const { OAuthHttpTransport } = require('./transports/oauth-http-transport');
|
|
347
355
|
const { TokenManager } = require('./auth/token-manager');
|
|
348
356
|
const { discover } = require('./auth/discovery-client');
|
|
@@ -393,7 +401,8 @@ class ConnectionPool {
|
|
|
393
401
|
const siteAuth = _siteAuthFromConfig(compositeKey, siteConfig, asMetadata);
|
|
394
402
|
|
|
395
403
|
return new OAuthHttpTransport({
|
|
396
|
-
endpoint: siteConfig.mcp_resource,
|
|
404
|
+
endpoint: resolvedEndpoint || siteConfig.mcp_resource,
|
|
405
|
+
subsiteUrl: subsiteUrl || null,
|
|
397
406
|
tokenManager: this._tokenManager,
|
|
398
407
|
siteAuth,
|
|
399
408
|
onAuthStatusChange: (newStatus, info) => {
|
|
@@ -455,9 +464,16 @@ class ConnectionPool {
|
|
|
455
464
|
_findExistingHttpTransport(compositeKey) {
|
|
456
465
|
const { siteConfig, resolvedEndpoint } = resolveSiteKey(this.config, compositeKey);
|
|
457
466
|
|
|
467
|
+
// The dedupe target must be the *subsite* endpoint when one is resolved,
|
|
468
|
+
// otherwise different subsite keys collapse onto whichever transport
|
|
469
|
+
// happens to be cached first — which was the v1.5.4 multisite-OAuth
|
|
470
|
+
// failure mode (Issue #48): every wickedevolutions.<subsite> reused the
|
|
471
|
+
// network-root transport built for `wickedevolutions`. Single-site keys
|
|
472
|
+
// (resolvedEndpoint=null) still fall back to siteConfig.mcp_resource /
|
|
473
|
+
// http.endpoint, which is what dedupes parent + same-host subsites.
|
|
458
474
|
let targetEndpoint = null;
|
|
459
475
|
if (siteConfig.auth && siteConfig.auth.method === 'oauth') {
|
|
460
|
-
targetEndpoint = siteConfig.mcp_resource;
|
|
476
|
+
targetEndpoint = resolvedEndpoint || siteConfig.mcp_resource;
|
|
461
477
|
} else if (siteConfig.transport === 'http') {
|
|
462
478
|
targetEndpoint = resolvedEndpoint || (siteConfig.http && siteConfig.http.endpoint);
|
|
463
479
|
}
|
|
@@ -44,7 +44,28 @@ const MAX_QUEUE = 100;
|
|
|
44
44
|
* it is NOT a runtime "if OAuth fails, try Basic" branch.
|
|
45
45
|
*
|
|
46
46
|
* @param {object} opts
|
|
47
|
-
* @param {string} opts.endpoint MCP resource URL (https)
|
|
47
|
+
* @param {string} opts.endpoint MCP resource URL (https). For
|
|
48
|
+
* multisite OAuth (Issue #48) the
|
|
49
|
+
* caller derives the subsite-host
|
|
50
|
+
* endpoint via resolveSiteKey and
|
|
51
|
+
* passes it here; the transport
|
|
52
|
+
* posts to whatever it's given.
|
|
53
|
+
* @param {string} [opts.subsiteUrl] Optional. When the resolved
|
|
54
|
+
* site key targets a multisite
|
|
55
|
+
* subsite, the subsite home URL
|
|
56
|
+
* is forwarded on every request
|
|
57
|
+
* via X-Abilities-MCP-Subsite-URL
|
|
58
|
+
* so server-side adapters that
|
|
59
|
+
* honour it (Phase B / future)
|
|
60
|
+
* can route per-request without
|
|
61
|
+
* re-parsing the endpoint URL.
|
|
62
|
+
* Subdomain-style multisite does
|
|
63
|
+
* not need this header — the host
|
|
64
|
+
* in the endpoint URL is
|
|
65
|
+
* sufficient — but the header is
|
|
66
|
+
* unconditional so path-style
|
|
67
|
+
* multisite works once the
|
|
68
|
+
* adapter consumes it.
|
|
48
69
|
* @param {object} opts.tokenManager TokenManager instance
|
|
49
70
|
* @param {object} opts.siteAuth SiteAuthState (see TokenManager)
|
|
50
71
|
* @param {function} opts.onAuthStatusChange Optional. Called with
|
|
@@ -70,6 +91,7 @@ class OAuthHttpTransport {
|
|
|
70
91
|
}
|
|
71
92
|
|
|
72
93
|
this.endpoint = opts.endpoint;
|
|
94
|
+
this.subsiteUrl = opts.subsiteUrl || null;
|
|
73
95
|
this._tokenManager = opts.tokenManager;
|
|
74
96
|
this._siteAuth = opts.siteAuth;
|
|
75
97
|
this._onAuthStatusChange = opts.onAuthStatusChange || null;
|
|
@@ -496,6 +518,12 @@ class OAuthHttpTransport {
|
|
|
496
518
|
|
|
497
519
|
if (this.sessionId) headers['Mcp-Session-Id'] = this.sessionId;
|
|
498
520
|
if (this.sessionToken) headers['Mcp-Session-Token'] = this.sessionToken;
|
|
521
|
+
// Forward subsite context for multisite OAuth (Issue #48). Subdomain-
|
|
522
|
+
// style multisite already routes by host in the endpoint URL; the
|
|
523
|
+
// header is forward-looking infrastructure for path-style multisite
|
|
524
|
+
// (Phase B), so the server can switch_to_blog() without re-parsing
|
|
525
|
+
// the request URL.
|
|
526
|
+
if (this.subsiteUrl) headers['X-Abilities-MCP-Subsite-URL'] = this.subsiteUrl;
|
|
499
527
|
|
|
500
528
|
const hostCookies = this._cookies.get(this.parsedUrl.hostname);
|
|
501
529
|
if (hostCookies && hostCookies.size > 0) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wickedevolutions/abilities-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.1",
|
|
4
4
|
"description": "Open-source MCP bridge connecting AI clients to WordPress through the Abilities API — multi-site routing, zero dependencies",
|
|
5
5
|
"main": "abilities-mcp.js",
|
|
6
6
|
"bin": {
|