@wickedevolutions/abilities-mcp 1.3.1 → 1.5.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 +61 -0
- package/README.md +88 -17
- package/abilities-mcp.js +182 -113
- 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 +98 -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 +161 -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.js +248 -19
- 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 +7 -2
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('node:child_process');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Cross-platform default-browser opener.
|
|
7
|
+
*
|
|
8
|
+
* Picks an OS-appropriate "open this URL in the user's default browser"
|
|
9
|
+
* command and dispatches it. Returns a Promise that resolves once the launch
|
|
10
|
+
* command has been spawned (we do not wait for the browser to render).
|
|
11
|
+
*
|
|
12
|
+
* Uses platform-native commands only — no third-party dependency.
|
|
13
|
+
* - macOS: /usr/bin/open
|
|
14
|
+
* - Windows: cmd /c start "" "<url>"
|
|
15
|
+
* - Linux/BSD: xdg-open
|
|
16
|
+
*
|
|
17
|
+
* Callers can override the launcher entirely via `opts.launcher` for tests
|
|
18
|
+
* or unusual environments (e.g. a remote SSH session where there is no
|
|
19
|
+
* display server — operator pastes the URL by hand).
|
|
20
|
+
*
|
|
21
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
22
|
+
* @license GPL-2.0-or-later
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
function _commandFor(platform) {
|
|
26
|
+
if (platform === 'darwin') return { cmd: 'open', args: [] };
|
|
27
|
+
if (platform === 'win32') return { cmd: 'cmd', args: ['/c', 'start', ''] };
|
|
28
|
+
return { cmd: 'xdg-open', args: [] };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Open `url` in the operator's default browser.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} url
|
|
35
|
+
* @param {object} [opts]
|
|
36
|
+
* @param {(url:string)=>Promise<void>|void} [opts.launcher] Test/override seam.
|
|
37
|
+
* @param {string} [opts.platform] Defaults to process.platform.
|
|
38
|
+
* @returns {Promise<{spawned:boolean, platform:string, command?:string}>}
|
|
39
|
+
*/
|
|
40
|
+
async function openBrowser(url, opts = {}) {
|
|
41
|
+
if (typeof url !== 'string' || url.length === 0) {
|
|
42
|
+
throw new TypeError('openBrowser: url must be a non-empty string');
|
|
43
|
+
}
|
|
44
|
+
if (!/^https?:\/\//i.test(url)) {
|
|
45
|
+
throw new Error(`openBrowser: refusing to open non-http(s) URL: ${url}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (opts.launcher) {
|
|
49
|
+
await opts.launcher(url);
|
|
50
|
+
return { spawned: true, platform: 'override' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const platform = opts.platform || process.platform;
|
|
54
|
+
const { cmd, args } = _commandFor(platform);
|
|
55
|
+
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const child = spawn(cmd, [...args, url], { stdio: 'ignore', detached: true });
|
|
58
|
+
child.on('error', (err) => reject(err));
|
|
59
|
+
// Once spawn-error has had a tick to surface, consider the launch dispatched.
|
|
60
|
+
setImmediate(() => {
|
|
61
|
+
child.unref();
|
|
62
|
+
resolve({ spawned: true, platform, command: cmd });
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { openBrowser, _commandFor };
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { execSync } = require('node:child_process');
|
|
6
|
+
|
|
7
|
+
const { SCHEMA_VERSION } = require('./schema-v2');
|
|
8
|
+
const { AUTH_STATUS } = require('./events');
|
|
9
|
+
const { makeRef } = require('./secret-store');
|
|
10
|
+
const { MigrationError } = require('./errors');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One-shot migration of wp-sites.json from the existing v1 (transport-style)
|
|
14
|
+
* schema to v2 (oauth + apppassword per site, secrets in keychain).
|
|
15
|
+
*
|
|
16
|
+
* Per Appendix F.5 (binding):
|
|
17
|
+
* - Triggered on first bridge launch after upgrade.
|
|
18
|
+
* - One-shot, non-destructive: idempotent — calling again on a v2 file
|
|
19
|
+
* returns `{ migrated: false, alreadyV2: true }`.
|
|
20
|
+
* - Atomic write: temp file → rename.
|
|
21
|
+
* - Backup the original to `<file>.v1.bak`.
|
|
22
|
+
*
|
|
23
|
+
* The "v1" the spec describes assumes plaintext `auth.password`. The actual
|
|
24
|
+
* existing bridge schema (lib/config.js) is transport-based with
|
|
25
|
+
* `http.password` / `http.passwordEnv` / `http.passwordCommand` and a
|
|
26
|
+
* separate `ssh` transport. This migration handles the real schema:
|
|
27
|
+
*
|
|
28
|
+
* transport: http + http.password → method: apppassword, secret to keychain
|
|
29
|
+
* transport: http + http.passwordEnv → method: apppassword, env var resolved at migration time
|
|
30
|
+
* transport: http + http.passwordCommand → method: apppassword, command run at migration time
|
|
31
|
+
* transport: ssh → method: apppassword (carrier-only); ssh block preserved
|
|
32
|
+
* so existing SSH transport continues to work; OAuth
|
|
33
|
+
* add-site flow does not apply to SSH sites.
|
|
34
|
+
*
|
|
35
|
+
* SSH-only sites are tagged `auth.method = "apppassword"` with a synthetic
|
|
36
|
+
* placeholder so v2 validation passes; the bridge's runtime still consults
|
|
37
|
+
* the original `transport`, `ssh`, `http` blocks (preserved on each site)
|
|
38
|
+
* for the actual connection. v2 adds OAuth fields but does NOT remove the
|
|
39
|
+
* transport-level fields needed by existing transports.
|
|
40
|
+
*
|
|
41
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
42
|
+
* @license GPL-2.0-or-later
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
const SECRET_SERVICE = 'abilities-mcp';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Determine whether a parsed config is already at v2.
|
|
49
|
+
* @param {object} config
|
|
50
|
+
* @returns {boolean}
|
|
51
|
+
*/
|
|
52
|
+
function isV2(config) {
|
|
53
|
+
return Boolean(config && config.schema_version === SCHEMA_VERSION);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve an http password from the legacy schema. Mirrors the resolver in
|
|
58
|
+
* lib/config.js so migration sees the actual value to lift into keychain.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} httpBlock
|
|
61
|
+
* @param {object} env
|
|
62
|
+
* @returns {string}
|
|
63
|
+
*/
|
|
64
|
+
function _resolveLegacyHttpPassword(httpBlock, env) {
|
|
65
|
+
if (httpBlock.password) return httpBlock.password;
|
|
66
|
+
if (httpBlock.passwordEnv) {
|
|
67
|
+
const v = env[httpBlock.passwordEnv];
|
|
68
|
+
if (typeof v !== 'string' || v.length === 0) {
|
|
69
|
+
throw new MigrationError(
|
|
70
|
+
`Cannot migrate site: passwordEnv "${httpBlock.passwordEnv}" is not set`,
|
|
71
|
+
{ code: 'env_var_missing' }
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return v;
|
|
75
|
+
}
|
|
76
|
+
if (httpBlock.passwordCommand) {
|
|
77
|
+
try {
|
|
78
|
+
return execSync(httpBlock.passwordCommand, { encoding: 'utf8' }).trim();
|
|
79
|
+
} catch (err) {
|
|
80
|
+
throw new MigrationError(
|
|
81
|
+
`Cannot migrate site: passwordCommand failed: ${err.message}`,
|
|
82
|
+
{ code: 'password_command_failed', cause: err }
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
throw new MigrationError(
|
|
87
|
+
`Cannot migrate site: no password, passwordEnv, or passwordCommand`,
|
|
88
|
+
{ code: 'no_password_source' }
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Convert a single legacy site block to v2 shape. Stores any plaintext
|
|
94
|
+
* secret to the SecretStore and replaces it with a keychain reference.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} siteId
|
|
97
|
+
* @param {object} legacy
|
|
98
|
+
* @param {object} args
|
|
99
|
+
* @param {object} args.secretStore
|
|
100
|
+
* @param {object} [args.env]
|
|
101
|
+
* @returns {Promise<{site: object, lifted: Array<{account:string}>}>}
|
|
102
|
+
*/
|
|
103
|
+
async function _convertSite(siteId, legacy, args) {
|
|
104
|
+
const env = args.env || process.env;
|
|
105
|
+
const lifted = [];
|
|
106
|
+
// Carry forward fields the runtime still uses (transport, ssh, http,
|
|
107
|
+
// multisite, label, url, allowInsecure, mcpServer).
|
|
108
|
+
const v2 = {};
|
|
109
|
+
for (const k of Object.keys(legacy)) {
|
|
110
|
+
if (k === 'auth') continue; // legacy didn't have one; preserve any existing
|
|
111
|
+
v2[k] = legacy[k];
|
|
112
|
+
}
|
|
113
|
+
if (typeof v2.url !== 'string') {
|
|
114
|
+
if (legacy.transport === 'ssh' && legacy.ssh && legacy.ssh.host) {
|
|
115
|
+
v2.url = `ssh://${legacy.ssh.host}`;
|
|
116
|
+
} else if (legacy.http && legacy.http.endpoint) {
|
|
117
|
+
const u = new URL(legacy.http.endpoint);
|
|
118
|
+
v2.url = u.origin;
|
|
119
|
+
} else {
|
|
120
|
+
v2.url = '';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (typeof v2.label !== 'string') v2.label = legacy.label || siteId;
|
|
124
|
+
|
|
125
|
+
if (legacy.transport === 'http' && legacy.http) {
|
|
126
|
+
const account = `${siteId}/apppassword`;
|
|
127
|
+
const password = _resolveLegacyHttpPassword(legacy.http, env);
|
|
128
|
+
await args.secretStore.set(SECRET_SERVICE, account, password);
|
|
129
|
+
lifted.push({ account });
|
|
130
|
+
v2.auth = {
|
|
131
|
+
method: 'apppassword',
|
|
132
|
+
username: legacy.http.username,
|
|
133
|
+
password_ref: makeRef(SECRET_SERVICE, account),
|
|
134
|
+
};
|
|
135
|
+
// Sanitize the http block — drop password/passwordEnv/passwordCommand
|
|
136
|
+
// so the keychain becomes the sole source of truth.
|
|
137
|
+
if (v2.http) {
|
|
138
|
+
v2.http = { ...v2.http };
|
|
139
|
+
delete v2.http.password;
|
|
140
|
+
delete v2.http.passwordEnv;
|
|
141
|
+
delete v2.http.passwordCommand;
|
|
142
|
+
v2.http.password_ref = makeRef(SECRET_SERVICE, account);
|
|
143
|
+
}
|
|
144
|
+
} else if (legacy.transport === 'ssh') {
|
|
145
|
+
// SSH sites have no app-password to migrate. We still tag them
|
|
146
|
+
// method: 'apppassword' so v2 validation passes; the synthetic
|
|
147
|
+
// password_ref points at an empty entry callers can ignore.
|
|
148
|
+
const account = `${siteId}/apppassword`;
|
|
149
|
+
await args.secretStore.set(SECRET_SERVICE, account, '');
|
|
150
|
+
lifted.push({ account });
|
|
151
|
+
v2.auth = {
|
|
152
|
+
method: 'apppassword',
|
|
153
|
+
username: (legacy.ssh && legacy.ssh.user) || 'ssh',
|
|
154
|
+
password_ref: makeRef(SECRET_SERVICE, account),
|
|
155
|
+
};
|
|
156
|
+
} else {
|
|
157
|
+
// Already-shaped v2 sites pass through unchanged.
|
|
158
|
+
if (legacy.auth && legacy.auth.method) {
|
|
159
|
+
v2.auth = legacy.auth;
|
|
160
|
+
} else {
|
|
161
|
+
throw new MigrationError(
|
|
162
|
+
`Cannot migrate site "${siteId}": no transport and no v2 auth block`,
|
|
163
|
+
{ code: 'unrecognized_site' }
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
v2.auth_status = legacy.auth_status || AUTH_STATUS.ACTIVE;
|
|
169
|
+
return { site: v2, lifted };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Migrate a parsed config object in memory. Side-effect: writes secrets to
|
|
174
|
+
* the supplied secret store. Caller is responsible for the file I/O.
|
|
175
|
+
*
|
|
176
|
+
* @param {object} legacyConfig
|
|
177
|
+
* @param {object} args
|
|
178
|
+
* @param {object} args.secretStore
|
|
179
|
+
* @param {object} [args.env]
|
|
180
|
+
* @returns {Promise<{config: object, lifted: Array}>}
|
|
181
|
+
*/
|
|
182
|
+
async function migrateConfig(legacyConfig, args) {
|
|
183
|
+
if (!args || !args.secretStore) {
|
|
184
|
+
throw new MigrationError('migrateConfig requires secretStore', { code: 'no_store' });
|
|
185
|
+
}
|
|
186
|
+
if (!legacyConfig || typeof legacyConfig !== 'object') {
|
|
187
|
+
throw new MigrationError('Legacy config is not an object', { code: 'invalid_input' });
|
|
188
|
+
}
|
|
189
|
+
if (!legacyConfig.sites || typeof legacyConfig.sites !== 'object') {
|
|
190
|
+
throw new MigrationError('Legacy config has no sites', { code: 'invalid_input' });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const v2 = {
|
|
194
|
+
$schema: 'https://wickedevolutions.com/schemas/abilities-mcp/wp-sites/v2.json',
|
|
195
|
+
schema_version: SCHEMA_VERSION,
|
|
196
|
+
sites: {},
|
|
197
|
+
};
|
|
198
|
+
if (legacyConfig.defaultSite) v2.defaultSite = legacyConfig.defaultSite;
|
|
199
|
+
|
|
200
|
+
const allLifted = [];
|
|
201
|
+
for (const [siteId, legacy] of Object.entries(legacyConfig.sites)) {
|
|
202
|
+
const { site, lifted } = await _convertSite(siteId, legacy, args);
|
|
203
|
+
v2.sites[siteId] = site;
|
|
204
|
+
allLifted.push(...lifted.map((l) => ({ siteId, ...l })));
|
|
205
|
+
}
|
|
206
|
+
return { config: v2, lifted: allLifted };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Atomic write of `config` to `filePath`. Writes to a temp file in the same
|
|
211
|
+
* directory and renames. Restrictive permissions (0600) on the new file.
|
|
212
|
+
* @param {string} filePath
|
|
213
|
+
* @param {object} config
|
|
214
|
+
*/
|
|
215
|
+
async function _atomicWrite(filePath, config) {
|
|
216
|
+
const dir = path.dirname(filePath);
|
|
217
|
+
const tmp = path.join(dir, `.wp-sites.json.tmp.${process.pid}.${Date.now()}`);
|
|
218
|
+
const payload = JSON.stringify(config, null, 2) + '\n';
|
|
219
|
+
await fs.promises.writeFile(tmp, payload, { mode: 0o600 });
|
|
220
|
+
// On POSIX, fs.rename is atomic if source and destination are on the same
|
|
221
|
+
// filesystem (we ensured that by writing tmp into the same dir).
|
|
222
|
+
await fs.promises.rename(tmp, filePath);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* One-shot migration of the on-disk wp-sites.json file. Idempotent.
|
|
227
|
+
*
|
|
228
|
+
* @param {object} args
|
|
229
|
+
* @param {string} args.filePath
|
|
230
|
+
* @param {object} args.secretStore
|
|
231
|
+
* @param {object} [args.env]
|
|
232
|
+
* @param {boolean} [args.dryRun] Skip the write and the .v1.bak
|
|
233
|
+
* @returns {Promise<{
|
|
234
|
+
* migrated: boolean,
|
|
235
|
+
* alreadyV2?: boolean,
|
|
236
|
+
* filePath: string,
|
|
237
|
+
* backupPath?: string,
|
|
238
|
+
* liftedCount?: number,
|
|
239
|
+
* }>}
|
|
240
|
+
*/
|
|
241
|
+
async function migrateFile(args) {
|
|
242
|
+
if (!args || !args.filePath) {
|
|
243
|
+
throw new MigrationError('migrateFile requires filePath', { code: 'no_path' });
|
|
244
|
+
}
|
|
245
|
+
if (!args.secretStore) {
|
|
246
|
+
throw new MigrationError('migrateFile requires secretStore', { code: 'no_store' });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let raw;
|
|
250
|
+
try {
|
|
251
|
+
raw = await fs.promises.readFile(args.filePath, 'utf8');
|
|
252
|
+
} catch (err) {
|
|
253
|
+
if (err.code === 'ENOENT') {
|
|
254
|
+
// Nothing to migrate — caller is starting clean.
|
|
255
|
+
return { migrated: false, alreadyV2: false, filePath: args.filePath, missing: true };
|
|
256
|
+
}
|
|
257
|
+
throw new MigrationError(`Cannot read ${args.filePath}: ${err.message}`, {
|
|
258
|
+
code: 'read_failed', cause: err,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
let parsed;
|
|
263
|
+
try { parsed = JSON.parse(raw); }
|
|
264
|
+
catch (err) {
|
|
265
|
+
throw new MigrationError(`Cannot parse ${args.filePath}: ${err.message}`, {
|
|
266
|
+
code: 'parse_failed', cause: err,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (isV2(parsed)) {
|
|
271
|
+
return { migrated: false, alreadyV2: true, filePath: args.filePath };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const { config: v2Config, lifted } = await migrateConfig(parsed, {
|
|
275
|
+
secretStore: args.secretStore,
|
|
276
|
+
env: args.env,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
if (args.dryRun) {
|
|
280
|
+
return {
|
|
281
|
+
migrated: false,
|
|
282
|
+
alreadyV2: false,
|
|
283
|
+
filePath: args.filePath,
|
|
284
|
+
previewConfig: v2Config,
|
|
285
|
+
liftedCount: lifted.length,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Backup BEFORE writing — if backup fails, we have not yet damaged anything.
|
|
290
|
+
const backupPath = `${args.filePath}.v1.bak`;
|
|
291
|
+
try {
|
|
292
|
+
await fs.promises.copyFile(args.filePath, backupPath);
|
|
293
|
+
} catch (err) {
|
|
294
|
+
throw new MigrationError(
|
|
295
|
+
`Failed to back up ${args.filePath} → ${backupPath}: ${err.message}`,
|
|
296
|
+
{ code: 'backup_failed', cause: err }
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
await _atomicWrite(args.filePath, v2Config);
|
|
302
|
+
} catch (err) {
|
|
303
|
+
throw new MigrationError(
|
|
304
|
+
`Failed to write v2 config to ${args.filePath}: ${err.message}`,
|
|
305
|
+
{ code: 'write_failed', cause: err }
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
migrated: true,
|
|
311
|
+
filePath: args.filePath,
|
|
312
|
+
backupPath,
|
|
313
|
+
liftedCount: lifted.length,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
module.exports = {
|
|
318
|
+
isV2,
|
|
319
|
+
migrateConfig,
|
|
320
|
+
migrateFile,
|
|
321
|
+
_atomicWrite,
|
|
322
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { postJson, getJson } = require('./http-json');
|
|
4
|
+
const { RegistrationError } = require('./errors');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Dynamic Client Registration (RFC 7591) client.
|
|
8
|
+
*
|
|
9
|
+
* Per Appendix F.4 (DCR registration metadata) and H.4.2 (software_id is a
|
|
10
|
+
* self-reported hint), v1.0 sends:
|
|
11
|
+
*
|
|
12
|
+
* software_id = "com.wickedevolutions.abilities-mcp"
|
|
13
|
+
* software_version = read from package.json (currently "1.4.0"; the
|
|
14
|
+
* design doc example used "1.0.0", but the spec wording
|
|
15
|
+
* defines software_version as "the bridge's current
|
|
16
|
+
* version" — we report what we ship as)
|
|
17
|
+
*
|
|
18
|
+
* Per Appendix D.2 L2: "GET probes before POST." We do a courtesy GET on the
|
|
19
|
+
* registration endpoint before POSTing — adapter Phase 1 wires both.
|
|
20
|
+
*
|
|
21
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
22
|
+
* @license GPL-2.0-or-later
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const SOFTWARE_ID = 'com.wickedevolutions.abilities-mcp';
|
|
26
|
+
const CLIENT_URI = 'https://wickedevolutions.com/docs/abilities-mcp';
|
|
27
|
+
const DEFAULT_GRANT_TYPES = Object.freeze(['authorization_code', 'refresh_token']);
|
|
28
|
+
const DEFAULT_RESPONSE_TYPES = Object.freeze(['code']);
|
|
29
|
+
const DEFAULT_AUTH_METHOD = 'none'; // public client — PKCE proof-of-possession
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build the DCR request body.
|
|
33
|
+
* @param {object} args
|
|
34
|
+
* @param {string} args.clientName
|
|
35
|
+
* @param {string} args.redirectUri
|
|
36
|
+
* @param {string|string[]} args.scope space-separated scope string OR array
|
|
37
|
+
* @param {string} args.softwareVersion
|
|
38
|
+
* @param {string[]} [args.grantTypes]
|
|
39
|
+
* @param {string[]} [args.responseTypes]
|
|
40
|
+
* @returns {object}
|
|
41
|
+
*/
|
|
42
|
+
function buildRegistrationBody(args) {
|
|
43
|
+
const scope = Array.isArray(args.scope) ? args.scope.join(' ') : args.scope;
|
|
44
|
+
return {
|
|
45
|
+
client_name: args.clientName,
|
|
46
|
+
redirect_uris: [args.redirectUri],
|
|
47
|
+
token_endpoint_auth_method: DEFAULT_AUTH_METHOD,
|
|
48
|
+
grant_types: args.grantTypes || DEFAULT_GRANT_TYPES,
|
|
49
|
+
response_types: args.responseTypes || DEFAULT_RESPONSE_TYPES,
|
|
50
|
+
scope,
|
|
51
|
+
software_id: SOFTWARE_ID,
|
|
52
|
+
software_version: args.softwareVersion,
|
|
53
|
+
client_uri: CLIENT_URI,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Register a new OAuth client with the adapter.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} args
|
|
61
|
+
* @param {string} args.registrationEndpoint
|
|
62
|
+
* @param {string} args.clientName
|
|
63
|
+
* @param {string} args.redirectUri
|
|
64
|
+
* @param {string|string[]} args.scope
|
|
65
|
+
* @param {string} args.softwareVersion
|
|
66
|
+
* @param {boolean} [args.skipGetProbe] Tests can disable.
|
|
67
|
+
* @param {boolean} [args.allowInsecure]
|
|
68
|
+
* @param {number} [args.timeoutMs]
|
|
69
|
+
* @param {object} [args.httpsAgent] test injection
|
|
70
|
+
* @returns {Promise<{clientId: string, raw: object}>}
|
|
71
|
+
*/
|
|
72
|
+
async function register(args) {
|
|
73
|
+
// L2 GET probe — best-effort, never raises.
|
|
74
|
+
if (!args.skipGetProbe) {
|
|
75
|
+
try {
|
|
76
|
+
await getJson(args.registrationEndpoint, {
|
|
77
|
+
allowInsecure: args.allowInsecure,
|
|
78
|
+
timeoutMs: args.timeoutMs,
|
|
79
|
+
httpsAgent: args.httpsAgent,
|
|
80
|
+
});
|
|
81
|
+
} catch {
|
|
82
|
+
// Ignore — the probe is informational. Real failures show up on POST.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const body = buildRegistrationBody({
|
|
87
|
+
clientName: args.clientName,
|
|
88
|
+
redirectUri: args.redirectUri,
|
|
89
|
+
scope: args.scope,
|
|
90
|
+
softwareVersion: args.softwareVersion,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
let res;
|
|
94
|
+
try {
|
|
95
|
+
res = await postJson(args.registrationEndpoint, body, {
|
|
96
|
+
allowInsecure: args.allowInsecure,
|
|
97
|
+
timeoutMs: args.timeoutMs,
|
|
98
|
+
httpsAgent: args.httpsAgent,
|
|
99
|
+
});
|
|
100
|
+
} catch (err) {
|
|
101
|
+
throw new RegistrationError(
|
|
102
|
+
`DCR request failed: ${err.message}`,
|
|
103
|
+
{ state: 'registering', cause: err }
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
108
|
+
throw new RegistrationError(
|
|
109
|
+
`DCR returned ${res.statusCode} from ${args.registrationEndpoint}`,
|
|
110
|
+
{ state: 'registering', cause: { statusCode: res.statusCode, body: res.body } }
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (!res.json || typeof res.json.client_id !== 'string') {
|
|
114
|
+
throw new RegistrationError(
|
|
115
|
+
`DCR response missing client_id`,
|
|
116
|
+
{ state: 'registering', cause: { body: res.body } }
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { clientId: res.json.client_id, raw: res.json };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { register, buildRegistrationBody, SOFTWARE_ID, CLIENT_URI };
|