@adrata/adrata-mcp 1.0.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.
Files changed (41) hide show
  1. package/README.md +548 -0
  2. package/access/auth.js +289 -0
  3. package/access/oauth.js +1059 -0
  4. package/access/resource-metadata.js +167 -0
  5. package/access/tiers.js +422 -0
  6. package/analytics.js +634 -0
  7. package/api-bridge.js +499 -0
  8. package/governance/money.js +141 -0
  9. package/output-formatter.js +589 -0
  10. package/package.json +68 -0
  11. package/resources.js +246 -0
  12. package/security.js +690 -0
  13. package/server.js +2139 -0
  14. package/server.json +55 -0
  15. package/skills/backlog-triage/SKILL.md +115 -0
  16. package/skills/board-review/SKILL.md +96 -0
  17. package/skills/incident-to-card/SKILL.md +126 -0
  18. package/skills/log-outreach.md +62 -0
  19. package/skills/ship-the-card/SKILL.md +155 -0
  20. package/tool-annotations.js +269 -0
  21. package/tools/billing.js +149 -0
  22. package/tools/email-tools.js +652 -0
  23. package/tools/enterprise-tools.js +651 -0
  24. package/tools/free-search.js +160 -0
  25. package/tools/memory.js +440 -0
  26. package/tools/morning-brief.js +551 -0
  27. package/tools/paper-tools.js +563 -0
  28. package/tools/scheduling.js +322 -0
  29. package/tools/work-board-tools.js +758 -0
  30. package/toolsets/communications.js +276 -0
  31. package/toolsets/crm.js +495 -0
  32. package/toolsets/extensibility.js +1131 -0
  33. package/toolsets/infrastructure.js +757 -0
  34. package/toolsets/intelligence.js +232 -0
  35. package/toolsets/knowledge.js +154 -0
  36. package/toolsets/matrix.js +217 -0
  37. package/toolsets/outreach.js +432 -0
  38. package/toolsets/prospecting.js +314 -0
  39. package/toolsets/revenue/always-loaded.js +341 -0
  40. package/toolsets/revenue/sloan-tools.js +81 -0
  41. package/transport-http.js +505 -0
@@ -0,0 +1,1059 @@
1
+ /**
2
+ * OAuth 2.0 Authorization Code Flow for Adrata MCP Server.
3
+ *
4
+ * Handles:
5
+ * - Building the authorization URL and opening the browser
6
+ * - Spinning up a local HTTP server to receive the callback
7
+ * - Exchanging the auth code for access + refresh tokens
8
+ * - Storing tokens securely in ~/.adrata/tokens.json (obfuscated)
9
+ * - Auto-refreshing expired access tokens
10
+ * - Revoking tokens on disconnect
11
+ * - Fetching workspace capabilities after connection
12
+ */
13
+
14
+ import { createServer } from 'node:http';
15
+ import { randomBytes, createCipheriv, createDecipheriv, createHash } from 'node:crypto';
16
+ import { execFile } from 'node:child_process';
17
+ import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync, chmodSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
19
+ import { join, resolve } from 'node:path';
20
+ import { canonicalResource } from './resource-metadata.js';
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Constants
24
+ // ---------------------------------------------------------------------------
25
+
26
+ const ADRATA_DIR = process.env.ADRATA_MCP_CONFIG_DIR
27
+ ? resolve(process.env.ADRATA_MCP_CONFIG_DIR)
28
+ : join(homedir(), '.adrata');
29
+ const TOKEN_FILE = join(ADRATA_DIR, 'tokens.json');
30
+ const CALLBACK_PORT = 19472; // Ephemeral port for OAuth callback
31
+ const CALLBACK_PATH = '/oauth/callback';
32
+ // IPv4 loopback is FIRST and that order is load-bearing, not cosmetic.
33
+ //
34
+ // A CSP source expression cannot express an IPv6 literal host: the `host-source`
35
+ // grammar only admits ALPHA / DIGIT / "-" host characters, so `http://[::1]:19472`
36
+ // is discarded by the browser as an invalid source while the rest of the
37
+ // directive still applies. The Adrata consent page ships
38
+ // `form-action 'self' http://127.0.0.1:19472 http://localhost:19472 http://[::1]:19472`,
39
+ // and the "Connect workspace" POST answers with a 307 to the loopback callback.
40
+ // Browsers that check `form-action` against post-submission redirects therefore
41
+ // allow the IPv4 redirect and silently drop the IPv6 one — the local listener
42
+ // never sees the callback and `connect_workspace` dies at the 2-minute timeout.
43
+ // Verified in Chrome: with both origins named in one directive, `[::1]` is
44
+ // blocked and `127.0.0.1` succeeds; with no CSP at all, both succeed.
45
+ //
46
+ // `::1` stays as a fallback for hosts where the IPv4 loopback cannot be bound
47
+ // (the loop below falls through on EADDRNOTAVAIL / EAFNOSUPPORT), and both forms
48
+ // remain in the default RFC 7591 registration set.
49
+ export const OAUTH_CALLBACK_ENDPOINTS = Object.freeze([
50
+ { host: '127.0.0.1', url: `http://127.0.0.1:${CALLBACK_PORT}${CALLBACK_PATH}` },
51
+ { host: '::1', url: `http://[::1]:${CALLBACK_PORT}${CALLBACK_PATH}` },
52
+ ]);
53
+ const OAUTH_BASE_PATH = '/api/v1/enterprise/oauth';
54
+ // Read-only surface + the AI dispatcher. `ai:base` is required for anything
55
+ // routing through POST /api/v1/ai-crm-tools/execute. The additional read:*
56
+ // scopes unblock ~30 tools that scope_guard gates behind their own families
57
+ // (analytics/forecast, buyer-groups + path-to-power, speedrun, agent-tasks,
58
+ // provider catalog).
59
+ //
60
+ // This stays the DEFAULT grant: least privilege, so a leaked MCP token cannot
61
+ // mutate a workspace. Write access is opt-in per connection via
62
+ // connect_workspace({ writeAccess: true }) — see OAUTH_WRITE_SCOPE below.
63
+ export const OAUTH_SCOPE = [
64
+ 'read:companies', 'read:people', 'read:opportunities', 'read:actions',
65
+ 'read:pipeline', 'read:speedrun', 'read:signals', 'read:search',
66
+ 'read:analytics', 'read:buyer-groups', 'read:tasks', 'read:integrations',
67
+ 'read:partnerships', 'read:sequences', 'read:campaigns', 'read:data',
68
+ 'read:email',
69
+ 'ai:base',
70
+ ].join(' ');
71
+
72
+ // Opt-in CRM write surface.
73
+ //
74
+ // A previous comment here claimed write scopes were pointless because "a
75
+ // machine/OAuth principal cannot complete a governed write anyway". That was
76
+ // factually wrong, and it left the MCP silently read-only while the tool
77
+ // catalog advertised "Full CRUD for companies, people, opportunities,
78
+ // activities, buyer groups". The API gates machine clients on scope ALONE —
79
+ // see adrata_middleware::scope_guard::first_party_user_bypass, which bypasses
80
+ // the guard for human roles and falls through to the scope check when
81
+ // role == "machine_client". A machine token holding write:companies does
82
+ // therefore complete DELETE /api/v1/companies/{id}.
83
+ //
84
+ // Deliberately excluded: admin:* (workspace administration),
85
+ // write:enrichment (spends provider credits), and write:email (sends bulk
86
+ // email under the workspace's identity — the highest-blast-radius write in
87
+ // the product, and not consent-elevatable at all: it requires an
88
+ // admin-provisioned OAuth client, so requesting it here would only make
89
+ // every writeAccess consent fail). Those still require a signed-in human
90
+ // session or an admin-created client.
91
+ export const OAUTH_WRITE_SCOPE = [
92
+ 'write:companies', 'write:people', 'write:buyer-groups',
93
+ 'write:opportunities', 'write:actions', 'write:tasks',
94
+ 'write:partnerships', 'write:sequences', 'write:campaigns', 'write:data',
95
+ ].join(' ');
96
+
97
+ /**
98
+ * Scope string for a connection. Read-only unless write access is requested.
99
+ *
100
+ * Requires a strict boolean `true`. A truthy check would let a stray string
101
+ * (notably the string "false", which is truthy) silently escalate the grant,
102
+ * so elevation is opt-in on exactly one value.
103
+ */
104
+ export function oauthScopeFor({ writeAccess = false } = {}) {
105
+ return writeAccess === true ? `${OAUTH_SCOPE} ${OAUTH_WRITE_SCOPE}` : OAUTH_SCOPE;
106
+ }
107
+
108
+ /**
109
+ * Human-readable capability summary derived from an *actually granted*
110
+ * OAuth scope string. Every place that tells a caller what connecting (or
111
+ * being connected) unlocks must go through this rather than asserting a
112
+ * fixed "enterprise tools are available" claim — OAUTH_SCOPE above is
113
+ * deliberately read-only (no write or admin scopes), and the API's scope_guard
114
+ * middleware rejects writes from a token that lacks them (403
115
+ * insufficient_scope) regardless of what any message here claims. Deriving
116
+ * the summary from the scope string (rather than hardcoding "read-only")
117
+ * also keeps this honest automatically if OAUTH_SCOPE ever changes.
118
+ */
119
+ export function describeOAuthScopeCapabilities(scopeString) {
120
+ const scopes = String(scopeString || '')
121
+ .split(/\s+/)
122
+ .filter(Boolean);
123
+ const canWrite = scopes.some((s) => s.startsWith('write:') || s.startsWith('admin:'));
124
+ const canAdmin = scopes.some((s) => s.startsWith('admin:'));
125
+ // Describe ONLY what the granted scopes actually permit. An earlier version
126
+ // of this string promised "sequences, campaigns, and workspace
127
+ // administration" for any write grant -- none of which OAUTH_WRITE_SCOPE
128
+ // contains. Over-claiming here is the same defect this module exists to
129
+ // avoid: a capability claim the API will refuse.
130
+ const written = scopes
131
+ .filter((s) => s.startsWith('write:'))
132
+ .map((s) => s.slice('write:'.length))
133
+ .sort();
134
+ return {
135
+ scopes,
136
+ canWrite,
137
+ canAdmin,
138
+ summary: canWrite
139
+ ? `Read access, plus create/update/delete on: ${written.join(', ') || '(none)'}. `
140
+ + `Anything outside that list -- including email sending`
141
+ + `${canAdmin ? '' : ' and workspace administration'} -- is NOT granted and the API will reject it `
142
+ + `with 403 insufficient_scope.`
143
+ : 'Read-only access: search and read companies, people, opportunities, pipeline, buyer groups, signals, and analytics, plus the governed AI tool dispatcher for read operations. CRM writes, sequences, campaigns, email sending, and admin actions are NOT granted by this connection — request CRM/sequence/campaign writes explicitly with connect_workspace({ writeAccess: true }), which requires interactive human approval. Email sending and admin actions are never granted to MCP connections.',
144
+ };
145
+ }
146
+ const CALLBACK_SECURITY_HEADERS = Object.freeze({
147
+ 'Cache-Control': 'no-store',
148
+ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
149
+ 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
150
+ 'Referrer-Policy': 'no-referrer',
151
+ 'X-Content-Type-Options': 'nosniff',
152
+ 'X-Frame-Options': 'DENY',
153
+ });
154
+ const CALLBACK_HTML_HEADERS = Object.freeze({
155
+ ...CALLBACK_SECURITY_HEADERS,
156
+ 'Content-Type': 'text/html; charset=utf-8',
157
+ });
158
+ const CALLBACK_TEXT_HEADERS = Object.freeze({
159
+ ...CALLBACK_SECURITY_HEADERS,
160
+ 'Content-Type': 'text/plain; charset=utf-8',
161
+ });
162
+
163
+ // Encryption uses AES-256-GCM with a machine-derived key.
164
+ // This is not a substitute for OS keychain, but prevents plaintext tokens on disk.
165
+ const ALGORITHM = 'aes-256-gcm';
166
+
167
+ function openBrowser(url) {
168
+ if (process.platform === 'darwin') {
169
+ execFile('open', [url], () => {});
170
+ } else if (process.platform === 'win32') {
171
+ execFile('rundll32.exe', ['url.dll,FileProtocolHandler', url], () => {});
172
+ } else {
173
+ execFile('xdg-open', [url], () => {});
174
+ }
175
+ }
176
+
177
+ function escapeHtml(value) {
178
+ return String(value ?? '')
179
+ .replaceAll('&', '&')
180
+ .replaceAll('<', '&lt;')
181
+ .replaceAll('>', '&gt;')
182
+ .replaceAll('"', '&quot;')
183
+ .replaceAll("'", '&#39;');
184
+ }
185
+
186
+ // The Adrata mark — 8 dashed arc segments forming a ring (Figma node 525:158),
187
+ // the same brand element the desktop app renders on its home and sign-in
188
+ // screens. Inlined so these OAuth pages match the product surface.
189
+ const ADRATA_MARK_SEGMENTS = [
190
+ 'M41.3099 9.12903C37.7723 8.94462 34.2275 8.94462 30.6899 9.12903',
191
+ 'M60.5402 18.7769C59.0102 15.3329 56.6672 12.9899 53.2202 11.4629',
192
+ 'M62.8711 30.6899C63.0611 34.2274 63.0611 37.7725 62.8711 41.3099',
193
+ 'M60.5401 53.2202C59.0101 56.6702 56.6701 59.0102 53.2231 60.5402',
194
+ 'M41.3129 62.8682C37.7745 63.0593 34.2284 63.0603 30.6899 62.8712',
195
+ 'M18.78 60.5371C15.33 59.0131 12.99 56.6671 11.46 53.2231',
196
+ 'M9.12908 30.6899C8.94556 34.2286 8.94656 37.7744 9.13208 41.3129',
197
+ 'M18.78 11.46C15.333 12.99 12.99 15.333 11.46 18.78',
198
+ ];
199
+
200
+ function adrataMarkSvg(size, color) {
201
+ const paths = ADRATA_MARK_SEGMENTS.map(
202
+ (d) =>
203
+ `<path d="${d}" stroke="${color}" stroke-width="5.4" stroke-linecap="round" stroke-linejoin="round"/>`,
204
+ ).join('');
205
+ return `<svg width="${size}" height="${size}" viewBox="0 0 72 72" fill="none" aria-label="Adrata" role="img">${paths}</svg>`;
206
+ }
207
+
208
+ export function oauthCallbackPage({ success, title, message, detail }) {
209
+ const accent = success ? '#56d48f' : '#ff6b6b';
210
+ const safeTitle = escapeHtml(title);
211
+ const safeMessage = escapeHtml(message);
212
+ const safeDetail = detail ? escapeHtml(detail) : '';
213
+ const closeText = success
214
+ ? 'You can close this window and return to your terminal.'
215
+ : 'You can close this window and return to your workspace.';
216
+ // Success shows a small check badge tucked onto the ring; error shows nothing
217
+ // extra — the red ring carries the state. Matches the desktop's restraint.
218
+ const badge = success
219
+ ? `<span class="badge" aria-hidden="true"><svg width="15" height="15" viewBox="0 0 24 24" fill="none"><path d="M5 12.5l4.2 4.2L19 7" stroke="#0a0a0b" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg></span>`
220
+ : '';
221
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${safeTitle} · Adrata</title><style>
222
+ :root{color-scheme:dark}*{box-sizing:border-box}html,body{height:100%}body{margin:0;display:grid;place-items:center;padding:24px;background:#0a0a0b;color:#f5f5f6;font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","SF Pro Display",system-ui,sans-serif;-webkit-font-smoothing:antialiased}main{width:min(100%,460px);padding:48px 44px;border:1px solid #1b1b1f;border-radius:22px;background:#0f0f11;box-shadow:0 1px 0 0 rgba(255,255,255,.02) inset,0 30px 80px -20px rgba(0,0,0,.7);text-align:center}.mark{position:relative;width:64px;height:64px;margin:0 auto 26px;color:${accent}}.mark svg{display:block;filter:drop-shadow(0 0 14px ${accent}40)}.badge{position:absolute;right:-2px;bottom:-2px;display:grid;place-items:center;width:24px;height:24px;border-radius:50%;background:${accent};box-shadow:0 0 0 4px #0f0f11}h1{margin:0 0 10px;font-size:25px;font-weight:500;letter-spacing:-.02em;line-height:1.15}p{margin:0 auto;max-width:34ch;color:#9a9aa2;font-size:15px;line-height:1.6}.detail{margin-top:22px;padding:11px 13px;border-radius:11px;border:1px solid #1b1b1f;background:#0a0a0b;color:#6f6f78;font:12px/1.5 ui-monospace,"SF Mono",SFMono-Regular,Menlo,monospace;overflow-wrap:anywhere;text-align:left}.close{margin-top:28px;color:#6f6f78;font-size:12.5px;letter-spacing:.01em}</style></head><body><main><div class="mark">${adrataMarkSvg(
223
+ 64,
224
+ accent,
225
+ )}${badge}</div><h1>${safeTitle}</h1><p>${safeMessage}</p>${
226
+ safeDetail ? `<div class="detail">${safeDetail}</div>` : ''
227
+ }<div class="close">${closeText}</div></main></body></html>`;
228
+ }
229
+
230
+ /**
231
+ * Derive a machine-specific encryption key from hostname + username.
232
+ * This is intentionally not high-security — it prevents casual plaintext
233
+ * exposure while remaining portable across sessions without a password.
234
+ */
235
+ function deriveKey() {
236
+ const material = `adrata-mcp:${homedir()}:${process.env.USER || process.env.USERNAME || 'default'}`;
237
+ return createHash('sha256').update(material).digest();
238
+ }
239
+
240
+ function base64Url(buffer) {
241
+ return Buffer.from(buffer)
242
+ .toString('base64')
243
+ .replace(/\+/g, '-')
244
+ .replace(/\//g, '_')
245
+ .replace(/=+$/g, '');
246
+ }
247
+
248
+ function createPkcePair() {
249
+ const verifier = base64Url(randomBytes(32));
250
+ const challenge = base64Url(createHash('sha256').update(verifier).digest());
251
+ return { verifier, challenge };
252
+ }
253
+
254
+ function normalizeTokenResult(result) {
255
+ return {
256
+ accessToken: result.accessToken || result.access_token,
257
+ refreshToken: result.refreshToken || result.refresh_token,
258
+ expiresIn: result.expiresIn || result.expires_in,
259
+ scope: result.scope,
260
+ };
261
+ }
262
+
263
+ /**
264
+ * Register a fresh native/public client for this explicit connection attempt.
265
+ * PKCE protects the code exchange; an npm-embedded client secret would not.
266
+ */
267
+ export async function registerNativeClient(
268
+ apiBase,
269
+ fetchImpl = fetch,
270
+ redirectUris = OAUTH_CALLBACK_ENDPOINTS.map(({ url: redirectUri }) => redirectUri),
271
+ scope = OAUTH_SCOPE,
272
+ ) {
273
+ const url = new URL(`${OAUTH_BASE_PATH}/register`, apiBase);
274
+ const res = await fetchImpl(url.toString(), {
275
+ method: 'POST',
276
+ headers: { 'Content-Type': 'application/json' },
277
+ body: JSON.stringify({
278
+ client_name: 'Adrata MCP',
279
+ redirect_uris: redirectUris,
280
+ grant_types: ['authorization_code', 'refresh_token'],
281
+ response_types: ['code'],
282
+ // Must match what the authorize step will request. Registering the
283
+ // read-only scope while asking for write scopes leaves the
284
+ // authorization server free to narrow the grant back to read.
285
+ scope,
286
+ token_endpoint_auth_method: 'none',
287
+ }),
288
+ });
289
+ const data = await res.json().catch(() => ({}));
290
+ if (!res.ok) {
291
+ throw new Error(`OAuth client registration failed: ${res.status} ${JSON.stringify(data).slice(0, 200)}`);
292
+ }
293
+ if (typeof data.client_id !== 'string' || !data.client_id) {
294
+ throw new Error('OAuth client registration returned no client_id.');
295
+ }
296
+ if (data.token_endpoint_auth_method !== 'none' || data.client_secret) {
297
+ throw new Error('OAuth server did not register a public PKCE client.');
298
+ }
299
+ return {
300
+ clientId: data.client_id,
301
+ registrationAccessToken: data.registration_access_token,
302
+ tokenEndpointAuthMethod: data.token_endpoint_auth_method,
303
+ };
304
+ }
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // Encryption helpers
308
+ // ---------------------------------------------------------------------------
309
+
310
+ function encrypt(plaintext) {
311
+ const key = deriveKey();
312
+ const iv = randomBytes(12);
313
+ const cipher = createCipheriv(ALGORITHM, key, iv);
314
+ let encrypted = cipher.update(plaintext, 'utf8', 'hex');
315
+ encrypted += cipher.final('hex');
316
+ const tag = cipher.getAuthTag().toString('hex');
317
+ return `${iv.toString('hex')}:${tag}:${encrypted}`;
318
+ }
319
+
320
+ function decrypt(ciphertext) {
321
+ const key = deriveKey();
322
+ const [ivHex, tagHex, encHex] = ciphertext.split(':');
323
+ if (!ivHex || !tagHex || !encHex) throw new Error('Malformed encrypted token');
324
+ const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(ivHex, 'hex'));
325
+ decipher.setAuthTag(Buffer.from(tagHex, 'hex'));
326
+ let decrypted = decipher.update(encHex, 'hex', 'utf8');
327
+ decrypted += decipher.final('utf8');
328
+ return decrypted;
329
+ }
330
+
331
+ // ---------------------------------------------------------------------------
332
+ // Token storage
333
+ // ---------------------------------------------------------------------------
334
+
335
+ function ensureDir() {
336
+ if (!existsSync(ADRATA_DIR)) {
337
+ mkdirSync(ADRATA_DIR, { recursive: true, mode: 0o700 });
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Save tokens to ~/.adrata/tokens.json (encrypted).
343
+ */
344
+ export function saveTokens(tokenData) {
345
+ ensureDir();
346
+ const payload = {
347
+ v: 1,
348
+ data: encrypt(JSON.stringify(tokenData)),
349
+ updatedAt: new Date().toISOString(),
350
+ };
351
+ writeFileSync(TOKEN_FILE, JSON.stringify(payload, null, 2), { mode: 0o600 });
352
+ try { chmodSync(TOKEN_FILE, 0o600); } catch { /* best-effort */ }
353
+ }
354
+
355
+ /**
356
+ * Load tokens from ~/.adrata/tokens.json. Returns null if not found or invalid.
357
+ */
358
+ export function loadTokens() {
359
+ try {
360
+ if (!existsSync(TOKEN_FILE)) return null;
361
+ const raw = JSON.parse(readFileSync(TOKEN_FILE, 'utf8'));
362
+ if (raw.v !== 1 || !raw.data) return null;
363
+ return JSON.parse(decrypt(raw.data));
364
+ } catch {
365
+ return null;
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Clear stored tokens (for disconnect).
371
+ */
372
+ export function clearTokens() {
373
+ try {
374
+ if (existsSync(TOKEN_FILE)) unlinkSync(TOKEN_FILE);
375
+ } catch { /* ignore */ }
376
+ }
377
+
378
+ /**
379
+ * Check if we have stored tokens.
380
+ */
381
+ export function hasStoredTokens() {
382
+ return loadTokens() !== null;
383
+ }
384
+
385
+ // ---------------------------------------------------------------------------
386
+ // Token refresh
387
+ // ---------------------------------------------------------------------------
388
+
389
+ /**
390
+ * Check if the access token is expired or about to expire (within 5 min).
391
+ */
392
+ function isTokenExpired(tokens) {
393
+ if (!tokens.expiresAt) return true;
394
+ const expiresAt = new Date(tokens.expiresAt).getTime();
395
+ const now = Date.now();
396
+ const bufferMs = 5 * 60 * 1000; // 5 minutes
397
+ return now >= expiresAt - bufferMs;
398
+ }
399
+
400
+ /**
401
+ * OAuth tokens are audience/environment bound. Never send a stored access or
402
+ * refresh token to an API origin other than the one that issued the session.
403
+ */
404
+ export function storedSessionMatchesApiBase(apiBase, tokens = loadTokens()) {
405
+ if (!tokens?.apiBase) return true;
406
+ try {
407
+ return new URL(tokens.apiBase).origin === new URL(apiBase).origin;
408
+ } catch {
409
+ return false;
410
+ }
411
+ }
412
+
413
+ /**
414
+ * The refresh token is dead — revoked, expired, rotated away, or bound to a
415
+ * different API/audience. The only recovery is an interactive reconnect, so
416
+ * callers should surface "run connect_workspace" rather than silently 401.
417
+ */
418
+ export class ReconnectRequiredError extends Error {
419
+ constructor(message, options = {}) {
420
+ super(message, options);
421
+ this.name = 'ReconnectRequiredError';
422
+ this.code = 'reconnect_required';
423
+ this.reconnectRequired = true;
424
+ }
425
+ }
426
+
427
+ /**
428
+ * A transient failure refreshing the access token (network error, timeout,
429
+ * 429, or 5xx). The stored refresh token is probably still valid and the
430
+ * caller may retry shortly. Kept distinct from ReconnectRequiredError so a
431
+ * flaky network never masquerades as a revoked session.
432
+ */
433
+ export class TransientRefreshError extends Error {
434
+ constructor(message, options = {}) {
435
+ super(message, options);
436
+ this.name = 'TransientRefreshError';
437
+ this.code = 'refresh_transient';
438
+ this.transient = true;
439
+ }
440
+ }
441
+
442
+ const REFRESH_MAX_ATTEMPTS = 3;
443
+ const REFRESH_BASE_DELAY_MS = 250;
444
+
445
+ function refreshBackoffDelay(attempt) {
446
+ // Exponential backoff with light jitter: ~250ms then ~500ms between attempts.
447
+ const base = REFRESH_BASE_DELAY_MS * 2 ** (attempt - 1);
448
+ return base + Math.floor(Math.random() * REFRESH_BASE_DELAY_MS);
449
+ }
450
+
451
+ function sleep(ms) {
452
+ return new Promise((resolve) => setTimeout(resolve, ms));
453
+ }
454
+
455
+ /**
456
+ * In-flight refresh promises keyed by API base. The authorization server
457
+ * ROTATES and revokes the previous refresh token on every refresh, so N
458
+ * concurrent tool calls must NOT each POST /token — the first would win and the
459
+ * rest would get invalid_grant. We single-flight: concurrent callers for the
460
+ * same apiBase await one shared refresh.
461
+ */
462
+ const _inflightRefresh = new Map();
463
+
464
+ /**
465
+ * Refresh the access token using the refresh token.
466
+ *
467
+ * - Single-flight: concurrent callers for the same apiBase share one refresh.
468
+ * - Retries transient failures (network / 429 / 5xx) with backoff.
469
+ * - Throws ReconnectRequiredError when the refresh token is rejected
470
+ * (invalid_grant and other 4xx) so the caller can prompt a reconnect instead
471
+ * of silently 401ing.
472
+ * - Preserves the RFC 8707 `resource` audience binding across the refresh.
473
+ *
474
+ * Updates stored tokens on success.
475
+ */
476
+ export async function refreshAccessToken(apiBase, fetchImpl = fetch) {
477
+ const existing = _inflightRefresh.get(apiBase);
478
+ if (existing) return existing;
479
+
480
+ const inflight = performTokenRefresh(apiBase, fetchImpl).finally(() => {
481
+ _inflightRefresh.delete(apiBase);
482
+ });
483
+ _inflightRefresh.set(apiBase, inflight);
484
+ return inflight;
485
+ }
486
+
487
+ async function performTokenRefresh(apiBase, fetchImpl) {
488
+ const tokens = loadTokens();
489
+ if (!tokens || !tokens.refreshToken) {
490
+ throw new ReconnectRequiredError(
491
+ 'No refresh token available. Run connect_workspace to reconnect your workspace.',
492
+ );
493
+ }
494
+ if (!storedSessionMatchesApiBase(apiBase, tokens)) {
495
+ throw new ReconnectRequiredError(
496
+ 'OAuth session/API mismatch. Run connect_workspace against the target workspace or correct ADRATA_API_URL.',
497
+ );
498
+ }
499
+
500
+ const url = new URL(`${OAUTH_BASE_PATH}/token`, apiBase);
501
+ const buildBody = () => new URLSearchParams({
502
+ grant_type: 'refresh_token',
503
+ client_id: tokens.clientId,
504
+ refresh_token: tokens.refreshToken,
505
+ // RFC 8707: keep the same audience binding the initial grant used so the
506
+ // refreshed access token stays bound to the /api/v1/mcp resource.
507
+ resource: tokens.resource || canonicalResource(),
508
+ });
509
+
510
+ let lastError;
511
+ for (let attempt = 1; attempt <= REFRESH_MAX_ATTEMPTS; attempt += 1) {
512
+ let res;
513
+ try {
514
+ res = await fetchImpl(url.toString(), {
515
+ method: 'POST',
516
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
517
+ body: buildBody(),
518
+ });
519
+ } catch (err) {
520
+ // Network-level failure — transient, worth a retry.
521
+ lastError = new TransientRefreshError(
522
+ `Token refresh network error: ${err.message}`,
523
+ { cause: err },
524
+ );
525
+ if (attempt < REFRESH_MAX_ATTEMPTS) {
526
+ await sleep(refreshBackoffDelay(attempt));
527
+ continue;
528
+ }
529
+ throw lastError;
530
+ }
531
+
532
+ const data = await res.json().catch(() => ({}));
533
+
534
+ if (res.ok) {
535
+ const result = normalizeTokenResult(data.data || data);
536
+ const updatedTokens = {
537
+ ...tokens,
538
+ accessToken: result.accessToken,
539
+ expiresAt: new Date(Date.now() + (result.expiresIn || 3600) * 1000).toISOString(),
540
+ // Refresh token may rotate — persist the new one so the next refresh works.
541
+ ...(result.refreshToken ? { refreshToken: result.refreshToken } : {}),
542
+ };
543
+ saveTokens(updatedTokens);
544
+ return updatedTokens;
545
+ }
546
+
547
+ // Transient server-side failure — retry with backoff.
548
+ if (res.status === 429 || res.status >= 500) {
549
+ lastError = new TransientRefreshError(
550
+ `Token refresh failed: ${res.status} ${JSON.stringify(data).slice(0, 200)}`,
551
+ );
552
+ if (attempt < REFRESH_MAX_ATTEMPTS) {
553
+ await sleep(refreshBackoffDelay(attempt));
554
+ continue;
555
+ }
556
+ throw lastError;
557
+ }
558
+
559
+ // 4xx (invalid_grant, invalid_client, invalid_request, …): the refresh
560
+ // token is dead. Retrying cannot help — surface a reconnect requirement.
561
+ const errorCode = data.error || data.data?.error;
562
+ throw new ReconnectRequiredError(
563
+ `Refresh token rejected (${res.status}${errorCode ? ` ${errorCode}` : ''}). `
564
+ + 'Run connect_workspace to reconnect your workspace.',
565
+ );
566
+ }
567
+
568
+ // Unreachable in practice, but keep the type contract honest.
569
+ throw lastError || new TransientRefreshError('Token refresh failed after retries.');
570
+ }
571
+
572
+ /**
573
+ * Get a valid access token, refreshing if expired.
574
+ *
575
+ * Returns null only when NO workspace session is stored (not connected). When a
576
+ * session exists but the refresh fails, this THROWS a typed error
577
+ * (ReconnectRequiredError or TransientRefreshError) instead of returning null —
578
+ * a silent null previously let callers proceed unauthenticated and 401 with no
579
+ * discoverable reason.
580
+ */
581
+ export async function getValidToken(apiBase, { forceRefresh = false, fetchImpl = fetch } = {}) {
582
+ const tokens = loadTokens();
583
+ if (!tokens) return null;
584
+
585
+ if (forceRefresh || isTokenExpired(tokens)) {
586
+ const refreshed = await refreshAccessToken(apiBase, fetchImpl);
587
+ return refreshed.accessToken;
588
+ }
589
+
590
+ return tokens.accessToken;
591
+ }
592
+
593
+ // ---------------------------------------------------------------------------
594
+ // OAuth authorization flow
595
+ // ---------------------------------------------------------------------------
596
+
597
+ /**
598
+ * Build the OAuth authorization URL.
599
+ */
600
+ function buildAuthUrl(apiBase, clientId, redirectUri, state, pkce, scope = OAUTH_SCOPE) {
601
+ // Use the web app's OAuth authorize page (user-facing login + consent screen)
602
+ const url = new URL(`${OAUTH_BASE_PATH}/authorize`, apiBase);
603
+ url.searchParams.set('client_id', clientId);
604
+ url.searchParams.set('redirect_uri', redirectUri);
605
+ url.searchParams.set('response_type', 'code');
606
+ url.searchParams.set('scope', scope);
607
+ url.searchParams.set('state', state);
608
+ url.searchParams.set('source', 'mcp');
609
+ // RFC 8707 Resource Indicator — request a token audience-bound to this MCP
610
+ // resource so it cannot be replayed against a different service.
611
+ url.searchParams.set('resource', canonicalResource());
612
+ url.searchParams.set('code_challenge', pkce.challenge);
613
+ url.searchParams.set('code_challenge_method', 'S256');
614
+ return { url: url.toString(), state, codeVerifier: pkce.verifier };
615
+ }
616
+
617
+ /**
618
+ * Start a temporary local HTTP server to receive the OAuth callback.
619
+ * Returns a promise that resolves with { code, state } or rejects on timeout/error.
620
+ */
621
+ export async function startCallbackServer(expectedState, timeoutMs = 120000) {
622
+ let lastError;
623
+ for (const endpoint of OAUTH_CALLBACK_ENDPOINTS) {
624
+ try {
625
+ return await listenForCallback(endpoint, expectedState, timeoutMs);
626
+ } catch (err) {
627
+ lastError = err;
628
+ if (!['EADDRNOTAVAIL', 'EAFNOSUPPORT'].includes(err.code)) throw err;
629
+ }
630
+ }
631
+ throw new Error(`No supported OAuth loopback address is available: ${lastError?.message || 'unknown error'}`);
632
+ }
633
+
634
+ function listenForCallback(endpoint, expectedState, timeoutMs) {
635
+ return new Promise((resolve, reject) => {
636
+ let listening = false;
637
+ const server = createServer((req, res) => {
638
+ const reqUrl = new URL(req.url, endpoint.url);
639
+
640
+ if (reqUrl.pathname !== CALLBACK_PATH) {
641
+ res.writeHead(404, CALLBACK_TEXT_HEADERS);
642
+ res.end('Not found');
643
+ return;
644
+ }
645
+
646
+ const code = reqUrl.searchParams.get('code');
647
+ const state = reqUrl.searchParams.get('state');
648
+ const error = reqUrl.searchParams.get('error');
649
+
650
+ if (error) {
651
+ res.writeHead(200, CALLBACK_HTML_HEADERS);
652
+ res.end(oauthCallbackPage({ success: false, title: 'Authorization failed', message: 'Adrata could not connect this client. Return to your workspace and try connecting again.', detail: 'oauth_authorization_failed' }));
653
+ cleanup();
654
+ const oauthError = new Error(`OAuth error: ${error}`);
655
+ oauthError.code = error;
656
+ callbackReject(oauthError);
657
+ return;
658
+ }
659
+
660
+ if (state !== expectedState) {
661
+ res.writeHead(400, CALLBACK_HTML_HEADERS);
662
+ res.end(oauthCallbackPage({ success: false, title: 'Connection could not be verified', message: 'The security state did not match. Start a new connection from Adrata.', detail: 'oauth_state_mismatch' }));
663
+ cleanup();
664
+ callbackReject(new Error('OAuth state mismatch (possible CSRF)'));
665
+ return;
666
+ }
667
+
668
+ if (!code) {
669
+ res.writeHead(400, CALLBACK_HTML_HEADERS);
670
+ res.end(oauthCallbackPage({ success: false, title: 'Connection incomplete', message: 'No authorization code was returned. Start a new connection from Adrata.', detail: 'missing_authorization_code' }));
671
+ cleanup();
672
+ callbackReject(new Error('No authorization code in callback'));
673
+ return;
674
+ }
675
+
676
+ res.writeHead(200, CALLBACK_HTML_HEADERS);
677
+ res.end(oauthCallbackPage({ success: true, title: 'Connected to Adrata', message: 'Your workspace connection is ready.' }));
678
+ cleanup();
679
+ callbackResolve({ code, state });
680
+ });
681
+
682
+ let callbackResolve;
683
+ let callbackReject;
684
+ const callback = new Promise((callbackResolveFn, callbackRejectFn) => {
685
+ callbackResolve = callbackResolveFn;
686
+ callbackReject = callbackRejectFn;
687
+ });
688
+ const timer = setTimeout(() => {
689
+ cleanup();
690
+ callbackReject(new Error('OAuth callback timed out after 2 minutes. Please try again.'));
691
+ }, timeoutMs);
692
+
693
+ function cleanup() {
694
+ clearTimeout(timer);
695
+ try { server.close(); } catch { /* ignore */ }
696
+ }
697
+
698
+ const listenOptions = {
699
+ port: CALLBACK_PORT,
700
+ host: endpoint.host,
701
+ ...(endpoint.host === '::1' ? { ipv6Only: true } : {}),
702
+ };
703
+ server.listen(listenOptions, () => {
704
+ listening = true;
705
+ resolve({ redirectUri: endpoint.url, callback, close: cleanup });
706
+ });
707
+
708
+ server.on('error', (err) => {
709
+ cleanup();
710
+ err.message = `Failed to start OAuth callback server on ${endpoint.host}: ${err.message}`;
711
+ if (listening) callbackReject(err);
712
+ else reject(err);
713
+ });
714
+ });
715
+ }
716
+
717
+ /**
718
+ * Build the request handler separately from the fixed-port listener so the
719
+ * complete browser response contract can be tested on an ephemeral port.
720
+ */
721
+ export function createOAuthCallbackRequestHandler({
722
+ endpointUrl,
723
+ expectedState,
724
+ onComplete,
725
+ onResolve,
726
+ onReject,
727
+ }) {
728
+ return (req, res) => {
729
+ let reqUrl;
730
+ try {
731
+ reqUrl = new URL(req.url, endpointUrl);
732
+ } catch {
733
+ res.writeHead(400, CALLBACK_TEXT_HEADERS);
734
+ res.end('Bad request');
735
+ return;
736
+ }
737
+
738
+ if (reqUrl.pathname !== CALLBACK_PATH) {
739
+ res.writeHead(404, CALLBACK_TEXT_HEADERS);
740
+ res.end('Not found');
741
+ return;
742
+ }
743
+
744
+ const code = reqUrl.searchParams.get('code');
745
+ const state = reqUrl.searchParams.get('state');
746
+ const error = reqUrl.searchParams.get('error');
747
+
748
+ if (error) {
749
+ res.writeHead(200, CALLBACK_HTML_HEADERS);
750
+ res.end(oauthCallbackPage({ success: false, title: 'Authorization failed', message: 'Adrata could not connect this client. Return to your workspace and try connecting again.', detail: 'oauth_authorization_failed' }));
751
+ onComplete();
752
+ const oauthError = new Error(`OAuth error: ${error}`);
753
+ oauthError.code = error;
754
+ onReject(oauthError);
755
+ return;
756
+ }
757
+
758
+ if (state !== expectedState) {
759
+ res.writeHead(400, CALLBACK_HTML_HEADERS);
760
+ res.end(oauthCallbackPage({ success: false, title: 'Connection could not be verified', message: 'The security state did not match. Start a new connection from Adrata.', detail: 'oauth_state_mismatch' }));
761
+ onComplete();
762
+ onReject(new Error('OAuth state mismatch (possible CSRF)'));
763
+ return;
764
+ }
765
+
766
+ if (!code) {
767
+ res.writeHead(400, CALLBACK_HTML_HEADERS);
768
+ res.end(oauthCallbackPage({ success: false, title: 'Connection incomplete', message: 'No authorization code was returned. Start a new connection from Adrata.', detail: 'missing_authorization_code' }));
769
+ onComplete();
770
+ onReject(new Error('No authorization code in callback'));
771
+ return;
772
+ }
773
+
774
+ res.writeHead(200, CALLBACK_HTML_HEADERS);
775
+ res.end(oauthCallbackPage({ success: true, title: 'Connected to Adrata', message: 'Your workspace connection is ready.' }));
776
+ onComplete();
777
+ onResolve({ code, state });
778
+ };
779
+ }
780
+
781
+ /**
782
+ * Exchange an authorization code for tokens.
783
+ */
784
+ async function exchangeCode(apiBase, code, clientId, codeVerifier, redirectUri) {
785
+ const url = new URL(`${OAUTH_BASE_PATH}/token`, apiBase);
786
+ const body = new URLSearchParams({
787
+ grant_type: 'authorization_code',
788
+ client_id: clientId,
789
+ code,
790
+ code_verifier: codeVerifier,
791
+ redirect_uri: redirectUri,
792
+ resource: canonicalResource(),
793
+ });
794
+
795
+ const res = await fetch(url.toString(), {
796
+ method: 'POST',
797
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
798
+ body,
799
+ });
800
+
801
+ const data = await res.json().catch(() => ({}));
802
+ if (!res.ok) {
803
+ throw new Error(`Token exchange failed: ${res.status} ${JSON.stringify(data).slice(0, 200)}`);
804
+ }
805
+
806
+ return normalizeTokenResult(data.data || data);
807
+ }
808
+
809
+ /**
810
+ * Revoke tokens on the server.
811
+ */
812
+ async function revokeToken(apiBase, accessToken) {
813
+ try {
814
+ const url = new URL(`${OAUTH_BASE_PATH}/revoke`, apiBase);
815
+ const body = new URLSearchParams({ token: accessToken });
816
+ await fetch(url.toString(), {
817
+ method: 'POST',
818
+ headers: {
819
+ 'Content-Type': 'application/x-www-form-urlencoded',
820
+ 'Authorization': `Bearer ${accessToken}`,
821
+ },
822
+ body,
823
+ });
824
+ } catch {
825
+ // Best-effort revocation — token will expire naturally if server unreachable
826
+ }
827
+ }
828
+
829
+ // ---------------------------------------------------------------------------
830
+ // Workspace capabilities
831
+ // ---------------------------------------------------------------------------
832
+
833
+ /** In-memory capability cache */
834
+ let _workspaceCapabilities = null;
835
+
836
+ /**
837
+ * Fetch workspace capabilities from the API.
838
+ */
839
+ export async function fetchWorkspaceCapabilities(apiBase, accessToken, fetchImpl = fetch) {
840
+ try {
841
+ const url = new URL('/api/v1/capabilities/workspace', apiBase);
842
+ const res = await fetchImpl(url.toString(), {
843
+ headers: { 'Authorization': `Bearer ${accessToken}` },
844
+ });
845
+ if (!res.ok) return null;
846
+ const data = await res.json();
847
+ _workspaceCapabilities = data.data || data;
848
+ return _workspaceCapabilities;
849
+ } catch {
850
+ return null;
851
+ }
852
+ }
853
+
854
+ /**
855
+ * Get cached workspace capabilities.
856
+ */
857
+ export function getWorkspaceCapabilities() {
858
+ return _workspaceCapabilities;
859
+ }
860
+
861
+ /**
862
+ * Check if a specific capability is enabled.
863
+ */
864
+ export function hasCapability(name) {
865
+ if (!_workspaceCapabilities) return false;
866
+ const caps = _workspaceCapabilities.features || _workspaceCapabilities;
867
+ if (Array.isArray(caps)) return caps.includes(name);
868
+ return !!caps[name];
869
+ }
870
+
871
+ // ---------------------------------------------------------------------------
872
+ // Public API: connect and disconnect workspace
873
+ // ---------------------------------------------------------------------------
874
+
875
+ /**
876
+ * Run the full OAuth connect flow:
877
+ * 1. Open browser to authorization URL
878
+ * 2. Wait for callback with auth code
879
+ * 3. Exchange code for tokens
880
+ * 4. Store tokens securely
881
+ * 5. Fetch workspace capabilities
882
+ *
883
+ * @param {string} apiBase - The Adrata API base URL
884
+ * @param {object} [options]
885
+ * @param {boolean} [options.writeAccess=false] - Additionally request the
886
+ * opt-in CRM write scopes (OAUTH_WRITE_SCOPE). Off by default so the
887
+ * ordinary connection stays least-privilege.
888
+ * @returns {object} Connection result with workspace info
889
+ */
890
+ export async function connectWorkspace(apiBase, { writeAccess = false } = {}) {
891
+ const requestedScope = oauthScopeFor({ writeAccess });
892
+ const state = randomBytes(16).toString('hex');
893
+ const pkce = createPkcePair();
894
+
895
+ // Bind before registration so the client advertises only the callback URI
896
+ // this host can actually receive, which narrows the registered redirect
897
+ // surface to exactly one exact-match URI. See OAUTH_CALLBACK_ENDPOINTS for
898
+ // why that negotiation prefers the IPv4 loopback.
899
+ const callbackServer = await startCallbackServer(state);
900
+
901
+ // Prefer an explicitly provisioned client when an operator supplies one.
902
+ // Otherwise use RFC 7591 DCR so every installation gets a real public client
903
+ // instead of relying on a database seed or a secret embedded in npm.
904
+ const configuredClientId = process.env.ADRATA_MCP_CLIENT_ID?.trim();
905
+ let registration;
906
+ try {
907
+ registration = configuredClientId
908
+ ? { clientId: configuredClientId, tokenEndpointAuthMethod: 'none' }
909
+ : await registerNativeClient(apiBase, fetch, [callbackServer.redirectUri], requestedScope);
910
+ } catch (error) {
911
+ callbackServer.close();
912
+ throw error;
913
+ }
914
+ const clientId = registration.clientId;
915
+ const authUrl = buildAuthUrl(
916
+ apiBase,
917
+ clientId,
918
+ callbackServer.redirectUri,
919
+ state,
920
+ pkce,
921
+ requestedScope,
922
+ ).url;
923
+
924
+ if (process.env.ADRATA_MCP_PRINT_AUTH_URL === '1') {
925
+ console.error(`[adrata-mcp] Open this URL to authorize the workspace: ${authUrl}`);
926
+ }
927
+
928
+ // Step 2: Open browser
929
+ openBrowser(authUrl);
930
+
931
+ // Step 3: Wait for callback
932
+ const { code } = await callbackServer.callback;
933
+
934
+ // Step 4: Exchange code for tokens
935
+ const tokenResult = await exchangeCode(
936
+ apiBase,
937
+ code,
938
+ clientId,
939
+ pkce.verifier,
940
+ callbackServer.redirectUri,
941
+ );
942
+
943
+ // Step 5: Store tokens
944
+ const tokenData = {
945
+ accessToken: tokenResult.accessToken,
946
+ refreshToken: tokenResult.refreshToken,
947
+ expiresAt: new Date(Date.now() + (tokenResult.expiresIn || 3600) * 1000).toISOString(),
948
+ // The SERVER's granted scope wins. If the authorization server narrows the
949
+ // grant (unregistered client scope, consent declined), the stored scope
950
+ // must reflect what was actually granted -- never what we asked for.
951
+ scope: tokenResult.scope || requestedScope,
952
+ clientId,
953
+ clientRegistration: configuredClientId ? 'configured' : 'dynamic',
954
+ tokenEndpointAuthMethod: registration.tokenEndpointAuthMethod,
955
+ resource: canonicalResource(),
956
+ apiBase,
957
+ connectedAt: new Date().toISOString(),
958
+ };
959
+ saveTokens(tokenData);
960
+
961
+ // Step 6: Fetch workspace capabilities
962
+ const capabilities = await fetchWorkspaceCapabilities(apiBase, tokenData.accessToken);
963
+
964
+ // The message must match what the granted scope actually supports — never
965
+ // assert "enterprise tools are now available" when the scope this flow
966
+ // requests (OAUTH_SCOPE) is deliberately read-only. See
967
+ // describeOAuthScopeCapabilities above.
968
+ const grantedAccess = describeOAuthScopeCapabilities(tokenData.scope);
969
+
970
+ return {
971
+ connected: true,
972
+ scope: tokenData.scope,
973
+ expiresAt: tokenData.expiresAt,
974
+ capabilities: capabilities || 'Unable to fetch capabilities (may require workspace setup)',
975
+ grantedAccess: grantedAccess.summary,
976
+ message: grantedAccess.canWrite
977
+ ? 'Workspace connected successfully. Enterprise tools are now available.'
978
+ : 'Workspace connected successfully with read-only access. See grantedAccess for exactly what this unlocks — CRM writes, email sending, sequences, and admin actions still require a signed-in human session, not this OAuth connection.',
979
+ };
980
+ }
981
+
982
+ /**
983
+ * Disconnect the workspace: revoke tokens and clear local storage.
984
+ *
985
+ * @param {string} apiBase - The Adrata API base URL
986
+ * @returns {object} Disconnection result
987
+ */
988
+ export async function disconnectWorkspace(apiBase) {
989
+ const tokens = loadTokens();
990
+
991
+ if (tokens && tokens.accessToken) {
992
+ await revokeToken(apiBase, tokens.accessToken);
993
+ }
994
+
995
+ clearTokens();
996
+ _workspaceCapabilities = null;
997
+
998
+ return {
999
+ disconnected: true,
1000
+ message: 'Workspace disconnected. Tokens revoked and local credentials cleared. Enterprise tools are no longer available.',
1001
+ };
1002
+ }
1003
+
1004
+ /**
1005
+ * Get the current connection status.
1006
+ */
1007
+ export async function getConnectionStatus(apiBase) {
1008
+ const tokens = loadTokens();
1009
+ if (!tokens) {
1010
+ return {
1011
+ connected: false,
1012
+ message: 'No workspace connected. Use connect_workspace to set up OAuth.',
1013
+ };
1014
+ }
1015
+
1016
+ let accessToken;
1017
+ try {
1018
+ accessToken = await getValidToken(apiBase);
1019
+ } catch (err) {
1020
+ const reconnectRequired = err instanceof ReconnectRequiredError;
1021
+ return {
1022
+ connected: false,
1023
+ reconnectRequired,
1024
+ transient: err instanceof TransientRefreshError,
1025
+ storedCredentials: true,
1026
+ scope: tokens.scope,
1027
+ connectedAt: tokens.connectedAt,
1028
+ expiresAt: tokens.expiresAt,
1029
+ hasRefreshToken: !!tokens.refreshToken,
1030
+ message: reconnectRequired
1031
+ ? 'Stored workspace credentials expired or were revoked. Reconnect the workspace before using governed tools.'
1032
+ : 'Could not refresh the workspace session due to a temporary network issue. Try again in a moment; no reconnect needed.',
1033
+ };
1034
+ }
1035
+
1036
+ if (!accessToken) {
1037
+ return {
1038
+ connected: false,
1039
+ reconnectRequired: true,
1040
+ storedCredentials: true,
1041
+ scope: tokens.scope,
1042
+ connectedAt: tokens.connectedAt,
1043
+ expiresAt: tokens.expiresAt,
1044
+ hasRefreshToken: !!tokens.refreshToken,
1045
+ message: 'Stored workspace credentials expired or were revoked. Reconnect the workspace before using governed tools.',
1046
+ };
1047
+ }
1048
+
1049
+ const capabilities = await fetchWorkspaceCapabilities(apiBase, accessToken);
1050
+
1051
+ return {
1052
+ connected: true,
1053
+ scope: tokens.scope,
1054
+ connectedAt: tokens.connectedAt,
1055
+ expiresAt: tokens.expiresAt,
1056
+ hasRefreshToken: !!tokens.refreshToken,
1057
+ capabilities: capabilities || _workspaceCapabilities || 'Workspace session is valid; capabilities are temporarily unavailable.',
1058
+ };
1059
+ }