@wickedevolutions/abilities-mcp 1.5.3 → 1.6.0
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 +79 -0
- package/README.md +8 -0
- package/lib/auth/keychain-secret-store.js +174 -31
- package/lib/auth/oauth-client.js +11 -1
- package/lib/cli/commands/add-site.js +65 -2
- package/lib/cli/commands/reauth.js +24 -2
- package/lib/cli/commands/upgrade-auth.js +15 -9
- package/lib/cli/index.js +16 -1
- package/lib/cli/multisite-probe.js +498 -0
- 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
|
@@ -119,24 +119,30 @@ async function run(args, ctx) {
|
|
|
119
119
|
username: site.auth.username,
|
|
120
120
|
password_ref: site.auth.password_ref,
|
|
121
121
|
};
|
|
122
|
-
//
|
|
123
|
-
// <siteId>/apppassword-legacy per the F.5 example.
|
|
124
|
-
// the
|
|
125
|
-
//
|
|
122
|
+
// Copy the keychain entry from <siteId>/apppassword to
|
|
123
|
+
// <siteId>/apppassword-legacy per the F.5 example. If macOS refuses or
|
|
124
|
+
// delays the secret read, keep the original password_ref as the fallback
|
|
125
|
+
// rather than writing config that points at a secret we did not create.
|
|
126
126
|
const legacyAccount = `${siteId}/apppassword-legacy`;
|
|
127
|
+
let legacyCopied = false;
|
|
127
128
|
try {
|
|
128
129
|
const oldAccount = parseRef(site.auth.password_ref).account;
|
|
129
130
|
const value = await ctx.secretStore.get(SECRET_SERVICE, oldAccount);
|
|
130
131
|
if (typeof value === 'string') {
|
|
131
132
|
await ctx.secretStore.set(SECRET_SERVICE, legacyAccount, value);
|
|
133
|
+
legacyCopied = true;
|
|
132
134
|
}
|
|
133
135
|
} catch {
|
|
134
|
-
// Non-fatal —
|
|
136
|
+
// Non-fatal — preserve the original fallback reference below.
|
|
137
|
+
}
|
|
138
|
+
if (legacyCopied) {
|
|
139
|
+
pendingFallback = {
|
|
140
|
+
username: site.auth.username,
|
|
141
|
+
password_ref: makeRef(SECRET_SERVICE, legacyAccount),
|
|
142
|
+
};
|
|
143
|
+
} else {
|
|
144
|
+
out.push(' ! Could not copy App Password fallback to a legacy keychain entry; keeping existing fallback reference.');
|
|
135
145
|
}
|
|
136
|
-
pendingFallback = {
|
|
137
|
-
username: site.auth.username,
|
|
138
|
-
password_ref: makeRef(SECRET_SERVICE, legacyAccount),
|
|
139
|
-
};
|
|
140
146
|
}
|
|
141
147
|
|
|
142
148
|
const clientName = `${ctx.userLabel}'s Operator (${ctx.hostnameLabel})`;
|
package/lib/cli/index.js
CHANGED
|
@@ -100,7 +100,12 @@ async function runCommand(opts) {
|
|
|
100
100
|
const lines = preLines.length
|
|
101
101
|
? preLines.concat(r.lines || [])
|
|
102
102
|
: (r.lines || []);
|
|
103
|
-
|
|
103
|
+
// Commands may return non-fatal advisories alongside a success exit
|
|
104
|
+
// code (e.g. add-site emits one when the multisite-discovery probe
|
|
105
|
+
// degrades gracefully). Surface them on stderr without changing
|
|
106
|
+
// exit semantics.
|
|
107
|
+
const errLines = Array.isArray(r.errLines) ? r.errLines : [];
|
|
108
|
+
return { exitCode: r.exitCode || EXIT_OK, lines, errLines };
|
|
104
109
|
} catch (err) {
|
|
105
110
|
const cliErr = err instanceof CliError ? err : fromAuthError(err);
|
|
106
111
|
const errLines = renderNextAction(cliErr);
|
|
@@ -135,6 +140,11 @@ const HELP_TEXT = [
|
|
|
135
140
|
' --label=<text> Human-readable label',
|
|
136
141
|
' --force Overwrite an existing site_id',
|
|
137
142
|
' reauth <site_id> Re-run OAuth flow for an existing site',
|
|
143
|
+
' --add-scope="<scopes>" Merge scopes into the existing set (recommended)',
|
|
144
|
+
' --remove-scope="<scopes>" Drop scopes by exact match (missing = no-op warning)',
|
|
145
|
+
' --scope="<scopes>" Replace the entire scope set (warns if dropping any)',
|
|
146
|
+
' (the three flags above are mutually exclusive;',
|
|
147
|
+
' accept comma- or space-separated scope lists)',
|
|
138
148
|
' revoke <site_id> Revoke OAuth tokens (local + remote)',
|
|
139
149
|
' list-sites Show configured sites + auth status',
|
|
140
150
|
' test <site_id> Ping the adapter and report scopes',
|
|
@@ -151,6 +161,11 @@ const HELP_TEXT = [
|
|
|
151
161
|
' --debug Include cause stack on errors',
|
|
152
162
|
' --allow-insecure Allow plain HTTP (localhost dev only)',
|
|
153
163
|
'',
|
|
164
|
+
'Environment:',
|
|
165
|
+
' ABILITIES_MCP_KEYCHAIN_BACKEND=security-cli',
|
|
166
|
+
' macOS-only opt-in: use the same /usr/bin/security',
|
|
167
|
+
' keychain backend Claude Desktop .mcpb uses',
|
|
168
|
+
'',
|
|
154
169
|
'Exit codes:',
|
|
155
170
|
' 0 success',
|
|
156
171
|
' 1 unexpected error',
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const https = require('node:https');
|
|
4
|
+
const http = require('node:http');
|
|
5
|
+
const { URL } = require('node:url');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Multisite Network root probe for `abilities-mcp add-site`.
|
|
9
|
+
*
|
|
10
|
+
* After OAuth completes, call `multisite/list-sites` against the freshly
|
|
11
|
+
* authenticated bridge connection. If the URL points to a Multisite Network
|
|
12
|
+
* root, build the `multisite` block (slug → subsite-URL map) the bridge's
|
|
13
|
+
* dot-notation routing already expects (see `lib/config.js:resolveSiteKey`,
|
|
14
|
+
* `lib/connection-pool.js:_findExistingHttpTransport`). If the URL is a
|
|
15
|
+
* single-site install, the OAuth user lacks `manage_network_options`, or the
|
|
16
|
+
* call fails for any other reason, the probe returns null / a structured
|
|
17
|
+
* error so `add-site` can degrade gracefully without writing the block.
|
|
18
|
+
*
|
|
19
|
+
* Schema (verified against routing read paths):
|
|
20
|
+
* site.multisite = { [slug]: subsiteUrlString }
|
|
21
|
+
*
|
|
22
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
23
|
+
* @license GPL-2.0-or-later
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const PROBE_PROTOCOL_VERSION = '2025-06-18';
|
|
27
|
+
const PROBE_PER_PAGE = 100;
|
|
28
|
+
const PROBE_TIMEOUT_MS = 30000;
|
|
29
|
+
// Page cap = 50 pages × 100/page = 5,000 sites. Networks larger than this
|
|
30
|
+
// are an exceptional case that should engage maintainers rather than silently
|
|
31
|
+
// override; not exposed as an env shim (Issue #49).
|
|
32
|
+
const PROBE_PAGE_CAP = 50;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {object} ProbeResult
|
|
36
|
+
* @property {object|null} block Multisite block, or null when no block
|
|
37
|
+
* should be written (single-site / empty).
|
|
38
|
+
* @property {string} reason One of: 'multisite-root', 'single-site',
|
|
39
|
+
* 'tool-not-registered', 'empty-list'.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {object} opts
|
|
44
|
+
* @param {string} opts.endpoint MCP resource URL (from prMetadata.resource).
|
|
45
|
+
* @param {string} opts.accessToken Freshly minted OAuth access token.
|
|
46
|
+
* @param {string} opts.siteUrl Parent network-root URL (parsedUrl.origin).
|
|
47
|
+
* @param {function} [opts.log] Logger.
|
|
48
|
+
* @param {object} [opts.deps]
|
|
49
|
+
* @param {function} [opts.deps.request] Inject for tests; replaces the inline
|
|
50
|
+
* bearer JSON-RPC client. Receives the
|
|
51
|
+
* full message and resolves the parsed
|
|
52
|
+
* JSON-RPC response (or rejects with a
|
|
53
|
+
* structured error).
|
|
54
|
+
* @returns {Promise<ProbeResult>}
|
|
55
|
+
*/
|
|
56
|
+
async function probeMultisite(opts) {
|
|
57
|
+
const { endpoint, accessToken, siteUrl, log } = opts;
|
|
58
|
+
const logger = typeof log === 'function' ? log : function noop() {};
|
|
59
|
+
|
|
60
|
+
if (!endpoint) {
|
|
61
|
+
const e = new Error('multisite probe: no MCP endpoint available');
|
|
62
|
+
e.code = 'no_endpoint';
|
|
63
|
+
throw e;
|
|
64
|
+
}
|
|
65
|
+
if (!accessToken) {
|
|
66
|
+
const e = new Error('multisite probe: no access token available');
|
|
67
|
+
e.code = 'no_access_token';
|
|
68
|
+
throw e;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const client = (opts.deps && opts.deps.request)
|
|
72
|
+
? new InjectedClient(opts.deps.request)
|
|
73
|
+
: new BearerJsonRpcClient(endpoint, accessToken, logger);
|
|
74
|
+
|
|
75
|
+
await client.initialize();
|
|
76
|
+
|
|
77
|
+
// Page through multisite/list-sites until the network is fully covered
|
|
78
|
+
// (Issue #49). Networks with >100 sites previously got a truncated block
|
|
79
|
+
// because the probe issued a single per_page=100 call.
|
|
80
|
+
//
|
|
81
|
+
// Termination order matters: when a page returns fewer than per_page items
|
|
82
|
+
// (or exposes a body-level total/total_pages we've reached), we still
|
|
83
|
+
// accumulate that page's items first, then exit the loop. Dropping the
|
|
84
|
+
// partial page's items would silently lose subsites.
|
|
85
|
+
let items = null;
|
|
86
|
+
let totalKnown = null;
|
|
87
|
+
let totalPagesKnown = null;
|
|
88
|
+
for (let page = 1; page <= PROBE_PAGE_CAP; page++) {
|
|
89
|
+
// Adapter exposes the ability as kebab-case `multisite-list-sites`
|
|
90
|
+
// (verified via tools/list against wickedevolutions). The slash form
|
|
91
|
+
// shipped in v1.5.4 (Issue #54 same-files extension) — masked by the
|
|
92
|
+
// session-token rejection until the bridge fix above surfaced it.
|
|
93
|
+
const toolResp = await client.callTool('multisite-list-sites', {
|
|
94
|
+
per_page: PROBE_PER_PAGE,
|
|
95
|
+
page,
|
|
96
|
+
});
|
|
97
|
+
const payload = parseToolResponse(toolResp);
|
|
98
|
+
const pageItems = extractSites(payload);
|
|
99
|
+
if (pageItems === null) {
|
|
100
|
+
// Malformed payload on page 1 → preserve the existing 'empty-list'
|
|
101
|
+
// contract that downstream callers (add-site) already handle.
|
|
102
|
+
// On a later page, treat as "no more items" and stop.
|
|
103
|
+
if (page === 1) { items = null; break; }
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
if (items === null) items = [];
|
|
107
|
+
items.push(...pageItems);
|
|
108
|
+
|
|
109
|
+
// Body-level total / total_pages take precedence — they're authoritative
|
|
110
|
+
// when present. Without them we use the partial-page fallback.
|
|
111
|
+
const meta = extractMeta(payload);
|
|
112
|
+
if (meta.total != null) totalKnown = meta.total;
|
|
113
|
+
if (meta.totalPages != null) totalPagesKnown = meta.totalPages;
|
|
114
|
+
|
|
115
|
+
const fullPage = pageItems.length === PROBE_PER_PAGE;
|
|
116
|
+
const reachedKnownTotal = totalKnown != null && items.length >= totalKnown;
|
|
117
|
+
const reachedKnownTotalPages = totalPagesKnown != null && page >= totalPagesKnown;
|
|
118
|
+
if (!fullPage || reachedKnownTotal || reachedKnownTotalPages) break;
|
|
119
|
+
|
|
120
|
+
// Full page AND no metadata signal we're done AND we're at the cap.
|
|
121
|
+
// This is the "5,000+ site network" case — fail loud rather than write
|
|
122
|
+
// a silently truncated block.
|
|
123
|
+
if (page === PROBE_PAGE_CAP) {
|
|
124
|
+
const e = new Error(
|
|
125
|
+
`multisite probe: page cap exceeded (${PROBE_PAGE_CAP} pages × ${PROBE_PER_PAGE}/page = ${PROBE_PAGE_CAP * PROBE_PER_PAGE} sites). ` +
|
|
126
|
+
`This network exceeds the supported probe size; contact maintainers.`
|
|
127
|
+
);
|
|
128
|
+
e.code = 'probe_cap_exceeded';
|
|
129
|
+
e.data = { count: items.length, cap: PROBE_PAGE_CAP * PROBE_PER_PAGE };
|
|
130
|
+
throw e;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (items === null) {
|
|
135
|
+
return { block: null, reason: 'empty-list' };
|
|
136
|
+
}
|
|
137
|
+
if (items.length === 0) {
|
|
138
|
+
return { block: null, reason: 'empty-list' };
|
|
139
|
+
}
|
|
140
|
+
if (items.length === 1) {
|
|
141
|
+
// Only the network root came back — treat as single-site for routing
|
|
142
|
+
// purposes. Operators can still call `multisite/*` abilities at the
|
|
143
|
+
// network level; dot-notation routing isn't useful with no subsites.
|
|
144
|
+
return { block: null, reason: 'single-site' };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const block = buildMultisiteBlock(siteUrl, items);
|
|
148
|
+
if (!block || Object.keys(block).length === 0) {
|
|
149
|
+
return { block: null, reason: 'empty-list' };
|
|
150
|
+
}
|
|
151
|
+
return { block, reason: 'multisite-root' };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Build the multisite block (slug → subsite URL) from a `multisite/list-sites`
|
|
156
|
+
* response. Pure function — no I/O, no logging. Exported so `add-site.js`
|
|
157
|
+
* tests can verify the schema-mapping logic in isolation.
|
|
158
|
+
*/
|
|
159
|
+
function buildMultisiteBlock(parentSiteUrl, items) {
|
|
160
|
+
let parentHost;
|
|
161
|
+
try { parentHost = new URL(parentSiteUrl).hostname.toLowerCase().replace(/^www\./, ''); }
|
|
162
|
+
catch { return null; }
|
|
163
|
+
|
|
164
|
+
const block = {};
|
|
165
|
+
const used = new Set();
|
|
166
|
+
for (const item of items) {
|
|
167
|
+
if (!item || typeof item.url !== 'string' || item.url.length === 0) continue;
|
|
168
|
+
const baseSlug = deriveSubsiteSlug(parentHost, item);
|
|
169
|
+
if (!baseSlug) continue;
|
|
170
|
+
const slug = uniqueSlug(baseSlug, used, item);
|
|
171
|
+
used.add(slug);
|
|
172
|
+
block[slug] = item.url;
|
|
173
|
+
}
|
|
174
|
+
return block;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Map a `multisite/list-sites` item to a slug usable for dot-notation
|
|
179
|
+
* routing. Subdomain mode → first label of the subdomain. Path mode →
|
|
180
|
+
* first segment of the path. Mapped-domain subsites → first label of
|
|
181
|
+
* the domain.
|
|
182
|
+
*
|
|
183
|
+
* Issue #70: returns null when `itemDomain === parentHost && itemPath
|
|
184
|
+
* is empty`. That case used to synthesize a `'main'` slug intended to
|
|
185
|
+
* point at the network root, but in subdomain-style multisite the only
|
|
186
|
+
* blog matching parent host is the parent subsite itself — so the
|
|
187
|
+
* resulting `<site>.main` URL was the source subsite, not the root,
|
|
188
|
+
* silently routing dot-notation calls to the wrong context. Skipping
|
|
189
|
+
* the slug means: in subdomain-style the network root is reachable via
|
|
190
|
+
* its domain-label slug from the fall-through (`<site>.<root-label>`);
|
|
191
|
+
* in path-style the network root is the parent itself and reachable as
|
|
192
|
+
* `<site>` (no dot). A first-class `'main' → blog_id 1` alias is
|
|
193
|
+
* post-alpha work.
|
|
194
|
+
*/
|
|
195
|
+
function deriveSubsiteSlug(parentHost, item) {
|
|
196
|
+
const itemDomain = String(item.domain || '').toLowerCase().replace(/^www\./, '');
|
|
197
|
+
const itemPath = String(item.path || '/').replace(/^\/+|\/+$/g, '');
|
|
198
|
+
|
|
199
|
+
if (itemDomain === parentHost) {
|
|
200
|
+
if (itemPath === '') return null;
|
|
201
|
+
return itemPath.split('/')[0];
|
|
202
|
+
}
|
|
203
|
+
if (itemDomain.endsWith('.' + parentHost)) {
|
|
204
|
+
const prefix = itemDomain.slice(0, itemDomain.length - parentHost.length - 1);
|
|
205
|
+
const first = prefix.split('.')[0];
|
|
206
|
+
return first || null;
|
|
207
|
+
}
|
|
208
|
+
// Mapped / different domain — fall back to first label
|
|
209
|
+
const first = itemDomain.split('.')[0];
|
|
210
|
+
return first || null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function uniqueSlug(base, used, item) {
|
|
214
|
+
if (!used.has(base)) return base;
|
|
215
|
+
// Disambiguate with blog_id when slugs collide (e.g. two subsites whose
|
|
216
|
+
// first path segments match). Never silently overwrite a previously
|
|
217
|
+
// mapped subsite.
|
|
218
|
+
const blogId = item && item.blog_id;
|
|
219
|
+
if (blogId !== undefined && blogId !== null) {
|
|
220
|
+
const candidate = `${base}-${blogId}`;
|
|
221
|
+
if (!used.has(candidate)) return candidate;
|
|
222
|
+
}
|
|
223
|
+
let n = 2;
|
|
224
|
+
while (used.has(`${base}-${n}`)) n += 1;
|
|
225
|
+
return `${base}-${n}`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function parseToolResponse(resp) {
|
|
229
|
+
if (resp && resp.error) {
|
|
230
|
+
const e = new Error(resp.error.message || 'JSON-RPC error');
|
|
231
|
+
e.code = mapJsonRpcErrorCode(resp.error.code, resp.error.message);
|
|
232
|
+
e.jsonrpcCode = resp.error.code;
|
|
233
|
+
throw e;
|
|
234
|
+
}
|
|
235
|
+
const result = resp && resp.result;
|
|
236
|
+
if (!result) {
|
|
237
|
+
const e = new Error('multisite/list-sites: empty result');
|
|
238
|
+
e.code = 'empty_result';
|
|
239
|
+
throw e;
|
|
240
|
+
}
|
|
241
|
+
const content = Array.isArray(result.content) ? result.content : [];
|
|
242
|
+
const first = content[0];
|
|
243
|
+
let payload = null;
|
|
244
|
+
if (first && first.type === 'text' && typeof first.text === 'string') {
|
|
245
|
+
try { payload = JSON.parse(first.text); }
|
|
246
|
+
catch { payload = { _raw: first.text }; }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (result.isError) {
|
|
250
|
+
const errMsg = (payload && (payload.error || payload._raw))
|
|
251
|
+
|| (first && first.text)
|
|
252
|
+
|| 'tool error';
|
|
253
|
+
const errCode = (payload && payload.error_code) || 'tool_error';
|
|
254
|
+
const e = new Error(errMsg);
|
|
255
|
+
e.code = mapAbilityErrorCode(errCode, errMsg);
|
|
256
|
+
e.abilityCode = errCode;
|
|
257
|
+
e.data = payload && payload.error_data;
|
|
258
|
+
throw e;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return payload || result;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function extractSites(payload) {
|
|
265
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
266
|
+
if (Array.isArray(payload.sites)) return payload.sites;
|
|
267
|
+
if (payload.data && Array.isArray(payload.data.sites)) return payload.data.sites;
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Extract pagination metadata from a multisite/list-sites payload, if the
|
|
273
|
+
* adapter exposes it on the body. The adapter may surface totals on HTTP
|
|
274
|
+
* headers instead — body-only is the supported channel for the probe loop;
|
|
275
|
+
* absence is fine because the partial-page fallback handles termination.
|
|
276
|
+
*/
|
|
277
|
+
function extractMeta(payload) {
|
|
278
|
+
if (!payload || typeof payload !== 'object') return { total: null, totalPages: null };
|
|
279
|
+
const root = payload.data && typeof payload.data === 'object' ? payload.data : payload;
|
|
280
|
+
const total = numericOrNull(root.total);
|
|
281
|
+
const totalPages = numericOrNull(root.total_pages);
|
|
282
|
+
return { total, totalPages };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function numericOrNull(v) {
|
|
286
|
+
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
|
287
|
+
if (typeof v === 'string' && /^-?\d+$/.test(v)) return parseInt(v, 10);
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function mapJsonRpcErrorCode(code, message) {
|
|
292
|
+
// -32601 = Method not found → tool not registered (single-site install)
|
|
293
|
+
if (code === -32601) return 'tool_not_registered';
|
|
294
|
+
if (typeof message === 'string' && /unknown\s+tool/i.test(message)) return 'tool_not_registered';
|
|
295
|
+
return 'jsonrpc_error';
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function mapAbilityErrorCode(abilityCode, message) {
|
|
299
|
+
const code = String(abilityCode || '').toLowerCase();
|
|
300
|
+
if (code === 'rest_forbidden_context'
|
|
301
|
+
|| code === 'rest_forbidden'
|
|
302
|
+
|| code === 'permission_denied'
|
|
303
|
+
|| code === 'forbidden_context'
|
|
304
|
+
|| code.indexOf('forbidden') !== -1) {
|
|
305
|
+
return 'permission_denied';
|
|
306
|
+
}
|
|
307
|
+
if (code === 'rest_no_route' || code === 'not_multisite') {
|
|
308
|
+
return 'tool_not_registered';
|
|
309
|
+
}
|
|
310
|
+
if (typeof message === 'string' && /manage_network_options|insufficient.*capabilit|forbidden/i.test(message)) {
|
|
311
|
+
return 'permission_denied';
|
|
312
|
+
}
|
|
313
|
+
return 'tool_error';
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// Bearer JSON-RPC client — minimal MCP handshake + tools/call over HTTP.
|
|
318
|
+
// Distinct from OAuthHttpTransport: this runs once during add-site with a
|
|
319
|
+
// fresh in-memory access token, so it skips TokenManager + queue/batch.
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
class BearerJsonRpcClient {
|
|
323
|
+
constructor(endpoint, accessToken, log) {
|
|
324
|
+
this.url = new URL(endpoint);
|
|
325
|
+
this.accessToken = accessToken;
|
|
326
|
+
this.log = log;
|
|
327
|
+
this.module = this.url.protocol === 'https:' ? https : http;
|
|
328
|
+
this.sessionId = null;
|
|
329
|
+
this.sessionToken = null; // Mcp-Session-Token (HMAC, echoed back on every request)
|
|
330
|
+
this.cookies = new Map();
|
|
331
|
+
this._idCounter = 1;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async initialize() {
|
|
335
|
+
const initResp = await this._post({
|
|
336
|
+
jsonrpc: '2.0',
|
|
337
|
+
id: this._idCounter++,
|
|
338
|
+
method: 'initialize',
|
|
339
|
+
params: {
|
|
340
|
+
protocolVersion: PROBE_PROTOCOL_VERSION,
|
|
341
|
+
capabilities: {},
|
|
342
|
+
clientInfo: { name: 'abilities-mcp-add-site', version: '1.5.4' },
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
if (initResp && initResp.error) {
|
|
346
|
+
const e = new Error(initResp.error.message || 'initialize failed');
|
|
347
|
+
e.code = 'initialize_failed';
|
|
348
|
+
e.jsonrpcCode = initResp.error.code;
|
|
349
|
+
throw e;
|
|
350
|
+
}
|
|
351
|
+
await this._post({
|
|
352
|
+
jsonrpc: '2.0',
|
|
353
|
+
method: 'notifications/initialized',
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
callTool(name, args) {
|
|
358
|
+
return this._post({
|
|
359
|
+
jsonrpc: '2.0',
|
|
360
|
+
id: this._idCounter++,
|
|
361
|
+
method: 'tools/call',
|
|
362
|
+
params: { name, arguments: args || {} },
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
_post(message) {
|
|
367
|
+
return new Promise((resolve, reject) => {
|
|
368
|
+
const body = JSON.stringify(message);
|
|
369
|
+
const headers = {
|
|
370
|
+
'Content-Type': 'application/json',
|
|
371
|
+
'Accept': 'application/json',
|
|
372
|
+
'Authorization': `Bearer ${this.accessToken}`,
|
|
373
|
+
'Content-Length': Buffer.byteLength(body),
|
|
374
|
+
};
|
|
375
|
+
if (this.sessionId) headers['Mcp-Session-Id'] = this.sessionId;
|
|
376
|
+
// Echo the per-session HMAC token captured from initialize. The
|
|
377
|
+
// adapter's HttpSessionValidator rejects any non-initialize request
|
|
378
|
+
// missing this header as session-fixation defense — see Issue #54
|
|
379
|
+
// and the equivalent handling in lib/transports/http-transport.js.
|
|
380
|
+
if (this.sessionToken) headers['Mcp-Session-Token'] = this.sessionToken;
|
|
381
|
+
if (this.cookies.size > 0) {
|
|
382
|
+
headers['Cookie'] = Array.from(this.cookies.entries())
|
|
383
|
+
.map(([k, v]) => `${k}=${v}`).join('; ');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const req = this.module.request({
|
|
387
|
+
hostname: this.url.hostname,
|
|
388
|
+
port: this.url.port || (this.url.protocol === 'https:' ? 443 : 80),
|
|
389
|
+
path: this.url.pathname + this.url.search,
|
|
390
|
+
method: 'POST',
|
|
391
|
+
headers,
|
|
392
|
+
}, (res) => {
|
|
393
|
+
const chunks = [];
|
|
394
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
395
|
+
res.on('end', () => {
|
|
396
|
+
const newSession = res.headers['mcp-session-id'];
|
|
397
|
+
if (newSession) this.sessionId = newSession;
|
|
398
|
+
const newSessionToken = res.headers['mcp-session-token'];
|
|
399
|
+
if (newSessionToken) this.sessionToken = newSessionToken;
|
|
400
|
+
const setCookie = res.headers['set-cookie'];
|
|
401
|
+
if (setCookie) {
|
|
402
|
+
const list = Array.isArray(setCookie) ? setCookie : [setCookie];
|
|
403
|
+
for (const raw of list) {
|
|
404
|
+
const nv = raw.split(';')[0].trim();
|
|
405
|
+
const eq = nv.indexOf('=');
|
|
406
|
+
if (eq > 0) this.cookies.set(nv.slice(0, eq), nv.slice(eq + 1));
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (res.statusCode === 401) {
|
|
410
|
+
const e = new Error('multisite probe: HTTP 401 (token rejected)');
|
|
411
|
+
e.code = 'unauthorized';
|
|
412
|
+
return reject(e);
|
|
413
|
+
}
|
|
414
|
+
if (res.statusCode === 403) {
|
|
415
|
+
const e = new Error('multisite probe: HTTP 403 (forbidden)');
|
|
416
|
+
e.code = 'permission_denied';
|
|
417
|
+
return reject(e);
|
|
418
|
+
}
|
|
419
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
420
|
+
if (!text.trim()) return resolve(null);
|
|
421
|
+
let parsed;
|
|
422
|
+
try { parsed = JSON.parse(text); }
|
|
423
|
+
catch (err) {
|
|
424
|
+
const e = new Error(`multisite probe: response parse error: ${err.message}`);
|
|
425
|
+
e.code = 'parse_error';
|
|
426
|
+
return reject(e);
|
|
427
|
+
}
|
|
428
|
+
// Tolerate single-element JSON-RPC batch responses (some servers
|
|
429
|
+
// wrap a single response in an array).
|
|
430
|
+
if (Array.isArray(parsed) && parsed.length === 1) parsed = parsed[0];
|
|
431
|
+
resolve(parsed);
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
req.setTimeout(PROBE_TIMEOUT_MS, () => {
|
|
436
|
+
req.destroy(new Error('multisite probe: request timeout'));
|
|
437
|
+
});
|
|
438
|
+
req.on('error', (err) => {
|
|
439
|
+
const e = new Error(`multisite probe: ${err.message}`);
|
|
440
|
+
e.code = 'network_error';
|
|
441
|
+
e.cause = err;
|
|
442
|
+
reject(e);
|
|
443
|
+
});
|
|
444
|
+
req.write(body);
|
|
445
|
+
req.end();
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Test injection seam — wraps a function `(message) => Promise<jsonrpcResp>`
|
|
452
|
+
* and presents the same surface (`initialize`, `callTool`) the inline client
|
|
453
|
+
* does so the probe code path is identical.
|
|
454
|
+
*/
|
|
455
|
+
class InjectedClient {
|
|
456
|
+
constructor(requestFn) {
|
|
457
|
+
this._request = requestFn;
|
|
458
|
+
this._idCounter = 1;
|
|
459
|
+
}
|
|
460
|
+
async initialize() {
|
|
461
|
+
const resp = await this._request({
|
|
462
|
+
jsonrpc: '2.0',
|
|
463
|
+
id: this._idCounter++,
|
|
464
|
+
method: 'initialize',
|
|
465
|
+
params: { protocolVersion: PROBE_PROTOCOL_VERSION, capabilities: {} },
|
|
466
|
+
});
|
|
467
|
+
if (resp && resp.error) {
|
|
468
|
+
const e = new Error(resp.error.message || 'initialize failed');
|
|
469
|
+
e.code = 'initialize_failed';
|
|
470
|
+
e.jsonrpcCode = resp.error.code;
|
|
471
|
+
throw e;
|
|
472
|
+
}
|
|
473
|
+
await this._request({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
474
|
+
}
|
|
475
|
+
callTool(name, args) {
|
|
476
|
+
return this._request({
|
|
477
|
+
jsonrpc: '2.0',
|
|
478
|
+
id: this._idCounter++,
|
|
479
|
+
method: 'tools/call',
|
|
480
|
+
params: { name, arguments: args || {} },
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
module.exports = {
|
|
486
|
+
probeMultisite,
|
|
487
|
+
buildMultisiteBlock,
|
|
488
|
+
deriveSubsiteSlug,
|
|
489
|
+
parseToolResponse,
|
|
490
|
+
// Exported for direct testing of the per-session HMAC echo contract
|
|
491
|
+
// (Issue #54). The runtime contract (capture Mcp-Session-Token from
|
|
492
|
+
// initialize and echo on every subsequent request) is observable
|
|
493
|
+
// protocol behavior, not implementation detail.
|
|
494
|
+
BearerJsonRpcClient,
|
|
495
|
+
PROBE_PROTOCOL_VERSION,
|
|
496
|
+
PROBE_PER_PAGE,
|
|
497
|
+
PROBE_PAGE_CAP,
|
|
498
|
+
};
|