@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.
@@ -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
+ }