@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
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();
|
package/lib/clerk-api.js
ADDED
|
@@ -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
|
+
}
|
package/lib/mcp-proxy.js
ADDED
|
@@ -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
|
+
}
|