@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/index.js ADDED
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import chalk from 'chalk';
5
+ import fs from 'fs-extra';
6
+ import path from 'path';
7
+ import { login } from './commands/login.js';
8
+ import { init } from './commands/init.js';
9
+ import { switchOrg, showOrg } from './commands/org.js';
10
+ import { record } from './commands/record.js';
11
+ import { config } from './lib/config.js';
12
+ import { getSelectedOrg } from './lib/token-storage.js';
13
+ import { loadAndRefreshToken } from './lib/token-validation.js';
14
+
15
+ const program = new Command();
16
+
17
+ program
18
+ .name('um')
19
+ .description('UnifiedMemory CLI - AI code assistant integration')
20
+ .version('1.0.0');
21
+
22
+ // Unified command (primary)
23
+ program
24
+ .command('init')
25
+ .description('Initialize UnifiedMemory in current project (one command does everything)')
26
+ .option('--oauth', 'Force OAuth login')
27
+ .option('--api-key <key>', 'Use API key authentication')
28
+ .option('--skip-configure', 'Skip AI tool configuration')
29
+ .action(async (options) => {
30
+ try {
31
+ await init(options);
32
+ process.exit(0);
33
+ } catch (error) {
34
+ console.error(chalk.red('Initialization failed:'), error.message);
35
+ process.exit(1);
36
+ }
37
+ });
38
+
39
+ // Individual commands (power users)
40
+ program
41
+ .command('login')
42
+ .description('Login to UnifiedMemory')
43
+ .option('--device', 'Use device flow for headless environments')
44
+ .action(async (options) => {
45
+ try {
46
+ await login();
47
+ process.exit(0);
48
+ } catch (error) {
49
+ console.error(chalk.red('Login failed:'), error.message);
50
+ process.exit(1);
51
+ }
52
+ });
53
+
54
+ // Status command
55
+ program
56
+ .command('status')
57
+ .description('Show configuration status')
58
+ .action(async () => {
59
+ try {
60
+ // Try to load and refresh token if expired
61
+ const tokenData = await loadAndRefreshToken(false);
62
+ const selectedOrg = getSelectedOrg();
63
+
64
+ console.log(chalk.blue('\n📋 UnifiedMemory Status\n'));
65
+
66
+ if (!tokenData) {
67
+ console.log(chalk.yellow('Authentication: ') + chalk.red('Not logged in'));
68
+ console.log(chalk.gray('\nRun `um login` or `um init` to authenticate'));
69
+ process.exit(0);
70
+ }
71
+
72
+ // Token is valid (either not expired or successfully refreshed)
73
+ console.log(chalk.yellow('Authentication:'));
74
+ console.log(chalk.green(' Status: Active'));
75
+ console.log(chalk.gray(` User ID: ${tokenData.decoded?.sub || 'N/A'}`));
76
+ console.log(chalk.gray(` Email: ${tokenData.decoded?.email || 'N/A'}`));
77
+ if (tokenData.decoded?.exp) {
78
+ console.log(chalk.gray(` Expires: ${new Date(tokenData.decoded.exp * 1000).toLocaleString()}`));
79
+ }
80
+
81
+ console.log(chalk.yellow('\nOrganization Context:'));
82
+ if (selectedOrg) {
83
+ console.log(chalk.green(` ${selectedOrg.name} (${selectedOrg.slug})`));
84
+ console.log(chalk.gray(` ID: ${selectedOrg.id}`));
85
+ console.log(chalk.gray(` Role: ${selectedOrg.role}`));
86
+ } else {
87
+ console.log(chalk.cyan(' Personal Account'));
88
+ }
89
+
90
+ console.log(chalk.yellow('\nProject Configuration:'));
91
+ const configPath = path.join(process.cwd(), '.um', 'config.json');
92
+
93
+ if (fs.existsSync(configPath)) {
94
+ try {
95
+ const projectConfig = fs.readJSONSync(configPath);
96
+ console.log(chalk.green(' Configured'));
97
+ console.log(chalk.gray(` Project: ${projectConfig.project_name}`));
98
+ console.log(chalk.gray(` Project ID: ${projectConfig.project_id}`));
99
+ console.log(chalk.gray(` Org ID: ${projectConfig.org_id}`));
100
+ } catch (error) {
101
+ console.log(chalk.red(' Error reading config:'), error.message);
102
+ }
103
+ } else {
104
+ console.log(chalk.yellow(' Not configured in this directory'));
105
+ console.log(chalk.gray(' Run `um init` to configure'));
106
+ }
107
+
108
+ console.log('');
109
+ process.exit(0);
110
+ } catch (error) {
111
+ console.error(chalk.red('Failed to show status:'), error.message);
112
+ process.exit(1);
113
+ }
114
+ });
115
+
116
+ // Project management command
117
+ program
118
+ .command('project')
119
+ .description('Manage project configuration')
120
+ .option('--create', 'Create new project')
121
+ .option('--link <id>', 'Link existing project')
122
+ .action(async (options) => {
123
+ console.log(chalk.yellow('Project management not yet implemented'));
124
+ console.log(chalk.gray('Use `um init` to configure a project'));
125
+ process.exit(0);
126
+ });
127
+
128
+ // Configure command
129
+ program
130
+ .command('configure')
131
+ .description('Configure AI code assistants')
132
+ .option('--all', 'Configure all detected assistants')
133
+ .option('--provider <name>', 'Configure specific provider')
134
+ .action(async (options) => {
135
+ console.log(chalk.yellow('Provider configuration not yet implemented'));
136
+ console.log(chalk.gray('Use `um init` to auto-configure all providers'));
137
+ process.exit(0);
138
+ });
139
+
140
+ // Organization management
141
+ const orgCommand = program
142
+ .command('org')
143
+ .description('Manage organization context');
144
+
145
+ orgCommand
146
+ .command('switch')
147
+ .description('Switch to a different organization')
148
+ .action(async () => {
149
+ try {
150
+ await switchOrg();
151
+ process.exit(0);
152
+ } catch (error) {
153
+ console.error(chalk.red('Failed to switch organization:'), error.message);
154
+ process.exit(1);
155
+ }
156
+ });
157
+
158
+ orgCommand
159
+ .command('show')
160
+ .alias('current')
161
+ .description('Show current organization context')
162
+ .action(async () => {
163
+ try {
164
+ await showOrg();
165
+ process.exit(0);
166
+ } catch (error) {
167
+ console.error(chalk.red('Failed to show organization:'), error.message);
168
+ process.exit(1);
169
+ }
170
+ });
171
+
172
+ // MCP server commands
173
+ const mcpCommand = program
174
+ .command('mcp')
175
+ .description('MCP server commands');
176
+
177
+ mcpCommand
178
+ .command('serve')
179
+ .description('Start MCP server (used by AI code assistants)')
180
+ .action(async () => {
181
+ try {
182
+ const { startMCPServer } = await import('./lib/mcp-server.js');
183
+ await startMCPServer();
184
+ // Server runs until killed by parent process
185
+ } catch (error) {
186
+ console.error(chalk.red('MCP server failed to start:'));
187
+ console.error(error.message);
188
+ process.exit(1);
189
+ }
190
+ });
191
+
192
+ // Note management commands
193
+ const noteCommand = program
194
+ .command('note')
195
+ .description('Manage vault notes');
196
+
197
+ noteCommand
198
+ .command('create <summary>')
199
+ .description('Create a note in the vault')
200
+ .option('--topic <topic>', 'Topic for the note', 'general')
201
+ .option('--source <source>', 'Source identifier', 'um-cli')
202
+ .option('--confidence <confidence>', 'Confidence score (0-1)', '0.7')
203
+ .option('--tags <tags>', 'Comma-separated tags')
204
+ .option('--metadata <json>', 'Additional metadata as JSON string')
205
+ .action(async (summary, options) => {
206
+ try {
207
+ await record(summary, options);
208
+ process.exit(0);
209
+ } catch (error) {
210
+ console.error(chalk.red('Failed to create note:'), error.message);
211
+ process.exit(1);
212
+ }
213
+ });
214
+
215
+ program.parse();
@@ -0,0 +1,172 @@
1
+ import chalk from 'chalk';
2
+ import { config } from './config.js';
3
+
4
+ /**
5
+ * Extract user's organizations from JWT token claims
6
+ * @param {Object} decodedToken - Decoded JWT token
7
+ * @returns {Array} Array of organization membership objects
8
+ */
9
+ export function getOrganizationsFromToken(decodedToken) {
10
+ if (!decodedToken) {
11
+ return [];
12
+ }
13
+
14
+ // Check various possible claim names where org data might be stored
15
+ const orgMemberships =
16
+ decodedToken.org_memberships ||
17
+ decodedToken.organizations ||
18
+ decodedToken.orgs ||
19
+ [];
20
+
21
+ // If JWT has organization array, format it
22
+ if (Array.isArray(orgMemberships) && orgMemberships.length > 0) {
23
+ return orgMemberships.map(org => ({
24
+ organization: {
25
+ id: org.id || org.org_id,
26
+ name: org.name || org.org_name,
27
+ slug: org.slug || org.org_slug,
28
+ },
29
+ role: org.role,
30
+ created_at: org.created_at || new Date().toISOString(),
31
+ }));
32
+ }
33
+
34
+ // If JWT has single org_id claim, create single membership
35
+ if (decodedToken.org_id) {
36
+ return [{
37
+ organization: {
38
+ id: decodedToken.org_id,
39
+ name: decodedToken.org_name || 'Organization',
40
+ slug: decodedToken.org_slug || decodedToken.org_id,
41
+ },
42
+ role: decodedToken.org_role || 'member',
43
+ created_at: new Date().toISOString(),
44
+ }];
45
+ }
46
+
47
+ return [];
48
+ }
49
+
50
+ /**
51
+ * Fetch user's organization memberships from backend API
52
+ * @param {string} userId - The Clerk user ID (not used, kept for compatibility)
53
+ * @param {string} sessionToken - The access_token or id_token from OAuth
54
+ * @returns {Promise<Array>} Array of organization membership objects
55
+ */
56
+ export async function getUserOrganizations(userId, sessionToken) {
57
+ if (!sessionToken) {
58
+ console.log(chalk.yellow('⚠️ No session token provided, cannot fetch organizations'));
59
+ return [];
60
+ }
61
+
62
+ try {
63
+ // Call our backend API through gateway
64
+ const url = `${config.apiEndpoint}/v1/users/me/organizations`;
65
+
66
+ console.log(chalk.gray('Fetching organizations from backend API...'));
67
+
68
+ const response = await fetch(url, {
69
+ method: 'GET',
70
+ headers: {
71
+ 'Authorization': `Bearer ${sessionToken}`,
72
+ 'Content-Type': 'application/json',
73
+ },
74
+ });
75
+
76
+ if (!response.ok) {
77
+ const errorText = await response.text();
78
+ throw new Error(`Failed to fetch organizations: ${response.status} - ${errorText}`);
79
+ }
80
+
81
+ const memberships = await response.json();
82
+
83
+ if (!Array.isArray(memberships)) {
84
+ throw new Error('Invalid response format from backend');
85
+ }
86
+
87
+ console.log(chalk.gray(`Found ${memberships.length} organization(s)`));
88
+ return memberships;
89
+ } catch (error) {
90
+ console.error(chalk.yellow(`⚠️ Error fetching organizations: ${error.message}`));
91
+ return [];
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Format organization for display in selection menu
97
+ * @param {Object} membership - Organization membership object from Clerk
98
+ * @returns {Object} Formatted organization object
99
+ */
100
+ export function formatOrganization(membership) {
101
+ const org = membership.organization;
102
+
103
+ // Handle created_at - could be int or string
104
+ let createdAt = 'Unknown';
105
+ try {
106
+ const timestamp = typeof membership.created_at === 'string'
107
+ ? parseInt(membership.created_at, 10)
108
+ : membership.created_at;
109
+ createdAt = new Date(timestamp).toLocaleDateString();
110
+ } catch (e) {
111
+ console.warn('Failed to parse created_at:', membership.created_at);
112
+ }
113
+
114
+ return {
115
+ id: org.id,
116
+ name: org.name || 'Unnamed Organization',
117
+ slug: org.slug || org.id,
118
+ role: membership.role || 'member',
119
+ createdAt: createdAt,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Get an organization-scoped JWT token via backend API
125
+ * @param {string} sessionId - Session ID from JWT sid claim
126
+ * @param {string} orgId - Organization ID to set as active
127
+ * @param {string} currentToken - Current session token (for authentication)
128
+ * @returns {Promise<{jwt: string, org_id: string}>} New token with org context
129
+ */
130
+ export async function getOrgScopedToken(sessionId, orgId, currentToken) {
131
+ if (!sessionId) {
132
+ throw new Error('Session ID is required');
133
+ }
134
+ if (!orgId) {
135
+ throw new Error('Organization ID is required');
136
+ }
137
+ if (!currentToken) {
138
+ throw new Error('Current token is required');
139
+ }
140
+
141
+ console.log(chalk.gray(`Requesting org-scoped token from backend...`));
142
+ console.log(chalk.gray(` Session: ${sessionId}`));
143
+ console.log(chalk.gray(` Org: ${orgId}`));
144
+
145
+ // Call backend API which proxies to Clerk Backend API
146
+ const apiUrl = `${config.apiEndpoint}/v1/auth/org-token`;
147
+ console.log(chalk.gray(`Calling backend API: ${apiUrl}`));
148
+
149
+ const response = await fetch(apiUrl, {
150
+ method: 'POST',
151
+ headers: {
152
+ 'Authorization': `Bearer ${currentToken}`,
153
+ 'Content-Type': 'application/json',
154
+ },
155
+ body: JSON.stringify({
156
+ session_id: sessionId,
157
+ org_id: orgId
158
+ })
159
+ });
160
+
161
+ if (!response.ok) {
162
+ const errorText = await response.text();
163
+ console.error(chalk.red(`Backend API failed: ${response.status}`));
164
+ console.error(chalk.gray(errorText));
165
+ throw new Error(`Failed to get org-scoped token: ${response.status} - ${errorText}`);
166
+ }
167
+
168
+ const tokenData = await response.json();
169
+ console.log(chalk.gray('✓ Received org-scoped token from backend'));
170
+
171
+ return tokenData;
172
+ }
package/lib/config.js ADDED
@@ -0,0 +1,39 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { dirname } from 'path';
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = dirname(__filename);
8
+
9
+ // Load environment variables from .env file if it exists
10
+ const envPath = path.join(__dirname, '..', '.env');
11
+ if (fs.existsSync(envPath)) {
12
+ const envContent = fs.readFileSync(envPath, 'utf8');
13
+ envContent.split('\n').forEach(line => {
14
+ const [key, ...valueParts] = line.split('=');
15
+ if (key && valueParts.length > 0) {
16
+ const value = valueParts.join('=').trim();
17
+ if (!process.env[key]) {
18
+ process.env[key] = value;
19
+ }
20
+ }
21
+ });
22
+ }
23
+
24
+ export const config = {
25
+ // Production Clerk OAuth client (safe to embed - public key only)
26
+ clerkClientId: process.env.CLERK_CLIENT_ID || 'nULlnomaKB9rRGP2',
27
+ clerkClientSecret: process.env.CLERK_CLIENT_SECRET, // Optional for PKCE flow
28
+ clerkDomain: process.env.CLERK_DOMAIN || 'clear-caiman-45.clerk.accounts.dev',
29
+ apiEndpoint: process.env.API_ENDPOINT || 'https://rose-asp-main-1c0b114.d2.zuplo.dev',
30
+ redirectUri: process.env.REDIRECT_URI || 'http://localhost:3333/callback',
31
+ port: parseInt(process.env.PORT || '3333', 10)
32
+ };
33
+
34
+ // Validation function (no longer throws - hardcoded defaults are valid)
35
+ export function validateConfig() {
36
+ // All required values now have defaults - validation always passes
37
+ // Keep function for backward compatibility with existing code
38
+ return true;
39
+ }
package/lib/hooks.js ADDED
@@ -0,0 +1,43 @@
1
+ export function generateClaudePromptSubmitHook(mcpUrl, accessToken) {
2
+ return `#!/bin/bash
3
+ # UnifiedMemory Context Recording Hook
4
+
5
+ HOOK_DATA=$(cat)
6
+
7
+ # Read project config
8
+ UM_CONFIG=".um/config.json"
9
+ PROJECT_ID=""
10
+ ORG_ID=""
11
+
12
+ if [ -f "$UM_CONFIG" ]; then
13
+ PROJECT_ID=$(jq -r '.project_id // empty' "$UM_CONFIG" 2>/dev/null)
14
+ ORG_ID=$(jq -r '.org_id // empty' "$UM_CONFIG" 2>/dev/null)
15
+ fi
16
+
17
+ # Extract context
18
+ USER_MESSAGE=$(echo "$HOOK_DATA" | jq -r '.userMessage // empty')
19
+ CONVERSATION_ID=$(echo "$HOOK_DATA" | jq -r '.conversationId // empty')
20
+ WORKING_DIR=$(pwd)
21
+ TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
22
+
23
+ # Send to UnifiedMemory (if configured)
24
+ if [ -n "$PROJECT_ID" ]; then
25
+ curl -s -X POST "${mcpUrl}/hooks/pre-submit" \\
26
+ -H "Content-Type: application/json" \\
27
+ -H "Authorization: Bearer ${accessToken}" \\
28
+ -H "X-Project-Id: $PROJECT_ID" \\
29
+ -H "X-Org-Id: $ORG_ID" \\
30
+ -d "{
31
+ \\"event\\": \\"pre_response\\",
32
+ \\"timestamp\\": \\"$TIMESTAMP\\",
33
+ \\"conversation_id\\": \\"$CONVERSATION_ID\\",
34
+ \\"working_directory\\": \\"$WORKING_DIR\\",
35
+ \\"user_message\\": \\"$USER_MESSAGE\\",
36
+ \\"project_id\\": \\"$PROJECT_ID\\"
37
+ }" >/dev/null 2>&1
38
+ fi
39
+
40
+ # Pass through original data
41
+ echo "$HOOK_DATA"
42
+ `;
43
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * MCP Proxy Client - Forwards MCP requests to the gateway HTTP endpoint
3
+ */
4
+
5
+ const GATEWAY_MCP_URL = 'https://rose-asp-main-1c0b114.d2.zuplo.dev/mcp';
6
+
7
+ /**
8
+ * Transform tool schema to hide context parameters (org, proj, user)
9
+ * These will be auto-injected from local config files
10
+ * @param {Object} tool - Tool definition with inputSchema
11
+ * @returns {Object} - Transformed tool with hidden context params
12
+ */
13
+ function transformToolSchema(tool) {
14
+ // Deep clone to avoid mutations
15
+ const transformed = JSON.parse(JSON.stringify(tool));
16
+
17
+ // Check if tool has pathParams in schema
18
+ if (!transformed.inputSchema?.properties?.pathParams) {
19
+ return transformed;
20
+ }
21
+
22
+ const pathParams = transformed.inputSchema.properties.pathParams;
23
+
24
+ // Remove org, proj, and user from properties
25
+ const contextParams = ['org', 'proj', 'user'];
26
+ contextParams.forEach(param => {
27
+ if (pathParams.properties) {
28
+ delete pathParams.properties[param];
29
+ }
30
+ });
31
+
32
+ // Remove from required array
33
+ if (pathParams.required && Array.isArray(pathParams.required)) {
34
+ pathParams.required = pathParams.required.filter(
35
+ field => !contextParams.includes(field)
36
+ );
37
+
38
+ // Remove empty required array
39
+ if (pathParams.required.length === 0) {
40
+ delete pathParams.required;
41
+ }
42
+ }
43
+
44
+ // If pathParams is now empty, remove it entirely
45
+ const hasProps = pathParams.properties && Object.keys(pathParams.properties).length > 0;
46
+ const hasRequired = pathParams.required && pathParams.required.length > 0;
47
+
48
+ if (!hasProps && !hasRequired) {
49
+ delete transformed.inputSchema.properties.pathParams;
50
+
51
+ // Also remove pathParams from schema's required array if present
52
+ if (transformed.inputSchema.required) {
53
+ transformed.inputSchema.required = transformed.inputSchema.required.filter(
54
+ field => field !== 'pathParams'
55
+ );
56
+
57
+ if (transformed.inputSchema.required.length === 0) {
58
+ delete transformed.inputSchema.required;
59
+ }
60
+ }
61
+ }
62
+
63
+ return transformed;
64
+ }
65
+
66
+ /**
67
+ * Inject context parameters into tool arguments
68
+ * @param {Object} args - Tool arguments from AI agent
69
+ * @param {Object} authContext - Auth context with user/org info
70
+ * @param {Object|null} projectContext - Project config from .um/config.json
71
+ * @returns {Object} - Arguments with injected context params
72
+ */
73
+ function injectContextParams(args, authContext, projectContext) {
74
+ // Deep clone to avoid mutations
75
+ const injected = JSON.parse(JSON.stringify(args || {}));
76
+
77
+ // Ensure pathParams object exists
78
+ if (!injected.pathParams) {
79
+ injected.pathParams = {};
80
+ }
81
+
82
+ // Inject user ID from decoded JWT
83
+ if (authContext?.decoded?.sub) {
84
+ injected.pathParams.user = authContext.decoded.sub;
85
+ }
86
+
87
+ // Inject org ID (from selectedOrg or fallback to user ID for personal account)
88
+ if (authContext?.selectedOrg?.id) {
89
+ injected.pathParams.org = authContext.selectedOrg.id;
90
+ } else if (authContext?.decoded?.sub) {
91
+ // Fallback to user ID for personal account context
92
+ injected.pathParams.org = authContext.decoded.sub;
93
+ }
94
+
95
+ // Inject project ID from project config
96
+ if (projectContext?.project_id) {
97
+ injected.pathParams.proj = projectContext.project_id;
98
+ }
99
+
100
+ return injected;
101
+ }
102
+
103
+ /**
104
+ * Fetch available tools from remote MCP endpoint
105
+ * @param {Object} authHeaders - Authentication headers
106
+ * @param {Object|null} projectContext - Project context (for future use)
107
+ * @returns {Promise<Object>} - Tools list response with transformed schemas
108
+ */
109
+ export async function fetchRemoteMCPTools(authHeaders, projectContext = null) {
110
+ try {
111
+ const response = await fetch(GATEWAY_MCP_URL, {
112
+ method: 'POST',
113
+ headers: {
114
+ 'Content-Type': 'application/json',
115
+ 'Accept': 'application/json, text/event-stream',
116
+ ...authHeaders,
117
+ },
118
+ body: JSON.stringify({
119
+ jsonrpc: '2.0',
120
+ id: 1,
121
+ method: 'tools/list',
122
+ params: {},
123
+ }),
124
+ });
125
+
126
+ if (!response.ok) {
127
+ const errorText = await response.text();
128
+ throw new Error(formatError(response.status, errorText));
129
+ }
130
+
131
+ const result = await response.json();
132
+
133
+ // MCP protocol returns {jsonrpc, id, result: {tools: [...]}}
134
+ if (result.error) {
135
+ throw new Error(`MCP error: ${result.error.message || JSON.stringify(result.error)}`);
136
+ }
137
+
138
+ // Transform tool schemas to hide context parameters
139
+ const toolsResult = result.result || { tools: [] };
140
+ if (toolsResult.tools && Array.isArray(toolsResult.tools)) {
141
+ toolsResult.tools = toolsResult.tools.map(transformToolSchema);
142
+ }
143
+
144
+ return toolsResult;
145
+ } catch (error) {
146
+ if (error.message.includes('fetch')) {
147
+ throw new Error('Cannot connect to UnifiedMemory API. Check your internet connection.');
148
+ }
149
+ throw error;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Call a tool on the remote MCP endpoint
155
+ * @param {string} toolName - Tool name
156
+ * @param {Object} toolArgs - Tool arguments
157
+ * @param {Object} authHeaders - Authentication headers
158
+ * @param {Object} authContext - Auth context with user/org info
159
+ * @param {Object|null} projectContext - Project config from .um/config.json
160
+ * @returns {Promise<Object>} - Tool execution result
161
+ */
162
+ export async function callRemoteMCPTool(toolName, toolArgs, authHeaders, authContext, projectContext = null) {
163
+ try {
164
+ // Inject context parameters before sending to gateway
165
+ const injectedArgs = injectContextParams(toolArgs, authContext, projectContext);
166
+
167
+ const response = await fetch(GATEWAY_MCP_URL, {
168
+ method: 'POST',
169
+ headers: {
170
+ 'Content-Type': 'application/json',
171
+ 'Accept': 'application/json, text/event-stream',
172
+ ...authHeaders,
173
+ },
174
+ body: JSON.stringify({
175
+ jsonrpc: '2.0',
176
+ id: 1,
177
+ method: 'tools/call',
178
+ params: {
179
+ name: toolName,
180
+ arguments: injectedArgs,
181
+ },
182
+ }),
183
+ });
184
+
185
+ if (!response.ok) {
186
+ const errorText = await response.text();
187
+ throw new Error(formatError(response.status, errorText));
188
+ }
189
+
190
+ const result = await response.json();
191
+
192
+ // Check for MCP error response
193
+ if (result.error) {
194
+ throw new Error(`Tool execution failed: ${result.error.message || JSON.stringify(result.error)}`);
195
+ }
196
+
197
+ return result.result || {};
198
+ } catch (error) {
199
+ if (error.message.includes('fetch')) {
200
+ throw new Error('Cannot connect to UnifiedMemory API. Check your internet connection.');
201
+ }
202
+ throw error;
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Format HTTP error into user-friendly message
208
+ * @param {number} status - HTTP status code
209
+ * @param {string} errorText - Error response body
210
+ * @returns {string} - Formatted error message
211
+ */
212
+ function formatError(status, errorText) {
213
+ switch (status) {
214
+ case 401:
215
+ return 'Authentication failed. Token may be expired. Run: um login';
216
+ case 403:
217
+ return 'Access denied. Check organization/project permissions.';
218
+ case 404:
219
+ return 'Endpoint not found. Please check API configuration.';
220
+ case 500:
221
+ case 502:
222
+ case 503:
223
+ return `Server error (${status}): ${errorText || 'Unknown error'}`;
224
+ default:
225
+ return `Gateway error (${status}): ${errorText || 'Unknown error'}`;
226
+ }
227
+ }