@unifiedmemory/cli 1.3.13 ā 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 +10 -0
- package/README.md +28 -0
- package/commands/debug.js +67 -0
- package/commands/init.js +17 -8
- package/commands/login.js +46 -94
- 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 +11 -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
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { loadAndRefreshToken } from '../lib/token-validation.js';
|
|
3
|
+
import { config } from '../lib/config.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Show current usage and quota allowances
|
|
7
|
+
*/
|
|
8
|
+
export async function usage() {
|
|
9
|
+
// Load token and refresh if expired
|
|
10
|
+
const tokenData = await loadAndRefreshToken();
|
|
11
|
+
|
|
12
|
+
const accessToken = tokenData.idToken || tokenData.accessToken;
|
|
13
|
+
|
|
14
|
+
if (!accessToken) {
|
|
15
|
+
console.error(chalk.red('ā Not authenticated'));
|
|
16
|
+
console.log(chalk.gray('Run `um login` to authenticate'));
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
console.log(chalk.blue('\nš Usage & Quota\n'));
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
// Build headers with org context (selectedOrgId fallback for when JWT has no org claims)
|
|
24
|
+
const userId = tokenData.decoded?.sub;
|
|
25
|
+
const orgId = tokenData.decoded?.o?.o_id || tokenData.selectedOrgId;
|
|
26
|
+
|
|
27
|
+
const headers = {
|
|
28
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
29
|
+
};
|
|
30
|
+
if (userId) headers['X-User-Id'] = userId;
|
|
31
|
+
if (orgId) headers['X-Org-Id'] = orgId;
|
|
32
|
+
|
|
33
|
+
// Fetch quota usage from API gateway
|
|
34
|
+
const response = await fetch(`${config.apiEndpoint}/v1/quota/usage`, { headers });
|
|
35
|
+
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
if (response.status === 401) {
|
|
38
|
+
console.error(chalk.red('ā Authentication failed'));
|
|
39
|
+
console.log(chalk.gray('Run `um login` to re-authenticate'));
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`API returned ${response.status}: ${response.statusText}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const data = await response.json();
|
|
46
|
+
displayUsage(data, tokenData);
|
|
47
|
+
|
|
48
|
+
} catch (error) {
|
|
49
|
+
console.error(chalk.red('ā Failed to fetch usage data:'), error.message);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Display formatted usage information
|
|
56
|
+
*/
|
|
57
|
+
function displayUsage(quotaData, tokenData) {
|
|
58
|
+
const { quota_type, usage, period } = quotaData;
|
|
59
|
+
const { used, total, percentage_used, remaining } = usage;
|
|
60
|
+
|
|
61
|
+
// Display account context (use selectedOrgName fallback for when JWT has no org claims)
|
|
62
|
+
console.log(chalk.yellow('Account:'));
|
|
63
|
+
if (quota_type === 'organization') {
|
|
64
|
+
// Get org name from JWT claims, fallback to selectedOrgName from login state
|
|
65
|
+
const orgName = tokenData.decoded?.o?.o_name || tokenData.selectedOrgName || 'Organization';
|
|
66
|
+
console.log(chalk.white(` Organization: ${orgName}`));
|
|
67
|
+
} else {
|
|
68
|
+
console.log(chalk.white(' Personal Account'));
|
|
69
|
+
}
|
|
70
|
+
console.log('');
|
|
71
|
+
|
|
72
|
+
// Handle special cases
|
|
73
|
+
if (total === 0) {
|
|
74
|
+
console.log(chalk.yellow('Monthly Queries:'));
|
|
75
|
+
console.log(chalk.yellow(' No active subscription'));
|
|
76
|
+
console.log(chalk.gray(' Start at: https://unifiedmemory.ai/pricing'));
|
|
77
|
+
console.log('');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (total < 0 || total > 1000000) {
|
|
82
|
+
console.log(chalk.yellow('Monthly Queries:'));
|
|
83
|
+
console.log(chalk.green(' Unlimited ā¾ļø'));
|
|
84
|
+
console.log('');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Determine color based on usage percentage
|
|
89
|
+
const color = getColorForUsage(percentage_used);
|
|
90
|
+
const colorFn = getChalkFunction(color);
|
|
91
|
+
|
|
92
|
+
// Display usage count and percentage
|
|
93
|
+
console.log(chalk.yellow('Monthly Queries:'));
|
|
94
|
+
console.log(colorFn(` ${used.toLocaleString()} / ${total.toLocaleString()} (${percentage_used.toFixed(1)}%)`));
|
|
95
|
+
|
|
96
|
+
// Display progress bar
|
|
97
|
+
const progressBar = buildProgressBar(percentage_used, 20);
|
|
98
|
+
console.log(colorFn(` [${progressBar}]`));
|
|
99
|
+
|
|
100
|
+
// Display remaining quota
|
|
101
|
+
console.log(colorFn(` Remaining: ${remaining.toLocaleString()} queries`));
|
|
102
|
+
|
|
103
|
+
// Display reset date if available
|
|
104
|
+
if (period?.next_reset_date) {
|
|
105
|
+
const resetDate = new Date(period.next_reset_date);
|
|
106
|
+
const formattedDate = resetDate.toLocaleDateString('en-US', {
|
|
107
|
+
month: 'numeric',
|
|
108
|
+
day: 'numeric',
|
|
109
|
+
year: 'numeric'
|
|
110
|
+
});
|
|
111
|
+
console.log(chalk.gray(` Resets: ${formattedDate}`));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
console.log('');
|
|
115
|
+
|
|
116
|
+
// Show upgrade prompt if at or above threshold
|
|
117
|
+
if (percentage_used >= 90) {
|
|
118
|
+
console.log(chalk.red('ā ļø Running low on quota!'));
|
|
119
|
+
console.log(chalk.gray('Upgrade your plan: https://unifiedmemory.ai/pricing'));
|
|
120
|
+
console.log('');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Get color category based on usage percentage
|
|
126
|
+
* @param {number} percentage - Usage percentage (0-100)
|
|
127
|
+
* @returns {string} - Color category: 'green', 'yellow', or 'red'
|
|
128
|
+
*/
|
|
129
|
+
function getColorForUsage(percentage) {
|
|
130
|
+
if (percentage >= 90) return 'red';
|
|
131
|
+
if (percentage >= 70) return 'yellow';
|
|
132
|
+
return 'green';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get chalk color function based on color name
|
|
137
|
+
* @param {string} color - Color name
|
|
138
|
+
* @returns {Function} - Chalk color function
|
|
139
|
+
*/
|
|
140
|
+
function getChalkFunction(color) {
|
|
141
|
+
switch (color) {
|
|
142
|
+
case 'red':
|
|
143
|
+
return chalk.red;
|
|
144
|
+
case 'yellow':
|
|
145
|
+
return chalk.yellow;
|
|
146
|
+
case 'green':
|
|
147
|
+
return chalk.green;
|
|
148
|
+
default:
|
|
149
|
+
return chalk.white;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Build a text-based progress bar
|
|
155
|
+
* @param {number} percentage - Usage percentage (0-100)
|
|
156
|
+
* @param {number} length - Total length of the bar in characters
|
|
157
|
+
* @returns {string} - Progress bar string
|
|
158
|
+
*/
|
|
159
|
+
function buildProgressBar(percentage, length) {
|
|
160
|
+
const filled = Math.round((percentage / 100) * length);
|
|
161
|
+
const empty = length - filled;
|
|
162
|
+
return 'ā'.repeat(filled) + 'ā'.repeat(empty);
|
|
163
|
+
}
|
package/index.js
CHANGED
|
@@ -11,8 +11,10 @@ import { login } from './commands/login.js';
|
|
|
11
11
|
import { init } from './commands/init.js';
|
|
12
12
|
import { switchOrg, showOrg, fixOrg } from './commands/org.js';
|
|
13
13
|
import { record } from './commands/record.js';
|
|
14
|
+
import { usage } from './commands/usage.js';
|
|
15
|
+
import { debugAuth } from './commands/debug.js';
|
|
14
16
|
import { config } from './lib/config.js';
|
|
15
|
-
import {
|
|
17
|
+
import { getOrgContext } from './lib/token-storage.js';
|
|
16
18
|
import { loadAndRefreshToken } from './lib/token-validation.js';
|
|
17
19
|
import { showWelcome } from './lib/welcome.js';
|
|
18
20
|
|
|
@@ -74,7 +76,8 @@ program
|
|
|
74
76
|
try {
|
|
75
77
|
// Try to load and refresh token if expired
|
|
76
78
|
const tokenData = await loadAndRefreshToken(false);
|
|
77
|
-
|
|
79
|
+
// Get org context from JWT claims (single source of truth)
|
|
80
|
+
const orgContext = getOrgContext();
|
|
78
81
|
|
|
79
82
|
console.log(chalk.blue('\nš UnifiedMemory Status\n'));
|
|
80
83
|
|
|
@@ -94,10 +97,10 @@ program
|
|
|
94
97
|
}
|
|
95
98
|
|
|
96
99
|
console.log(chalk.yellow('\nOrganization Context:'));
|
|
97
|
-
if (
|
|
98
|
-
console.log(chalk.green(` ${
|
|
99
|
-
console.log(chalk.gray(` ID: ${
|
|
100
|
-
console.log(chalk.gray(` Role: ${
|
|
100
|
+
if (orgContext) {
|
|
101
|
+
console.log(chalk.green(` ${orgContext.name}${orgContext.slug ? ` (${orgContext.slug})` : ''}`));
|
|
102
|
+
console.log(chalk.gray(` ID: ${orgContext.id}`));
|
|
103
|
+
console.log(chalk.gray(` Role: ${orgContext.role || 'member'}`));
|
|
101
104
|
} else {
|
|
102
105
|
console.log(chalk.cyan(' Personal Account'));
|
|
103
106
|
}
|
|
@@ -128,6 +131,34 @@ program
|
|
|
128
131
|
}
|
|
129
132
|
});
|
|
130
133
|
|
|
134
|
+
// Usage command
|
|
135
|
+
program
|
|
136
|
+
.command('usage')
|
|
137
|
+
.description('Show current usage and quota allowances')
|
|
138
|
+
.action(async () => {
|
|
139
|
+
try {
|
|
140
|
+
await usage();
|
|
141
|
+
process.exit(0);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
console.error(chalk.red('Failed to show usage:'), error.message);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Debug command
|
|
149
|
+
program
|
|
150
|
+
.command('debug')
|
|
151
|
+
.description('Debug authentication and token issues')
|
|
152
|
+
.action(async () => {
|
|
153
|
+
try {
|
|
154
|
+
await debugAuth();
|
|
155
|
+
process.exit(0);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
console.error(chalk.red('Debug failed:'), error.message);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
131
162
|
// Organization management
|
|
132
163
|
const orgCommand = program
|
|
133
164
|
.command('org')
|
package/lib/clerk-api.js
CHANGED
|
@@ -121,30 +121,27 @@ export function formatOrganization(membership) {
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
/**
|
|
124
|
-
* Get
|
|
124
|
+
* Get a JWT token with specific org context via backend API
|
|
125
125
|
* @param {string} sessionId - Session ID from JWT sid claim
|
|
126
|
-
* @param {string} orgId - Organization ID to set as active
|
|
126
|
+
* @param {string|null} orgId - Organization ID to set as active, or null for personal context
|
|
127
127
|
* @param {string} currentToken - Current session token (for authentication)
|
|
128
|
-
* @returns {Promise<{jwt: string, org_id: string}>} New token with
|
|
128
|
+
* @returns {Promise<{jwt: string, org_id: string|null}>} New token with specified context
|
|
129
129
|
*/
|
|
130
130
|
export async function getOrgScopedToken(sessionId, orgId, currentToken) {
|
|
131
131
|
if (!sessionId) {
|
|
132
132
|
throw new Error('Session ID is required');
|
|
133
133
|
}
|
|
134
|
-
if (!orgId) {
|
|
135
|
-
throw new Error('Organization ID is required');
|
|
136
|
-
}
|
|
137
134
|
if (!currentToken) {
|
|
138
135
|
throw new Error('Current token is required');
|
|
139
136
|
}
|
|
137
|
+
// orgId can be null for personal context
|
|
140
138
|
|
|
141
|
-
|
|
139
|
+
const contextDesc = orgId ? `org ${orgId}` : 'personal context';
|
|
140
|
+
console.log(chalk.gray(`Requesting token for ${contextDesc}...`));
|
|
142
141
|
console.log(chalk.gray(` Session: ${sessionId}`));
|
|
143
|
-
console.log(chalk.gray(` Org: ${orgId}`));
|
|
144
142
|
|
|
145
143
|
// Call backend API which proxies to Clerk Backend API
|
|
146
144
|
const apiUrl = `${config.apiEndpoint}/v1/auth/org-token`;
|
|
147
|
-
console.log(chalk.gray(`Calling backend API: ${apiUrl}`));
|
|
148
145
|
|
|
149
146
|
const response = await fetch(apiUrl, {
|
|
150
147
|
method: 'POST',
|
|
@@ -154,7 +151,7 @@ export async function getOrgScopedToken(sessionId, orgId, currentToken) {
|
|
|
154
151
|
},
|
|
155
152
|
body: JSON.stringify({
|
|
156
153
|
session_id: sessionId,
|
|
157
|
-
org_id: orgId
|
|
154
|
+
org_id: orgId // null for personal context
|
|
158
155
|
})
|
|
159
156
|
});
|
|
160
157
|
|
|
@@ -162,11 +159,11 @@ export async function getOrgScopedToken(sessionId, orgId, currentToken) {
|
|
|
162
159
|
const errorText = await response.text();
|
|
163
160
|
console.error(chalk.red(`Backend API failed: ${response.status}`));
|
|
164
161
|
console.error(chalk.gray(errorText));
|
|
165
|
-
throw new Error(`Failed to get
|
|
162
|
+
throw new Error(`Failed to get token: ${response.status} - ${errorText}`);
|
|
166
163
|
}
|
|
167
164
|
|
|
168
165
|
const tokenData = await response.json();
|
|
169
|
-
console.log(chalk.gray(
|
|
166
|
+
console.log(chalk.gray(`ā Received token for ${contextDesc}`));
|
|
170
167
|
|
|
171
168
|
return tokenData;
|
|
172
169
|
}
|
package/lib/config.js
CHANGED
|
@@ -22,7 +22,10 @@ export const config = {
|
|
|
22
22
|
port: parseInt(process.env.PORT || '3333', 10),
|
|
23
23
|
|
|
24
24
|
// OAuth success page configuration (optional website link for account management)
|
|
25
|
-
oauthSuccessUrl: process.env.OAUTH_SUCCESS_URL || 'https://unifiedmemory.ai/oauth/callback'
|
|
25
|
+
oauthSuccessUrl: process.env.OAUTH_SUCCESS_URL || 'https://unifiedmemory.ai/oauth/callback',
|
|
26
|
+
|
|
27
|
+
// Frontend URL for CLI auth page (org picker)
|
|
28
|
+
frontendUrl: process.env.FRONTEND_URL || 'https://unifiedmemory.ai'
|
|
26
29
|
};
|
|
27
30
|
|
|
28
31
|
// Validation function - validates configuration values
|
|
@@ -51,5 +54,12 @@ export function validateConfig() {
|
|
|
51
54
|
throw new Error(`OAUTH_SUCCESS_URL must be a valid URL (got: ${config.oauthSuccessUrl})`);
|
|
52
55
|
}
|
|
53
56
|
|
|
57
|
+
// Validate frontendUrl format
|
|
58
|
+
try {
|
|
59
|
+
new URL(config.frontendUrl);
|
|
60
|
+
} catch (e) {
|
|
61
|
+
throw new Error(`FRONTEND_URL must be a valid URL (got: ${config.frontendUrl})`);
|
|
62
|
+
}
|
|
63
|
+
|
|
54
64
|
return true;
|
|
55
65
|
}
|
package/lib/mcp-proxy.js
CHANGED
|
@@ -82,8 +82,9 @@ function transformToolSchema(tool) {
|
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
84
|
* Inject context parameters into tool arguments
|
|
85
|
+
* JWT is the single source of truth for org context
|
|
85
86
|
* @param {Object} args - Tool arguments from AI agent
|
|
86
|
-
* @param {Object} authContext - Auth context with user/org info
|
|
87
|
+
* @param {Object} authContext - Auth context with user/org info (from JWT claims)
|
|
87
88
|
* @param {Object|null} projectContext - Project config from .um/config.json
|
|
88
89
|
* @returns {Object} - Arguments with injected context params
|
|
89
90
|
*/
|
|
@@ -101,21 +102,19 @@ function injectContextParams(args, authContext, projectContext) {
|
|
|
101
102
|
injected.headers = {};
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
// Inject user ID from decoded JWT
|
|
105
|
+
// Inject user ID from decoded JWT (always present)
|
|
105
106
|
if (authContext?.decoded?.sub) {
|
|
106
107
|
injected.pathParams.user = authContext.decoded.sub;
|
|
107
108
|
injected.headers['X-User-Id'] = authContext.decoded.sub;
|
|
108
109
|
}
|
|
109
110
|
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
injected.
|
|
114
|
-
|
|
115
|
-
// Fallback to user ID for personal account context
|
|
116
|
-
injected.pathParams.org = authContext.decoded.sub;
|
|
117
|
-
injected.headers['X-Org-Id'] = authContext.decoded.sub;
|
|
111
|
+
// ONLY inject org ID if JWT has org claims
|
|
112
|
+
// NO FALLBACK - gateway handles personal context by using userId
|
|
113
|
+
if (authContext?.orgContext?.id) {
|
|
114
|
+
injected.pathParams.org = authContext.orgContext.id;
|
|
115
|
+
injected.headers['X-Org-Id'] = authContext.orgContext.id;
|
|
118
116
|
}
|
|
117
|
+
// For personal context: no X-Org-Id header set, gateway falls back to userId
|
|
119
118
|
|
|
120
119
|
// Inject project ID from project config
|
|
121
120
|
if (projectContext?.project_id) {
|
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
|