@unifiedmemory/cli 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.
- package/.env.example +9 -0
- package/CHANGELOG.md +34 -0
- package/HOOK_SETUP.md +338 -0
- package/LICENSE +51 -0
- package/README.md +220 -0
- package/bin/um-mcp-serve +62 -0
- package/commands/init.js +315 -0
- package/commands/login.js +390 -0
- package/commands/org.js +111 -0
- package/commands/record.js +114 -0
- package/index.js +215 -0
- package/lib/clerk-api.js +172 -0
- package/lib/config.js +39 -0
- package/lib/hooks.js +43 -0
- package/lib/mcp-proxy.js +227 -0
- package/lib/mcp-server.js +284 -0
- package/lib/provider-detector.js +291 -0
- package/lib/token-refresh.js +113 -0
- package/lib/token-storage.js +63 -0
- package/lib/token-validation.js +47 -0
- package/package.json +49 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import {
|
|
4
|
+
CallToolRequestSchema,
|
|
5
|
+
ListToolsRequestSchema,
|
|
6
|
+
ListResourcesRequestSchema,
|
|
7
|
+
ReadResourceRequestSchema,
|
|
8
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import os from 'os';
|
|
12
|
+
import { loadAndRefreshToken } from './token-validation.js';
|
|
13
|
+
import { fetchRemoteMCPTools, callRemoteMCPTool } from './mcp-proxy.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Start the MCP server on stdio transport
|
|
17
|
+
*/
|
|
18
|
+
export async function startMCPServer() {
|
|
19
|
+
try {
|
|
20
|
+
// 1. Load and validate authentication
|
|
21
|
+
const authData = await loadAndValidateAuth();
|
|
22
|
+
|
|
23
|
+
// 2. Load project context from current directory
|
|
24
|
+
const projectContext = loadProjectContext();
|
|
25
|
+
|
|
26
|
+
// 3. Build auth headers
|
|
27
|
+
const authHeaders = buildAuthHeaders(authData, projectContext);
|
|
28
|
+
|
|
29
|
+
// 4. Extract auth context for parameter injection
|
|
30
|
+
const authContext = {
|
|
31
|
+
decoded: authData.decoded,
|
|
32
|
+
selectedOrg: authData.selectedOrg
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// 5. Create MCP server
|
|
36
|
+
const server = new Server(
|
|
37
|
+
{
|
|
38
|
+
name: "unifiedmemory-vault",
|
|
39
|
+
version: "1.0.0",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
capabilities: {
|
|
43
|
+
tools: {},
|
|
44
|
+
resources: {}, // Add resources capability for authentication context
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// 6. Register handlers
|
|
50
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
51
|
+
return await handleListTools(authHeaders, projectContext);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
55
|
+
return await handleCallTool(request, authHeaders, authContext, projectContext);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Register resource handlers for authentication context
|
|
59
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
60
|
+
const resources = [];
|
|
61
|
+
|
|
62
|
+
// Add org_id resource
|
|
63
|
+
const orgId = authData.selectedOrg?.id || authData.decoded?.sub;
|
|
64
|
+
if (orgId) {
|
|
65
|
+
resources.push({
|
|
66
|
+
uri: "um://context/org_id",
|
|
67
|
+
name: "Organization ID",
|
|
68
|
+
description: "Current organization context for authentication",
|
|
69
|
+
mimeType: "text/plain"
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Add user_id resource
|
|
74
|
+
if (authData.decoded?.sub) {
|
|
75
|
+
resources.push({
|
|
76
|
+
uri: "um://context/user_id",
|
|
77
|
+
name: "User ID",
|
|
78
|
+
description: "Current user context for authentication",
|
|
79
|
+
mimeType: "text/plain"
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Add project_id resource
|
|
84
|
+
if (projectContext?.project_id) {
|
|
85
|
+
resources.push({
|
|
86
|
+
uri: "um://context/project_id",
|
|
87
|
+
name: "Project ID",
|
|
88
|
+
description: "Current project context",
|
|
89
|
+
mimeType: "text/plain"
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
console.error(`✓ Exposed ${resources.length} authentication context resources`);
|
|
94
|
+
return { resources };
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
98
|
+
const { uri } = request.params;
|
|
99
|
+
console.error(`→ Reading resource: ${uri}`);
|
|
100
|
+
|
|
101
|
+
switch(uri) {
|
|
102
|
+
case "um://context/org_id": {
|
|
103
|
+
const orgId = authData.selectedOrg?.id || authData.decoded?.sub || "";
|
|
104
|
+
if (!orgId) {
|
|
105
|
+
throw new Error("Organization context not available");
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
contents: [{
|
|
109
|
+
uri,
|
|
110
|
+
mimeType: "text/plain",
|
|
111
|
+
text: orgId
|
|
112
|
+
}]
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
case "um://context/user_id": {
|
|
117
|
+
const userId = authData.decoded?.sub || "";
|
|
118
|
+
if (!userId) {
|
|
119
|
+
throw new Error("User context not available");
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
contents: [{
|
|
123
|
+
uri,
|
|
124
|
+
mimeType: "text/plain",
|
|
125
|
+
text: userId
|
|
126
|
+
}]
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
case "um://context/project_id": {
|
|
131
|
+
const projectId = projectContext?.project_id || "";
|
|
132
|
+
if (!projectId) {
|
|
133
|
+
throw new Error("Project context not available (run 'um init')");
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
contents: [{
|
|
137
|
+
uri,
|
|
138
|
+
mimeType: "text/plain",
|
|
139
|
+
text: projectId
|
|
140
|
+
}]
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
default:
|
|
145
|
+
throw new Error(`Unknown resource URI: ${uri}`);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// 7. Connect to stdio transport
|
|
150
|
+
const transport = new StdioServerTransport();
|
|
151
|
+
await server.connect(transport);
|
|
152
|
+
|
|
153
|
+
console.error("✓ UnifiedMemory MCP server running on stdio");
|
|
154
|
+
} catch (error) {
|
|
155
|
+
console.error("❌ MCP server failed to start:");
|
|
156
|
+
console.error(error.message);
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Load and validate authentication, refreshing if necessary
|
|
163
|
+
* @returns {Promise<Object>} - Validated token data
|
|
164
|
+
*/
|
|
165
|
+
async function loadAndValidateAuth() {
|
|
166
|
+
return await loadAndRefreshToken();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Load project context from .um/config.json in current directory
|
|
171
|
+
* @returns {Object|null} - Project config or null
|
|
172
|
+
*/
|
|
173
|
+
function loadProjectContext() {
|
|
174
|
+
const configPath = path.join(process.cwd(), '.um', 'config.json');
|
|
175
|
+
|
|
176
|
+
if (!fs.existsSync(configPath)) {
|
|
177
|
+
console.error('⚠️ No project config found in current directory');
|
|
178
|
+
console.error(' Using personal account context (no project)');
|
|
179
|
+
console.error(' Tip: Run "um init" to configure a project');
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
185
|
+
console.error(`✓ Loaded project: ${config.project_name} (${config.project_id})`);
|
|
186
|
+
return config;
|
|
187
|
+
} catch (error) {
|
|
188
|
+
console.error(`⚠️ Failed to read project config: ${error.message}`);
|
|
189
|
+
console.error(' Using personal account context');
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Build authentication headers for gateway requests
|
|
196
|
+
* @param {Object} authData - Token data
|
|
197
|
+
* @param {Object|null} projectContext - Project config
|
|
198
|
+
* @returns {Object} - Headers object
|
|
199
|
+
*/
|
|
200
|
+
function buildAuthHeaders(authData, projectContext) {
|
|
201
|
+
const headers = {
|
|
202
|
+
'Authorization': `Bearer ${authData.idToken || authData.accessToken}`,
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// Add org context
|
|
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
|
+
if (authData.decoded?.sub) {
|
|
217
|
+
headers['X-User-Id'] = authData.decoded.sub;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Add project context if available
|
|
221
|
+
if (projectContext) {
|
|
222
|
+
headers['X-Project-Id'] = projectContext.project_id;
|
|
223
|
+
console.error(`✓ Project context: ${projectContext.project_id}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return headers;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Handle tools/list request
|
|
231
|
+
* @param {Object} authHeaders - Auth headers
|
|
232
|
+
* @param {Object|null} projectContext - Project context
|
|
233
|
+
* @returns {Promise<Object>} - Tools list
|
|
234
|
+
*/
|
|
235
|
+
async function handleListTools(authHeaders, projectContext) {
|
|
236
|
+
try {
|
|
237
|
+
console.error('→ Fetching tools from gateway...');
|
|
238
|
+
const remoteTools = await fetchRemoteMCPTools(authHeaders, projectContext);
|
|
239
|
+
console.error(`✓ Loaded ${remoteTools.tools?.length || 0} tools`);
|
|
240
|
+
return { tools: remoteTools.tools || [] };
|
|
241
|
+
} catch (error) {
|
|
242
|
+
console.error(`❌ Error fetching tools: ${error.message}`);
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Handle tools/call request
|
|
249
|
+
* @param {Object} request - MCP request
|
|
250
|
+
* @param {Object} authHeaders - Auth headers
|
|
251
|
+
* @param {Object} authContext - Auth context with user/org info
|
|
252
|
+
* @param {Object|null} projectContext - Project context
|
|
253
|
+
* @returns {Promise<Object>} - Tool result
|
|
254
|
+
*/
|
|
255
|
+
async function handleCallTool(request, authHeaders, authContext, projectContext) {
|
|
256
|
+
const { name, arguments: args } = request.params;
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
console.error(`→ Calling tool: ${name}`);
|
|
260
|
+
const result = await callRemoteMCPTool(name, args, authHeaders, authContext, projectContext);
|
|
261
|
+
console.error(`✓ Tool executed successfully: ${name}`);
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
content: result.content || [
|
|
265
|
+
{
|
|
266
|
+
type: "text",
|
|
267
|
+
text: JSON.stringify(result, null, 2),
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
};
|
|
271
|
+
} catch (error) {
|
|
272
|
+
console.error(`❌ Error calling tool ${name}: ${error.message}`);
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
content: [
|
|
276
|
+
{
|
|
277
|
+
type: "text",
|
|
278
|
+
text: `Error: ${error.message}`,
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
isError: true,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
|
|
5
|
+
class ProviderDetector {
|
|
6
|
+
constructor(projectDir = null) {
|
|
7
|
+
this.providers = [
|
|
8
|
+
new ClaudeProvider(projectDir), // Project-level config
|
|
9
|
+
new CursorProvider(), // Global config
|
|
10
|
+
new ClineProvider(), // Global config
|
|
11
|
+
new CodexProvider(), // Global config - TOML
|
|
12
|
+
new GeminiProvider(), // Global config - JSON
|
|
13
|
+
];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
detectAll() {
|
|
17
|
+
return this.providers.filter(p => p.detect());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
getByName(name) {
|
|
21
|
+
return this.providers.find(p =>
|
|
22
|
+
p.name.toLowerCase() === name.toLowerCase()
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
class BaseProvider {
|
|
28
|
+
constructor(name, configPath, supportsHooks = false, detectionPath = null) {
|
|
29
|
+
this.name = name;
|
|
30
|
+
this.configPath = configPath;
|
|
31
|
+
this.supportsHooks = supportsHooks;
|
|
32
|
+
// Use detection path (directory) if provided, otherwise check config file
|
|
33
|
+
this.detectionPath = detectionPath || configPath;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
detect() {
|
|
37
|
+
return fs.existsSync(this.detectionPath);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
readConfig() {
|
|
41
|
+
if (!this.detect()) return null;
|
|
42
|
+
try {
|
|
43
|
+
return fs.readJSONSync(this.configPath);
|
|
44
|
+
} catch (e) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
writeConfig(config) {
|
|
50
|
+
try {
|
|
51
|
+
fs.ensureFileSync(this.configPath);
|
|
52
|
+
fs.writeJSONSync(this.configPath, config, { spaces: 2 });
|
|
53
|
+
return true;
|
|
54
|
+
} catch (e) {
|
|
55
|
+
console.error(`Failed to write config: ${e.message}`);
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
configureMCP() {
|
|
61
|
+
// NEW APPROACH: Configure local server instead of HTTP
|
|
62
|
+
// No parameters needed - local server reads from filesystem
|
|
63
|
+
const config = this.readConfig() || { mcpServers: {} };
|
|
64
|
+
|
|
65
|
+
config.mcpServers = config.mcpServers || {};
|
|
66
|
+
config.mcpServers.vault = {
|
|
67
|
+
command: "um",
|
|
68
|
+
args: ["mcp", "serve"]
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return this.writeConfig(config);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
installHook(hookType, scriptContent) {
|
|
75
|
+
// Implemented by providers that support hooks
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
class ClaudeProvider extends BaseProvider {
|
|
81
|
+
constructor(projectDir = null) {
|
|
82
|
+
// Claude Code uses project-level MCP configuration at .mcp.json
|
|
83
|
+
const configPath = projectDir ? path.join(projectDir, '.mcp.json') : null;
|
|
84
|
+
const claudeDir = projectDir ? path.join(projectDir, '.claude') : null;
|
|
85
|
+
|
|
86
|
+
// Always detect as available (project-level config)
|
|
87
|
+
super('Claude Code', configPath, true, null);
|
|
88
|
+
this.projectDir = projectDir;
|
|
89
|
+
this.claudeDir = claudeDir;
|
|
90
|
+
this.hooksDir = claudeDir ? path.join(claudeDir, 'hooks') : null;
|
|
91
|
+
this.settingsPath = claudeDir ? path.join(claudeDir, 'settings.local.json') : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
detect() {
|
|
95
|
+
// Always return true for project-level config
|
|
96
|
+
// We'll create the config directory when configuring
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
readConfig() {
|
|
101
|
+
if (!this.configPath || !fs.existsSync(this.configPath)) return null;
|
|
102
|
+
try {
|
|
103
|
+
return fs.readJSONSync(this.configPath);
|
|
104
|
+
} catch (e) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
configureMCP() {
|
|
110
|
+
// Create .mcp.json at project root with local server config
|
|
111
|
+
const success = super.configureMCP();
|
|
112
|
+
|
|
113
|
+
if (success) {
|
|
114
|
+
// Update .claude/settings.local.json to enable the MCP server
|
|
115
|
+
this.updateClaudeSettings();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return success;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
updateClaudeSettings() {
|
|
122
|
+
if (!this.settingsPath) return false;
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
// Read existing settings or create new object
|
|
126
|
+
let settings = {};
|
|
127
|
+
if (fs.existsSync(this.settingsPath)) {
|
|
128
|
+
settings = fs.readJSONSync(this.settingsPath);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Add or update the required settings
|
|
132
|
+
settings.enableAllProjectMcpServers = true;
|
|
133
|
+
|
|
134
|
+
// Ensure vault is in the enabled servers list
|
|
135
|
+
if (!settings.enabledMcpjsonServers) {
|
|
136
|
+
settings.enabledMcpjsonServers = [];
|
|
137
|
+
}
|
|
138
|
+
if (!settings.enabledMcpjsonServers.includes('vault')) {
|
|
139
|
+
settings.enabledMcpjsonServers.push('vault');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Write settings
|
|
143
|
+
fs.ensureDirSync(this.claudeDir);
|
|
144
|
+
fs.writeJSONSync(this.settingsPath, settings, { spaces: 2 });
|
|
145
|
+
return true;
|
|
146
|
+
} catch (e) {
|
|
147
|
+
console.error(`Failed to update Claude settings: ${e.message}`);
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
installHook(hookType, scriptContent) {
|
|
153
|
+
if (hookType !== 'prompt-submit' || !this.hooksDir) return false;
|
|
154
|
+
|
|
155
|
+
const hookPath = path.join(this.hooksDir, 'prompt-submit');
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
fs.ensureDirSync(this.hooksDir);
|
|
159
|
+
fs.writeFileSync(hookPath, scriptContent, { mode: 0o755 });
|
|
160
|
+
return true;
|
|
161
|
+
} catch (e) {
|
|
162
|
+
console.error(`Failed to install hook: ${e.message}`);
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
class CursorProvider extends BaseProvider {
|
|
169
|
+
constructor() {
|
|
170
|
+
const baseDir = path.join(os.homedir(), '.cursor');
|
|
171
|
+
const configPath = path.join(baseDir, 'mcp.json');
|
|
172
|
+
// Detect by directory existence, not config file
|
|
173
|
+
super('Cursor', configPath, false, baseDir);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
class ClineProvider extends BaseProvider {
|
|
178
|
+
constructor() {
|
|
179
|
+
const baseDir = path.join(os.homedir(), '.cline');
|
|
180
|
+
const configPath = path.join(baseDir, 'mcp.json');
|
|
181
|
+
// Detect by directory existence, not config file
|
|
182
|
+
super('Cline', configPath, false, baseDir);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
class CodexProvider extends BaseProvider {
|
|
187
|
+
constructor() {
|
|
188
|
+
const configPath = path.join(os.homedir(), '.codex', 'config.toml');
|
|
189
|
+
const detectionPath = path.dirname(configPath); // Detect by directory
|
|
190
|
+
super('Codex CLI', configPath, false, detectionPath);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
readConfig() {
|
|
194
|
+
if (!fs.existsSync(this.configPath)) return null;
|
|
195
|
+
try {
|
|
196
|
+
const content = fs.readFileSync(this.configPath, 'utf8');
|
|
197
|
+
return this.parseTOML(content);
|
|
198
|
+
} catch (e) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
writeConfig(config) {
|
|
204
|
+
try {
|
|
205
|
+
fs.ensureFileSync(this.configPath);
|
|
206
|
+
const tomlContent = this.generateTOML(config);
|
|
207
|
+
fs.writeFileSync(this.configPath, tomlContent);
|
|
208
|
+
return true;
|
|
209
|
+
} catch (e) {
|
|
210
|
+
console.error(`Failed to write config: ${e.message}`);
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
parseTOML(content) {
|
|
216
|
+
// Simple TOML parser for [mcp_servers.*] sections
|
|
217
|
+
const servers = {};
|
|
218
|
+
const lines = content.split('\n');
|
|
219
|
+
let currentServer = null;
|
|
220
|
+
|
|
221
|
+
for (const line of lines) {
|
|
222
|
+
const trimmed = line.trim();
|
|
223
|
+
if (trimmed.startsWith('[mcp_servers.')) {
|
|
224
|
+
const match = trimmed.match(/\[mcp_servers\.([^\]]+)\]/);
|
|
225
|
+
if (match) {
|
|
226
|
+
currentServer = match[1];
|
|
227
|
+
servers[currentServer] = {};
|
|
228
|
+
}
|
|
229
|
+
} else if (currentServer && trimmed.includes('=')) {
|
|
230
|
+
const [key, ...valueParts] = trimmed.split('=');
|
|
231
|
+
const value = valueParts.join('=').trim();
|
|
232
|
+
if (key.trim() === 'command') {
|
|
233
|
+
servers[currentServer].command = value.replace(/"/g, '');
|
|
234
|
+
} else if (key.trim() === 'args') {
|
|
235
|
+
// Parse array: ["arg1", "arg2"]
|
|
236
|
+
const argsMatch = value.match(/\[(.*)\]/);
|
|
237
|
+
if (argsMatch) {
|
|
238
|
+
servers[currentServer].args = argsMatch[1]
|
|
239
|
+
.split(',')
|
|
240
|
+
.map(s => s.trim().replace(/"/g, ''));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return { mcp_servers: servers };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
generateTOML(config) {
|
|
250
|
+
let toml = '';
|
|
251
|
+
const servers = config.mcp_servers || {};
|
|
252
|
+
|
|
253
|
+
for (const [name, serverConfig] of Object.entries(servers)) {
|
|
254
|
+
toml += `[mcp_servers.${name}]\n`;
|
|
255
|
+
toml += `command = "${serverConfig.command}"\n`;
|
|
256
|
+
if (serverConfig.args && serverConfig.args.length > 0) {
|
|
257
|
+
const argsStr = serverConfig.args.map(arg => `"${arg}"`).join(', ');
|
|
258
|
+
toml += `args = [${argsStr}]\n`;
|
|
259
|
+
}
|
|
260
|
+
toml += '\n';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return toml;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
configureMCP() {
|
|
267
|
+
const config = this.readConfig() || { mcp_servers: {} };
|
|
268
|
+
config.mcp_servers.vault = {
|
|
269
|
+
command: "um",
|
|
270
|
+
args: ["mcp", "serve"]
|
|
271
|
+
};
|
|
272
|
+
return this.writeConfig(config);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
class GeminiProvider extends BaseProvider {
|
|
277
|
+
constructor() {
|
|
278
|
+
const configPath = path.join(os.homedir(), '.gemini', 'settings.json');
|
|
279
|
+
const detectionPath = path.dirname(configPath); // Detect by directory
|
|
280
|
+
super('Gemini CLI', configPath, false, detectionPath);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export {
|
|
285
|
+
ProviderDetector,
|
|
286
|
+
ClaudeProvider,
|
|
287
|
+
CursorProvider,
|
|
288
|
+
ClineProvider,
|
|
289
|
+
CodexProvider,
|
|
290
|
+
GeminiProvider,
|
|
291
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { getToken, saveToken } from './token-storage.js';
|
|
2
|
+
import { config } from './config.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Check if token has expired
|
|
6
|
+
* @param {Object} tokenData - Token data from storage
|
|
7
|
+
* @returns {boolean} - True if expired
|
|
8
|
+
*/
|
|
9
|
+
export function isTokenExpired(tokenData) {
|
|
10
|
+
if (!tokenData || !tokenData.decoded || !tokenData.decoded.exp) {
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Check expiration with 5-minute buffer
|
|
15
|
+
const expirationTime = tokenData.decoded.exp * 1000;
|
|
16
|
+
const bufferMs = 5 * 60 * 1000; // 5 minutes
|
|
17
|
+
const now = Date.now();
|
|
18
|
+
|
|
19
|
+
return now >= (expirationTime - bufferMs);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Refresh access token using refresh token
|
|
24
|
+
* @param {Object} tokenData - Current token data
|
|
25
|
+
* @returns {Promise<Object>} - New token data
|
|
26
|
+
*/
|
|
27
|
+
export async function refreshAccessToken(tokenData) {
|
|
28
|
+
if (!tokenData.refresh_token) {
|
|
29
|
+
throw new Error('No refresh token available');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const tokenParams = {
|
|
34
|
+
client_id: config.clerkClientId,
|
|
35
|
+
refresh_token: tokenData.refresh_token,
|
|
36
|
+
grant_type: 'refresh_token',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Add client secret if available
|
|
40
|
+
if (config.clerkClientSecret) {
|
|
41
|
+
tokenParams.client_secret = config.clerkClientSecret;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const response = await fetch(`https://${config.clerkDomain}/oauth/token`, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: {
|
|
47
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
48
|
+
},
|
|
49
|
+
body: new URLSearchParams(tokenParams),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (!response.ok) {
|
|
53
|
+
const errorText = await response.text();
|
|
54
|
+
let errorDetail;
|
|
55
|
+
try {
|
|
56
|
+
const errorJson = JSON.parse(errorText);
|
|
57
|
+
errorDetail = errorJson.error_description || errorJson.error || errorText;
|
|
58
|
+
} catch {
|
|
59
|
+
errorDetail = errorText;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Token refresh failed: ${response.status}\n` +
|
|
64
|
+
`Details: ${errorDetail}\n` +
|
|
65
|
+
`\n` +
|
|
66
|
+
`This usually means your refresh token has expired or been revoked.\n` +
|
|
67
|
+
`Please run: um login`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const newTokenData = await response.json();
|
|
72
|
+
|
|
73
|
+
// Parse new JWT
|
|
74
|
+
const decoded = parseJWT(newTokenData.id_token || newTokenData.access_token);
|
|
75
|
+
|
|
76
|
+
// Build updated token object, preserving selectedOrg
|
|
77
|
+
const updatedToken = {
|
|
78
|
+
accessToken: newTokenData.access_token,
|
|
79
|
+
idToken: newTokenData.id_token,
|
|
80
|
+
tokenType: newTokenData.token_type || 'Bearer',
|
|
81
|
+
expiresIn: newTokenData.expires_in,
|
|
82
|
+
receivedAt: Date.now(),
|
|
83
|
+
decoded: decoded,
|
|
84
|
+
selectedOrg: tokenData.selectedOrg, // Preserve organization context
|
|
85
|
+
refresh_token: newTokenData.refresh_token || tokenData.refresh_token,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Save to storage
|
|
89
|
+
saveToken(updatedToken);
|
|
90
|
+
|
|
91
|
+
return updatedToken;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
throw new Error(`Token refresh failed: ${error.message}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Parse JWT token payload
|
|
99
|
+
* @param {string} token - JWT token
|
|
100
|
+
* @returns {Object|null} - Decoded payload
|
|
101
|
+
*/
|
|
102
|
+
function parseJWT(token) {
|
|
103
|
+
try {
|
|
104
|
+
const parts = token.split('.');
|
|
105
|
+
if (parts.length !== 3) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const payload = Buffer.from(parts[1], 'base64').toString('utf8');
|
|
109
|
+
return JSON.parse(payload);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|