@unifiedmemory/cli 1.3.12 → 1.3.14
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/.env.example +18 -0
- package/README.md +28 -0
- package/commands/debug.js +67 -0
- package/commands/init.js +17 -8
- package/commands/login.js +138 -101
- package/commands/org.js +25 -177
- package/commands/record.js +14 -11
- package/commands/usage.js +163 -0
- package/index.js +37 -6
- package/lib/clerk-api.js +9 -12
- package/lib/config.js +21 -1
- package/lib/mcp-proxy.js +9 -10
- package/lib/mcp-server.js +139 -34
- package/lib/token-refresh.js +10 -24
- package/lib/token-storage.js +33 -27
- package/lib/token-validation.js +2 -85
- package/package.json +1 -1
- package/tests/unit/mcp-proxy.test.js +9 -6
- package/tests/unit/token-refresh.test.js +18 -44
- package/tests/unit/token-storage.test.js +1 -2
- package/tests/unit/token-validation.test.js +24 -241
package/lib/mcp-server.js
CHANGED
|
@@ -11,28 +11,32 @@ import path from 'path';
|
|
|
11
11
|
import os from 'os';
|
|
12
12
|
import { loadAndRefreshToken } from './token-validation.js';
|
|
13
13
|
import { fetchRemoteMCPTools, callRemoteMCPTool } from './mcp-proxy.js';
|
|
14
|
+
import { config } from './config.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Usage cache to avoid spamming quota API
|
|
18
|
+
* Map: orgId -> { data: QuotaResponse, timestamp: number }
|
|
19
|
+
*/
|
|
20
|
+
const usageCache = new Map();
|
|
21
|
+
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
22
|
+
const UPGRADE_THRESHOLD = 90; // Show prompt at 90%+
|
|
14
23
|
|
|
15
24
|
/**
|
|
16
25
|
* Start the MCP server on stdio transport
|
|
17
26
|
*/
|
|
18
27
|
export async function startMCPServer() {
|
|
19
28
|
try {
|
|
20
|
-
// 1.
|
|
21
|
-
|
|
29
|
+
// 1. Validate authentication at startup for immediate failure
|
|
30
|
+
await loadAndValidateAuth();
|
|
22
31
|
|
|
23
|
-
// 2. Load project context from current directory
|
|
32
|
+
// 2. Load project context from current directory (this doesn't change)
|
|
24
33
|
const projectContext = loadProjectContext();
|
|
25
34
|
|
|
26
|
-
//
|
|
27
|
-
|
|
35
|
+
// Note: We don't cache authData here anymore. Instead, we reload it
|
|
36
|
+
// on each request to ensure we always have the current token state,
|
|
37
|
+
// even after token refreshes.
|
|
28
38
|
|
|
29
|
-
//
|
|
30
|
-
const authContext = {
|
|
31
|
-
decoded: authData.decoded,
|
|
32
|
-
selectedOrg: authData.selectedOrg
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
// 5. Create MCP server
|
|
39
|
+
// 3. Create MCP server
|
|
36
40
|
const server = new Server(
|
|
37
41
|
{
|
|
38
42
|
name: "unifiedmemory",
|
|
@@ -46,22 +50,25 @@ export async function startMCPServer() {
|
|
|
46
50
|
}
|
|
47
51
|
);
|
|
48
52
|
|
|
49
|
-
//
|
|
53
|
+
// 4. Register handlers - reload auth on each request
|
|
50
54
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
55
|
+
const { authHeaders } = await loadFreshAuth(projectContext);
|
|
51
56
|
return await handleListTools(authHeaders, projectContext);
|
|
52
57
|
});
|
|
53
58
|
|
|
54
59
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
60
|
+
const { authHeaders, authContext } = await loadFreshAuth(projectContext);
|
|
55
61
|
return await handleCallTool(request, authHeaders, authContext, projectContext);
|
|
56
62
|
});
|
|
57
63
|
|
|
58
64
|
// Register resource handlers for authentication context
|
|
59
65
|
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
66
|
+
// Reload auth for fresh context
|
|
67
|
+
const authData = await loadAndRefreshToken();
|
|
60
68
|
const resources = [];
|
|
61
69
|
|
|
62
|
-
// Add org_id resource
|
|
63
|
-
|
|
64
|
-
if (orgId) {
|
|
70
|
+
// Add org_id resource only if JWT has org context
|
|
71
|
+
if (authData.decoded?.o?.o_id) {
|
|
65
72
|
resources.push({
|
|
66
73
|
uri: "um://context/org_id",
|
|
67
74
|
name: "Organization ID",
|
|
@@ -95,14 +102,17 @@ export async function startMCPServer() {
|
|
|
95
102
|
});
|
|
96
103
|
|
|
97
104
|
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
105
|
+
// Reload auth for fresh context
|
|
106
|
+
const authData = await loadAndRefreshToken();
|
|
98
107
|
const { uri } = request.params;
|
|
99
108
|
console.error(`→ Reading resource: ${uri}`);
|
|
100
109
|
|
|
101
110
|
switch(uri) {
|
|
102
111
|
case "um://context/org_id": {
|
|
103
|
-
|
|
112
|
+
// Only return org_id if JWT has org claims
|
|
113
|
+
const orgId = authData.decoded?.o?.o_id;
|
|
104
114
|
if (!orgId) {
|
|
105
|
-
throw new Error("Organization context not available");
|
|
115
|
+
throw new Error("Organization context not available (using personal account)");
|
|
106
116
|
}
|
|
107
117
|
return {
|
|
108
118
|
contents: [{
|
|
@@ -193,6 +203,7 @@ function loadProjectContext() {
|
|
|
193
203
|
|
|
194
204
|
/**
|
|
195
205
|
* Build authentication headers for gateway requests
|
|
206
|
+
* Checks JWT claims first, falls back to selectedOrgId from login
|
|
196
207
|
* @param {Object} authData - Token data
|
|
197
208
|
* @param {Object|null} projectContext - Project config
|
|
198
209
|
* @returns {Object} - Headers object
|
|
@@ -202,21 +213,22 @@ function buildAuthHeaders(authData, projectContext) {
|
|
|
202
213
|
'Authorization': `Bearer ${authData.idToken || authData.accessToken}`,
|
|
203
214
|
};
|
|
204
215
|
|
|
205
|
-
// Add
|
|
206
|
-
if (authData.selectedOrg) {
|
|
207
|
-
headers['X-Org-Id'] = authData.selectedOrg.id;
|
|
208
|
-
console.error(`✓ Organization: ${authData.selectedOrg.name} (${authData.selectedOrg.id})`);
|
|
209
|
-
} else if (authData.decoded?.sub) {
|
|
210
|
-
// Fallback to user ID for personal account
|
|
211
|
-
headers['X-Org-Id'] = authData.decoded.sub;
|
|
212
|
-
console.error(`✓ Using personal account (${authData.decoded.sub})`);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Add user ID from JWT
|
|
216
|
+
// Add user ID from JWT (always present)
|
|
216
217
|
if (authData.decoded?.sub) {
|
|
217
218
|
headers['X-User-Id'] = authData.decoded.sub;
|
|
218
219
|
}
|
|
219
220
|
|
|
221
|
+
// Add X-Org-Id - check JWT claims first, fall back to selectedOrgId
|
|
222
|
+
const orgId = authData.decoded?.o?.o_id || authData.selectedOrgId;
|
|
223
|
+
const orgName = authData.decoded?.o?.o_name || authData.selectedOrgName;
|
|
224
|
+
|
|
225
|
+
if (orgId) {
|
|
226
|
+
headers['X-Org-Id'] = orgId;
|
|
227
|
+
console.error(`✓ Organization: ${orgName || orgId}`);
|
|
228
|
+
} else {
|
|
229
|
+
console.error(`✓ Using personal account context`);
|
|
230
|
+
}
|
|
231
|
+
|
|
220
232
|
// Add project context if available
|
|
221
233
|
if (projectContext) {
|
|
222
234
|
headers['X-Project-Id'] = projectContext.project_id;
|
|
@@ -226,6 +238,93 @@ function buildAuthHeaders(authData, projectContext) {
|
|
|
226
238
|
return headers;
|
|
227
239
|
}
|
|
228
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Load fresh authentication data and build headers
|
|
243
|
+
* Called on each MCP request to ensure current token state
|
|
244
|
+
* Checks JWT claims first, falls back to selectedOrgId from login
|
|
245
|
+
* @param {Object|null} projectContext - Project config
|
|
246
|
+
* @returns {Promise<{authHeaders: Object, authContext: Object}>}
|
|
247
|
+
*/
|
|
248
|
+
async function loadFreshAuth(projectContext) {
|
|
249
|
+
const authData = await loadAndRefreshToken();
|
|
250
|
+
const authHeaders = buildAuthHeaders(authData, projectContext);
|
|
251
|
+
|
|
252
|
+
// Build auth context - check JWT claims first, fall back to selectedOrgId
|
|
253
|
+
const jwtOrgId = authData.decoded?.o?.o_id;
|
|
254
|
+
const orgId = jwtOrgId || authData.selectedOrgId;
|
|
255
|
+
const orgName = authData.decoded?.o?.o_name || authData.selectedOrgName;
|
|
256
|
+
const orgRole = authData.decoded?.o?.o_role;
|
|
257
|
+
|
|
258
|
+
const authContext = {
|
|
259
|
+
decoded: authData.decoded,
|
|
260
|
+
orgContext: orgId ? {
|
|
261
|
+
id: orgId,
|
|
262
|
+
name: orgName,
|
|
263
|
+
role: orgRole,
|
|
264
|
+
} : null
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
return { authHeaders, authContext };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Check quota usage and return upgrade prompt if needed
|
|
272
|
+
* Uses caching to avoid API spam (5-min TTL)
|
|
273
|
+
*/
|
|
274
|
+
async function checkAndGetUpgradePrompt(authHeaders, authContext) {
|
|
275
|
+
try {
|
|
276
|
+
// Use org from JWT claims, or userId for personal context
|
|
277
|
+
const orgId = authContext?.orgContext?.id || authContext?.decoded?.sub;
|
|
278
|
+
if (!orgId) return null;
|
|
279
|
+
|
|
280
|
+
// Check cache
|
|
281
|
+
const cached = usageCache.get(orgId);
|
|
282
|
+
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
|
283
|
+
return formatUpgradePrompt(cached.data, authContext);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Fetch fresh data
|
|
287
|
+
const response = await fetch(`${config.apiEndpoint}/v1/quota/usage`, {
|
|
288
|
+
headers: authHeaders
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
if (!response.ok) {
|
|
292
|
+
console.error(`⚠️ Quota check failed: ${response.status}`);
|
|
293
|
+
return null; // Fail silently, don't break MCP
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const data = await response.json();
|
|
297
|
+
|
|
298
|
+
// Update cache
|
|
299
|
+
usageCache.set(orgId, { data, timestamp: Date.now() });
|
|
300
|
+
|
|
301
|
+
return formatUpgradePrompt(data, authContext);
|
|
302
|
+
|
|
303
|
+
} catch (error) {
|
|
304
|
+
console.error(`⚠️ Quota check error: ${error.message}`);
|
|
305
|
+
return null; // Fail silently
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Format upgrade prompt if usage >= threshold
|
|
311
|
+
*/
|
|
312
|
+
function formatUpgradePrompt(quotaData, authContext) {
|
|
313
|
+
const { percentage_used, used, total } = quotaData.usage;
|
|
314
|
+
|
|
315
|
+
if (percentage_used < UPGRADE_THRESHOLD) return null;
|
|
316
|
+
|
|
317
|
+
const isOrg = quotaData.quota_type === 'organization';
|
|
318
|
+
const contextName = isOrg
|
|
319
|
+
? authContext?.orgContext?.name
|
|
320
|
+
: 'your account';
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
type: "text",
|
|
324
|
+
text: `\n---\n⚠️ Quota Alert: ${contextName} is at ${percentage_used.toFixed(1)}% capacity (${used.toLocaleString()}/${total.toLocaleString()} queries).\nUpgrade your plan at https://unifiedmemory.ai/pricing to unlock more capacity.\n---`
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
229
328
|
/**
|
|
230
329
|
* Handle tools/list request
|
|
231
330
|
* @param {Object} authHeaders - Auth headers
|
|
@@ -260,12 +359,18 @@ async function handleCallTool(request, authHeaders, authContext, projectContext)
|
|
|
260
359
|
const result = await callRemoteMCPTool(name, args, authHeaders, authContext, projectContext);
|
|
261
360
|
console.error(`✓ Tool executed successfully: ${name}`);
|
|
262
361
|
|
|
362
|
+
// Check if upgrade prompt needed (cached, 5-min TTL)
|
|
363
|
+
const upgradePrompt = await checkAndGetUpgradePrompt(authHeaders, authContext);
|
|
364
|
+
|
|
263
365
|
return {
|
|
264
|
-
content:
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
366
|
+
content: [
|
|
367
|
+
...(result.content || [
|
|
368
|
+
{
|
|
369
|
+
type: "text",
|
|
370
|
+
text: JSON.stringify(result, null, 2),
|
|
371
|
+
},
|
|
372
|
+
]),
|
|
373
|
+
...(upgradePrompt ? [upgradePrompt] : [])
|
|
269
374
|
],
|
|
270
375
|
};
|
|
271
376
|
} catch (error) {
|
package/lib/token-refresh.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { saveToken } from './token-storage.js';
|
|
2
2
|
import { config } from './config.js';
|
|
3
3
|
import { parseJWT } from './jwt-utils.js';
|
|
4
|
-
import { getOrgScopedToken } from './clerk-api.js';
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
6
|
* Check if token has expired
|
|
@@ -23,6 +22,7 @@ export function isTokenExpired(tokenData) {
|
|
|
23
22
|
|
|
24
23
|
/**
|
|
25
24
|
* Refresh access token using refresh token
|
|
25
|
+
* Simple refresh - Clerk preserves org context if it was set
|
|
26
26
|
* @param {Object} tokenData - Current token data
|
|
27
27
|
* @returns {Promise<Object>} - New token data
|
|
28
28
|
*/
|
|
@@ -74,36 +74,22 @@ export async function refreshAccessToken(tokenData) {
|
|
|
74
74
|
|
|
75
75
|
// Parse refreshed JWT
|
|
76
76
|
const refreshedToken = newTokenData.id_token || newTokenData.access_token;
|
|
77
|
-
|
|
78
|
-
let decoded = parseJWT(refreshedToken);
|
|
77
|
+
const decoded = parseJWT(refreshedToken);
|
|
79
78
|
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const orgToken = await getOrgScopedToken(
|
|
84
|
-
decoded.sid,
|
|
85
|
-
tokenData.selectedOrg.id,
|
|
86
|
-
refreshedToken
|
|
87
|
-
);
|
|
88
|
-
finalIdToken = orgToken.jwt;
|
|
89
|
-
decoded = parseJWT(orgToken.jwt);
|
|
90
|
-
} catch (error) {
|
|
91
|
-
// Log warning but continue with base token
|
|
92
|
-
// The subsequent API call may fail, prompting re-login
|
|
93
|
-
console.error(`⚠️ Could not refresh org-scoped token: ${error.message}`);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Build updated token object, preserving selectedOrg
|
|
79
|
+
// Build updated token object
|
|
80
|
+
// Note: Clerk preserves org context through refresh automatically
|
|
81
|
+
// We also preserve selectedOrgId/selectedOrgName from login state
|
|
98
82
|
const updatedToken = {
|
|
99
83
|
accessToken: newTokenData.access_token,
|
|
100
|
-
idToken:
|
|
84
|
+
idToken: newTokenData.id_token,
|
|
101
85
|
tokenType: newTokenData.token_type || 'Bearer',
|
|
102
86
|
expiresIn: newTokenData.expires_in,
|
|
103
87
|
receivedAt: Date.now(),
|
|
104
88
|
decoded: decoded,
|
|
105
|
-
selectedOrg: tokenData.selectedOrg, // Preserve organization context
|
|
106
89
|
refresh_token: newTokenData.refresh_token || tokenData.refresh_token,
|
|
90
|
+
sessionId: tokenData.sessionId, // Preserve sessionId through refresh
|
|
91
|
+
selectedOrgId: tokenData.selectedOrgId, // Preserve org selection through refresh
|
|
92
|
+
selectedOrgName: tokenData.selectedOrgName, // Preserve org name through refresh
|
|
107
93
|
};
|
|
108
94
|
|
|
109
95
|
// Save to storage
|
package/lib/token-storage.js
CHANGED
|
@@ -14,12 +14,6 @@ export function saveToken(tokenData) {
|
|
|
14
14
|
// Ensure directory permissions are correct (in case it was created with wrong permissions)
|
|
15
15
|
fs.chmodSync(TOKEN_DIR, 0o700);
|
|
16
16
|
|
|
17
|
-
// Preserve existing organization context if not explicitly overwritten
|
|
18
|
-
const existingData = getToken();
|
|
19
|
-
if (existingData && existingData.selectedOrg && !tokenData.selectedOrg) {
|
|
20
|
-
tokenData.selectedOrg = existingData.selectedOrg;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
17
|
// Write token file with owner-only read/write permissions (0600)
|
|
24
18
|
fs.writeFileSync(TOKEN_FILE, JSON.stringify(tokenData, null, 2));
|
|
25
19
|
// Explicitly set file permissions (writeFileSync mode option doesn't always work with extended attributes)
|
|
@@ -46,31 +40,43 @@ export function clearToken() {
|
|
|
46
40
|
}
|
|
47
41
|
|
|
48
42
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
43
|
+
* Get organization context from JWT claims
|
|
44
|
+
* JWT is the single source of truth for org context
|
|
45
|
+
* @returns {Object|null} Organization context or null if personal context
|
|
51
46
|
*/
|
|
52
|
-
export function
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
47
|
+
export function getOrgContext() {
|
|
48
|
+
const token = getToken();
|
|
49
|
+
|
|
50
|
+
// Try nested 'o' object first (original OAuth token format)
|
|
51
|
+
if (token?.decoded?.o?.o_id) {
|
|
52
|
+
return {
|
|
53
|
+
id: token.decoded.o.o_id,
|
|
54
|
+
name: token.decoded.o.o_name,
|
|
55
|
+
slug: token.decoded.o.o_slug,
|
|
56
|
+
role: token.decoded.o.o_role
|
|
57
|
+
};
|
|
56
58
|
}
|
|
57
59
|
|
|
58
|
-
//
|
|
59
|
-
if (
|
|
60
|
-
|
|
60
|
+
// Fall back to flat org_* claims (org-scoped token from Clerk)
|
|
61
|
+
if (token?.decoded?.org_id) {
|
|
62
|
+
return {
|
|
63
|
+
id: token.decoded.org_id,
|
|
64
|
+
name: token.decoded.org_name,
|
|
65
|
+
slug: token.decoded.org_slug,
|
|
66
|
+
role: token.decoded.org_role
|
|
67
|
+
};
|
|
61
68
|
}
|
|
62
69
|
|
|
63
|
-
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
// Fall back to selectedOrgId from login state parameter
|
|
71
|
+
// This is used when JWT doesn't have org claims (Clerk setActive limitation)
|
|
72
|
+
if (token?.selectedOrgId) {
|
|
73
|
+
return {
|
|
74
|
+
id: token.selectedOrgId,
|
|
75
|
+
name: token.selectedOrgName || null,
|
|
76
|
+
slug: null,
|
|
77
|
+
role: null
|
|
78
|
+
};
|
|
79
|
+
}
|
|
68
80
|
|
|
69
|
-
|
|
70
|
-
* Get the currently selected organization context
|
|
71
|
-
* @returns {Object|null} Organization data or null if personal context
|
|
72
|
-
*/
|
|
73
|
-
export function getSelectedOrg() {
|
|
74
|
-
const tokenData = getToken();
|
|
75
|
-
return tokenData?.selectedOrg || null;
|
|
81
|
+
return null;
|
|
76
82
|
}
|
package/lib/token-validation.js
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
import { getToken
|
|
1
|
+
import { getToken } from './token-storage.js';
|
|
2
2
|
import { isTokenExpired, refreshAccessToken } from './token-refresh.js';
|
|
3
|
-
import { getOrgScopedToken } from './clerk-api.js';
|
|
4
|
-
import { parseJWT } from './jwt-utils.js';
|
|
5
|
-
import { config } from './config.js';
|
|
6
3
|
|
|
7
4
|
/**
|
|
8
5
|
* Load token and refresh if expired
|
|
6
|
+
* JWT is the single source of truth - no recovery logic needed
|
|
9
7
|
* @param {boolean} requireAuth - Whether to throw if not authenticated (default true)
|
|
10
8
|
* @returns {Promise<Object|null>} - Token data or null
|
|
11
9
|
* @throws {Error} - If not authenticated and requireAuth is true, or if refresh fails
|
|
@@ -46,86 +44,5 @@ export async function loadAndRefreshToken(requireAuth = true) {
|
|
|
46
44
|
}
|
|
47
45
|
}
|
|
48
46
|
|
|
49
|
-
// Check if token has org context but lacks org claims in JWT
|
|
50
|
-
// This can happen if getOrgScopedToken() failed during login
|
|
51
|
-
if (tokenData.selectedOrg?.id && !tokenData.decoded?.o) {
|
|
52
|
-
console.error('⚠️ Token missing org claims, attempting to fix...');
|
|
53
|
-
|
|
54
|
-
// Check for sid in decoded token or preserved originalSid from login
|
|
55
|
-
let sessionId = tokenData.decoded?.sid || tokenData.originalSid;
|
|
56
|
-
let currentToken = tokenData.idToken || tokenData.accessToken;
|
|
57
|
-
|
|
58
|
-
// If no sid, try to refresh first to get a new base token with sid
|
|
59
|
-
if (!sessionId && tokenData.refresh_token) {
|
|
60
|
-
console.error(' Refreshing to obtain session ID...');
|
|
61
|
-
try {
|
|
62
|
-
const tokenParams = {
|
|
63
|
-
client_id: config.clerkClientId,
|
|
64
|
-
refresh_token: tokenData.refresh_token,
|
|
65
|
-
grant_type: 'refresh_token',
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
if (config.clerkClientSecret) {
|
|
69
|
-
tokenParams.client_secret = config.clerkClientSecret;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const response = await fetch(`https://${config.clerkDomain}/oauth/token`, {
|
|
73
|
-
method: 'POST',
|
|
74
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
75
|
-
body: new URLSearchParams(tokenParams),
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
if (response.ok) {
|
|
79
|
-
const newTokenData = await response.json();
|
|
80
|
-
const refreshedToken = newTokenData.id_token || newTokenData.access_token;
|
|
81
|
-
const refreshedDecoded = parseJWT(refreshedToken);
|
|
82
|
-
|
|
83
|
-
sessionId = refreshedDecoded?.sid;
|
|
84
|
-
currentToken = refreshedToken;
|
|
85
|
-
|
|
86
|
-
// Update tokenData with refreshed values
|
|
87
|
-
tokenData.accessToken = newTokenData.access_token;
|
|
88
|
-
tokenData.idToken = newTokenData.id_token;
|
|
89
|
-
tokenData.refresh_token = newTokenData.refresh_token || tokenData.refresh_token;
|
|
90
|
-
tokenData.decoded = refreshedDecoded;
|
|
91
|
-
|
|
92
|
-
if (sessionId) {
|
|
93
|
-
console.error(' ✓ Obtained session ID');
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
} catch (error) {
|
|
97
|
-
console.error(` Could not refresh token: ${error.message}`);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Now try to get org-scoped token if we have sessionId
|
|
102
|
-
if (sessionId) {
|
|
103
|
-
try {
|
|
104
|
-
const orgToken = await getOrgScopedToken(
|
|
105
|
-
sessionId,
|
|
106
|
-
tokenData.selectedOrg.id,
|
|
107
|
-
currentToken
|
|
108
|
-
);
|
|
109
|
-
|
|
110
|
-
const decoded = parseJWT(orgToken.jwt);
|
|
111
|
-
const updatedToken = {
|
|
112
|
-
...tokenData,
|
|
113
|
-
idToken: orgToken.jwt,
|
|
114
|
-
decoded: decoded,
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
saveToken(updatedToken);
|
|
118
|
-
console.error('✓ Token updated with org claims');
|
|
119
|
-
return updatedToken;
|
|
120
|
-
} catch (error) {
|
|
121
|
-
console.error(`⚠️ Could not get org-scoped token: ${error.message}`);
|
|
122
|
-
// Continue with existing token - API calls may fail
|
|
123
|
-
}
|
|
124
|
-
} else {
|
|
125
|
-
console.error('⚠️ Could not obtain session ID for org-scoped token');
|
|
126
|
-
console.error(' Please run: um login');
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
47
|
return tokenData;
|
|
131
48
|
}
|
package/package.json
CHANGED
|
@@ -221,7 +221,7 @@ describe('mcp-proxy', () => {
|
|
|
221
221
|
|
|
222
222
|
const authContext = {
|
|
223
223
|
decoded: { sub: 'user_123' },
|
|
224
|
-
|
|
224
|
+
orgContext: { id: 'org_456' },
|
|
225
225
|
};
|
|
226
226
|
|
|
227
227
|
const projectContext = {
|
|
@@ -245,7 +245,7 @@ describe('mcp-proxy', () => {
|
|
|
245
245
|
expect(capturedBody.params.arguments.headers['X-Org-Id']).toBe('org_456');
|
|
246
246
|
});
|
|
247
247
|
|
|
248
|
-
it('should
|
|
248
|
+
it('should not inject org when no orgContext (personal context)', async () => {
|
|
249
249
|
let capturedBody = null;
|
|
250
250
|
|
|
251
251
|
server.use(
|
|
@@ -263,7 +263,7 @@ describe('mcp-proxy', () => {
|
|
|
263
263
|
|
|
264
264
|
const authContext = {
|
|
265
265
|
decoded: { sub: 'user_123' },
|
|
266
|
-
// No
|
|
266
|
+
// No orgContext - personal context
|
|
267
267
|
};
|
|
268
268
|
|
|
269
269
|
await callRemoteMCPTool(
|
|
@@ -274,9 +274,12 @@ describe('mcp-proxy', () => {
|
|
|
274
274
|
null
|
|
275
275
|
);
|
|
276
276
|
|
|
277
|
-
//
|
|
278
|
-
expect(capturedBody.params.arguments.pathParams.org).
|
|
279
|
-
expect(capturedBody.params.arguments.headers['X-Org-Id']).
|
|
277
|
+
// No org injection for personal context - gateway handles this
|
|
278
|
+
expect(capturedBody.params.arguments.pathParams.org).toBeUndefined();
|
|
279
|
+
expect(capturedBody.params.arguments.headers['X-Org-Id']).toBeUndefined();
|
|
280
|
+
// User should still be injected
|
|
281
|
+
expect(capturedBody.params.arguments.pathParams.user).toBe('user_123');
|
|
282
|
+
expect(capturedBody.params.arguments.headers['X-User-Id']).toBe('user_123');
|
|
280
283
|
});
|
|
281
284
|
|
|
282
285
|
it('should return tool execution result', async () => {
|
|
@@ -44,17 +44,9 @@ vi.mock('../../lib/jwt-utils.js', () => ({
|
|
|
44
44
|
}),
|
|
45
45
|
}));
|
|
46
46
|
|
|
47
|
-
vi.mock('../../lib/clerk-api.js', () => ({
|
|
48
|
-
getOrgScopedToken: vi.fn().mockResolvedValue({
|
|
49
|
-
jwt: 'org_scoped_jwt',
|
|
50
|
-
object: 'token',
|
|
51
|
-
}),
|
|
52
|
-
}));
|
|
53
|
-
|
|
54
47
|
// Import after mocking
|
|
55
48
|
import { isTokenExpired, refreshAccessToken } from '../../lib/token-refresh.js';
|
|
56
49
|
import { saveToken } from '../../lib/token-storage.js';
|
|
57
|
-
import { getOrgScopedToken } from '../../lib/clerk-api.js';
|
|
58
50
|
|
|
59
51
|
describe('isTokenExpired', () => {
|
|
60
52
|
it('returns true for null tokenData', () => {
|
|
@@ -181,7 +173,7 @@ describe('refreshAccessToken', () => {
|
|
|
181
173
|
);
|
|
182
174
|
});
|
|
183
175
|
|
|
184
|
-
it('preserves
|
|
176
|
+
it('preserves selectedOrgId and selectedOrgName in refreshed token', async () => {
|
|
185
177
|
global.fetch = vi.fn().mockResolvedValue({
|
|
186
178
|
ok: true,
|
|
187
179
|
json: () => Promise.resolve({
|
|
@@ -193,60 +185,42 @@ describe('refreshAccessToken', () => {
|
|
|
193
185
|
}),
|
|
194
186
|
});
|
|
195
187
|
|
|
196
|
-
|
|
197
|
-
|
|
188
|
+
await refreshAccessToken({
|
|
189
|
+
refresh_token: 'rt_123',
|
|
190
|
+
selectedOrgId: 'org_456',
|
|
191
|
+
selectedOrgName: 'Test Org',
|
|
192
|
+
});
|
|
198
193
|
|
|
199
194
|
expect(saveToken).toHaveBeenCalledWith(
|
|
200
195
|
expect.objectContaining({
|
|
201
|
-
|
|
196
|
+
selectedOrgId: 'org_456',
|
|
197
|
+
selectedOrgName: 'Test Org',
|
|
202
198
|
})
|
|
203
199
|
);
|
|
204
200
|
});
|
|
205
201
|
|
|
206
|
-
it('
|
|
202
|
+
it('preserves sessionId through refresh', async () => {
|
|
207
203
|
global.fetch = vi.fn().mockResolvedValue({
|
|
208
204
|
ok: true,
|
|
209
205
|
json: () => Promise.resolve({
|
|
210
206
|
access_token: 'new_access_token',
|
|
211
|
-
id_token: '
|
|
207
|
+
id_token: 'new_id_token',
|
|
212
208
|
refresh_token: 'new_refresh_token',
|
|
213
209
|
token_type: 'Bearer',
|
|
214
210
|
expires_in: 3600,
|
|
215
211
|
}),
|
|
216
212
|
});
|
|
217
213
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
expect(getOrgScopedToken).toHaveBeenCalledWith(
|
|
222
|
-
'sess_new_123',
|
|
223
|
-
'org_456',
|
|
224
|
-
'new_id_token_with_sid'
|
|
225
|
-
);
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
it('continues with base token when getOrgScopedToken fails', async () => {
|
|
229
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
230
|
-
ok: true,
|
|
231
|
-
json: () => Promise.resolve({
|
|
232
|
-
access_token: 'new_access_token',
|
|
233
|
-
id_token: 'new_id_token_with_sid',
|
|
234
|
-
refresh_token: 'new_refresh_token',
|
|
235
|
-
token_type: 'Bearer',
|
|
236
|
-
expires_in: 3600,
|
|
237
|
-
}),
|
|
214
|
+
await refreshAccessToken({
|
|
215
|
+
refresh_token: 'rt_123',
|
|
216
|
+
sessionId: 'sess_123',
|
|
238
217
|
});
|
|
239
218
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
// Should not throw - continues with base token
|
|
246
|
-
const result = await refreshAccessToken({ refresh_token: 'rt_123', selectedOrg });
|
|
247
|
-
|
|
248
|
-
expect(result).toBeDefined();
|
|
249
|
-
expect(result.idToken).toBe('new_id_token_with_sid'); // Falls back to base token
|
|
219
|
+
expect(saveToken).toHaveBeenCalledWith(
|
|
220
|
+
expect.objectContaining({
|
|
221
|
+
sessionId: 'sess_123',
|
|
222
|
+
})
|
|
223
|
+
);
|
|
250
224
|
});
|
|
251
225
|
|
|
252
226
|
it('throws error when OAuth refresh fails with 400', async () => {
|
|
@@ -132,7 +132,6 @@ describe('token-storage module behavior', () => {
|
|
|
132
132
|
expect(typeof ts.saveToken).toBe('function');
|
|
133
133
|
expect(typeof ts.getToken).toBe('function');
|
|
134
134
|
expect(typeof ts.clearToken).toBe('function');
|
|
135
|
-
expect(typeof ts.
|
|
136
|
-
expect(typeof ts.getSelectedOrg).toBe('function');
|
|
135
|
+
expect(typeof ts.getOrgContext).toBe('function');
|
|
137
136
|
});
|
|
138
137
|
});
|