@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +88 -17
  3. package/abilities-mcp.js +182 -113
  4. package/lib/auth/bridge-identity-provider.js +34 -0
  5. package/lib/auth/browser-launcher.js +67 -0
  6. package/lib/auth/config-migration.js +322 -0
  7. package/lib/auth/dcr-client.js +123 -0
  8. package/lib/auth/discovery-client.js +273 -0
  9. package/lib/auth/errors.js +114 -0
  10. package/lib/auth/events.js +55 -0
  11. package/lib/auth/fresh-each-time-identity.js +101 -0
  12. package/lib/auth/http-json.js +151 -0
  13. package/lib/auth/index.js +88 -0
  14. package/lib/auth/keychain-secret-store.js +98 -0
  15. package/lib/auth/loopback-server.js +249 -0
  16. package/lib/auth/memory-secret-store.js +0 -0
  17. package/lib/auth/oauth-client.js +357 -0
  18. package/lib/auth/pkce.js +93 -0
  19. package/lib/auth/schema-v2.js +110 -0
  20. package/lib/auth/secret-store.js +78 -0
  21. package/lib/auth/token-manager.js +378 -0
  22. package/lib/cli/commands/add-site.js +226 -0
  23. package/lib/cli/commands/force-downgrade.js +93 -0
  24. package/lib/cli/commands/list-sites.js +93 -0
  25. package/lib/cli/commands/reauth.js +108 -0
  26. package/lib/cli/commands/revoke.js +127 -0
  27. package/lib/cli/commands/self-check.js +158 -0
  28. package/lib/cli/commands/test.js +174 -0
  29. package/lib/cli/commands/upgrade-auth.js +259 -0
  30. package/lib/cli/config-store.js +161 -0
  31. package/lib/cli/context.js +102 -0
  32. package/lib/cli/errors.js +227 -0
  33. package/lib/cli/index.js +173 -0
  34. package/lib/cli/output.js +175 -0
  35. package/lib/cli/parse-args.js +80 -0
  36. package/lib/config.js +248 -19
  37. package/lib/connection-pool.js +214 -11
  38. package/lib/router.js +29 -11
  39. package/lib/transports/oauth-http-transport.js +601 -0
  40. 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 };