@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.
@@ -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
- // For HTTP transport: build subsite endpoint from subsite URL + parent endpoint path.
360
- // This makes WordPress boot natively into the correct blog context
361
- // community.wickedevolutions.com/wp-json/mcp/... boots into blog 2,
362
- // not wickedevolutions.com/wp-json/mcp/... which boots into blog 1.
363
- if (site.transport === 'http' && site.http && site.http.endpoint) {
364
- const parentUrl = new URL(site.http.endpoint);
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
  }
@@ -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.5.4",
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": {