@recall_v3/mcp-server 0.1.0 → 3.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/src/cli.ts ADDED
@@ -0,0 +1,334 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Recall v3 CLI
5
+ *
6
+ * Command-line interface for Recall MCP operations.
7
+ * Used by Claude Code hooks and for manual operations.
8
+ *
9
+ * Commands:
10
+ * recall-mcp context Load and display team context
11
+ * recall-mcp save Save current session
12
+ * recall-mcp status Check authentication status
13
+ */
14
+
15
+ import { getContext } from './tools/getContext.js';
16
+ import { saveSession } from './tools/saveSession.js';
17
+ import { getApiBaseUrl, getApiToken, getConfigPath } from './config/index.js';
18
+ import { RecallApiClient } from './api/client.js';
19
+
20
+ // Parse command line arguments
21
+ const args = process.argv.slice(2);
22
+ const command = args[0];
23
+ const flags = args.slice(1);
24
+
25
+ // Check for --quiet flag
26
+ const quiet = flags.includes('--quiet') || flags.includes('-q');
27
+
28
+ // Helper to log only if not quiet
29
+ function log(message: string): void {
30
+ if (!quiet) {
31
+ console.log(message);
32
+ }
33
+ }
34
+
35
+ // Helper to log errors (always shown)
36
+ function logError(message: string): void {
37
+ console.error(message);
38
+ }
39
+
40
+ /**
41
+ * Parse --project-path flag from args
42
+ */
43
+ function getProjectPathFromFlags(flagList: string[]): string | undefined {
44
+ const projectPathIndex = flagList.findIndex(f => f === '--project-path' || f === '-p');
45
+ if (projectPathIndex !== -1 && flagList[projectPathIndex + 1]) {
46
+ return flagList[projectPathIndex + 1];
47
+ }
48
+ return undefined;
49
+ }
50
+
51
+ // Parse --project-path flag
52
+ const projectPath = getProjectPathFromFlags(flags);
53
+
54
+ /**
55
+ * Display usage information
56
+ */
57
+ function showHelp(): void {
58
+ console.log(`
59
+ Recall v3 CLI - Team memory for AI coding assistants
60
+
61
+ Usage:
62
+ recall-mcp <command> [options]
63
+
64
+ Commands:
65
+ context Load team context for current repository
66
+ save Save current session (with AI summarization)
67
+ status Check authentication and connection status
68
+ help Show this help message
69
+
70
+ Options:
71
+ --quiet, -q Suppress non-essential output
72
+ --summary, -s Provide a manual summary (skips AI summarization)
73
+ --project-path, -p PATH Specify the git repository path (defaults to cwd)
74
+
75
+ Save Command:
76
+ The save command supports three modes:
77
+
78
+ 1. AI Summarization (recommended):
79
+ echo "<conversation transcript>" | recall-mcp save
80
+ - Pipes conversation to API for AI-powered summarization
81
+ - Extracts decisions, mistakes, files changed automatically
82
+
83
+ 2. Manual Summary:
84
+ recall-mcp save --summary "Fixed authentication bug"
85
+ - Use when you want to write your own summary
86
+
87
+ 3. Placeholder (fallback):
88
+ recall-mcp save --quiet
89
+ - Saves minimal placeholder when no transcript available
90
+ - Used by Claude Code stop hooks as a backup
91
+
92
+ Examples:
93
+ recall-mcp context # Load context at session start
94
+ recall-mcp save --quiet # Save session quietly (for hooks)
95
+ recall-mcp save --summary "Fixed X" # Save with manual summary
96
+ cat transcript.txt | recall-mcp save # Save with AI summarization
97
+ recall-mcp save -p /path/to/repo --quiet # Save for specific repo (Claude Code hooks)
98
+ recall-mcp context --project-path /path/to/repo # Load context for specific repo
99
+ recall-mcp status # Check if properly configured
100
+
101
+ Environment Variables:
102
+ RECALL_API_URL API base URL (default: https://api-v3.recall.team)
103
+ RECALL_API_TOKEN Authentication token (required)
104
+ `);
105
+ }
106
+
107
+ /**
108
+ * Load and display team context
109
+ */
110
+ async function runContext(): Promise<void> {
111
+ try {
112
+ const result = await getContext({ projectPath });
113
+
114
+ if (result.isError) {
115
+ logError(`Error: ${result.content[0]?.text || 'Unknown error'}`);
116
+ process.exit(1);
117
+ }
118
+
119
+ // Output the context (always, even in quiet mode - it's the primary output)
120
+ console.log(result.content[0]?.text || '');
121
+
122
+ } catch (error) {
123
+ const message = error instanceof Error ? error.message : 'Unknown error';
124
+ logError(`Failed to load context: ${message}`);
125
+ process.exit(1);
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Read from stdin if available (non-blocking)
131
+ * Handles large transcripts (Claude Code sessions can be 1MB+)
132
+ */
133
+ async function readStdin(): Promise<string | null> {
134
+ return new Promise((resolve) => {
135
+ // Check if stdin has data (is a pipe, not TTY)
136
+ if (process.stdin.isTTY) {
137
+ resolve(null);
138
+ return;
139
+ }
140
+
141
+ const chunks: string[] = [];
142
+ let resolved = false;
143
+
144
+ // Initial timeout - wait up to 500ms for first data
145
+ let timeout = setTimeout(() => {
146
+ if (!resolved) {
147
+ resolved = true;
148
+ resolve(chunks.length > 0 ? chunks.join('') : null);
149
+ }
150
+ }, 500);
151
+
152
+ process.stdin.setEncoding('utf8');
153
+
154
+ process.stdin.on('data', (chunk: string) => {
155
+ // Clear and reset timeout on each chunk (allows large files)
156
+ clearTimeout(timeout);
157
+ chunks.push(chunk);
158
+
159
+ // Set a shorter timeout between chunks (100ms idle = end of data)
160
+ timeout = setTimeout(() => {
161
+ if (!resolved) {
162
+ resolved = true;
163
+ resolve(chunks.join(''));
164
+ }
165
+ }, 100);
166
+ });
167
+
168
+ process.stdin.on('end', () => {
169
+ clearTimeout(timeout);
170
+ if (!resolved) {
171
+ resolved = true;
172
+ resolve(chunks.length > 0 ? chunks.join('') : null);
173
+ }
174
+ });
175
+
176
+ process.stdin.on('error', () => {
177
+ clearTimeout(timeout);
178
+ if (!resolved) {
179
+ resolved = true;
180
+ resolve(null);
181
+ }
182
+ });
183
+
184
+ // Start reading
185
+ process.stdin.resume();
186
+ });
187
+ }
188
+
189
+ /**
190
+ * Save current session
191
+ *
192
+ * Supports two modes:
193
+ * 1. With transcript via stdin: `echo "conversation..." | recall-mcp save`
194
+ * - Sends transcript to API for AI summarization
195
+ * 2. Without transcript: `recall-mcp save`
196
+ * - Saves a minimal placeholder (fallback)
197
+ *
198
+ * Usage with --summary flag: `recall-mcp save --summary "What we did"`
199
+ */
200
+ async function runSave(): Promise<void> {
201
+ try {
202
+ // Check for --summary flag
203
+ const summaryIndex = flags.findIndex(f => f === '--summary' || f === '-s');
204
+ let providedSummary: string | undefined;
205
+ if (summaryIndex !== -1 && flags[summaryIndex + 1]) {
206
+ providedSummary = flags[summaryIndex + 1];
207
+ }
208
+
209
+ // Try to read transcript from stdin
210
+ const transcript = await readStdin();
211
+
212
+ // Build saveSession args
213
+ const saveArgs: { summary?: string; transcript?: string; projectPath?: string } = {};
214
+
215
+ // Add project path if specified
216
+ if (projectPath) {
217
+ saveArgs.projectPath = projectPath;
218
+ }
219
+
220
+ if (transcript && transcript.trim().length >= 50) {
221
+ // We have a transcript - send it for AI summarization
222
+ saveArgs.transcript = transcript;
223
+ log('Summarizing session with AI...');
224
+ } else if (providedSummary) {
225
+ // Use provided summary
226
+ saveArgs.summary = providedSummary;
227
+ } else {
228
+ // Fallback to placeholder - this is the minimal save case
229
+ saveArgs.summary = 'Session auto-saved by Recall hook';
230
+ }
231
+
232
+ const result = await saveSession(saveArgs);
233
+
234
+ if (result.isError) {
235
+ logError(`Error: ${result.content[0]?.text || 'Unknown error'}`);
236
+ process.exit(1);
237
+ }
238
+
239
+ log(result.content[0]?.text || 'Session saved');
240
+
241
+ } catch (error) {
242
+ const message = error instanceof Error ? error.message : 'Unknown error';
243
+ logError(`Failed to save session: ${message}`);
244
+ process.exit(1);
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Check authentication and connection status
250
+ */
251
+ async function runStatus(): Promise<void> {
252
+ try {
253
+ const token = getApiToken();
254
+ const baseUrl = getApiBaseUrl();
255
+
256
+ console.log('Recall v3 Status');
257
+ console.log('================');
258
+ console.log(`Config: ${getConfigPath()}`);
259
+ console.log(`API URL: ${baseUrl}`);
260
+ console.log(`Token: ${token ? `${token.substring(0, 10)}...` : 'Not set'}`);
261
+
262
+ if (!token) {
263
+ logError('\nError: RECALL_API_TOKEN not set');
264
+ logError(`Set the environment variable or configure ${getConfigPath()}`);
265
+ process.exit(1);
266
+ }
267
+
268
+ // Test API connection
269
+ const client = new RecallApiClient({
270
+ baseUrl,
271
+ token,
272
+ });
273
+
274
+ console.log('\nTesting API connection...');
275
+
276
+ try {
277
+ const teams = await client.listTeams();
278
+ console.log(`✓ Connected successfully`);
279
+ console.log(`✓ Teams: ${teams.teams?.length || 0}`);
280
+
281
+ if (teams.teams && teams.teams.length > 0) {
282
+ for (const team of teams.teams) {
283
+ console.log(` - ${team.name} (${team.role})`);
284
+ }
285
+ }
286
+ } catch (apiError) {
287
+ const message = apiError instanceof Error ? apiError.message : 'Unknown error';
288
+ logError(`✗ API connection failed: ${message}`);
289
+ process.exit(1);
290
+ }
291
+
292
+ } catch (error) {
293
+ const message = error instanceof Error ? error.message : 'Unknown error';
294
+ logError(`Status check failed: ${message}`);
295
+ process.exit(1);
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Main entry point
301
+ */
302
+ async function main(): Promise<void> {
303
+ switch (command) {
304
+ case 'context':
305
+ await runContext();
306
+ break;
307
+
308
+ case 'save':
309
+ await runSave();
310
+ break;
311
+
312
+ case 'status':
313
+ await runStatus();
314
+ break;
315
+
316
+ case 'help':
317
+ case '--help':
318
+ case '-h':
319
+ case undefined:
320
+ showHelp();
321
+ break;
322
+
323
+ default:
324
+ logError(`Unknown command: ${command}`);
325
+ logError('Run "recall-mcp help" for usage information');
326
+ process.exit(1);
327
+ }
328
+ }
329
+
330
+ // Run CLI
331
+ main().catch((error) => {
332
+ logError(`Fatal error: ${error instanceof Error ? error.message : 'Unknown error'}`);
333
+ process.exit(1);
334
+ });
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Recall Configuration Management
2
+ * Recall v3 Configuration Management
3
3
  *
4
- * Manages the local config file at ~/.recall/config.json.
4
+ * Manages the local config file at ~/.config/recall/config.json.
5
+ * This is intentionally SEPARATE from v2's ~/.recall/ directory.
5
6
  * Handles secure storage of API tokens and team keys.
6
7
  */
7
8
 
@@ -16,11 +17,26 @@ const CONFIG_VERSION = 1;
16
17
  // Default API base URL (v3)
17
18
  const DEFAULT_API_BASE_URL = 'https://api-v3.recall.team';
18
19
 
20
+ // v3 uses ~/.config/recall/ (XDG-compliant, separate from v2's ~/.recall/)
21
+ const V3_CONFIG_DIR = path.join(os.homedir(), '.config', 'recall');
22
+
23
+ // v2 used ~/.recall/ - we check this for migration
24
+ const V2_CONFIG_DIR = path.join(os.homedir(), '.recall');
25
+
19
26
  /**
20
- * Get the path to the Recall config directory
27
+ * Get the path to the Recall v3 config directory
28
+ * Uses ~/.config/recall/ (XDG-compliant, separate from v2)
21
29
  */
22
30
  export function getConfigDir(): string {
23
- return path.join(os.homedir(), '.recall');
31
+ return V3_CONFIG_DIR;
32
+ }
33
+
34
+ /**
35
+ * Get the path to the legacy v2 config directory
36
+ * (used only for migration checking)
37
+ */
38
+ export function getLegacyConfigDir(): string {
39
+ return V2_CONFIG_DIR;
24
40
  }
25
41
 
26
42
  /**
@@ -68,11 +84,75 @@ function migrateConfig(config: Partial<RecallConfig>): RecallConfig {
68
84
  return migrated;
69
85
  }
70
86
 
87
+ /**
88
+ * Check if a config file at a given path is a v3 config
89
+ * v3 configs have teamKeys and a version field (v2 didn't)
90
+ */
91
+ function isV3Config(config: Record<string, unknown>): boolean {
92
+ // v3 configs always have version field
93
+ return typeof config.version === 'number' && config.version >= 1;
94
+ }
95
+
96
+ /**
97
+ * Migrate v3 config from old v2 location (~/.recall/) to new v3 location (~/.config/recall/)
98
+ * This only migrates if:
99
+ * 1. No config exists at new v3 location
100
+ * 2. A v3 config exists at old v2 location (has version field)
101
+ *
102
+ * v2 configs (without version field) are left alone - they belong to v2
103
+ */
104
+ function migrateFromV2Location(): void {
105
+ const v3ConfigPath = getConfigPath();
106
+ const v2ConfigPath = path.join(getLegacyConfigDir(), 'config.json');
107
+
108
+ // If v3 config already exists at new location, nothing to migrate
109
+ if (fs.existsSync(v3ConfigPath)) {
110
+ return;
111
+ }
112
+
113
+ // If no config exists at old v2 location, nothing to migrate
114
+ if (!fs.existsSync(v2ConfigPath)) {
115
+ return;
116
+ }
117
+
118
+ try {
119
+ const raw = fs.readFileSync(v2ConfigPath, 'utf-8');
120
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
121
+
122
+ // Only migrate if it's a v3 config (has version field)
123
+ // v2 configs don't have version field and belong to v2
124
+ if (!isV3Config(parsed)) {
125
+ return;
126
+ }
127
+
128
+ // This is a v3 config in the old location - migrate it
129
+ console.log(`[Recall v3] Migrating config from ~/.recall/ to ~/.config/recall/`);
130
+
131
+ // Ensure new directory exists
132
+ ensureConfigDir();
133
+
134
+ // Copy config to new location
135
+ fs.writeFileSync(v3ConfigPath, JSON.stringify(parsed, null, 2), { mode: 0o600 });
136
+
137
+ // Remove config from old location (but leave other v2 artifacts)
138
+ // Note: We only remove config.json, not the whole directory
139
+ fs.unlinkSync(v2ConfigPath);
140
+
141
+ console.log(`[Recall v3] Migration complete. v3 config is now at ~/.config/recall/config.json`);
142
+ } catch (error) {
143
+ // Migration failed - not critical, user can manually migrate
144
+ console.warn(`[Recall v3] Could not migrate config from ~/.recall/ (non-critical)`);
145
+ }
146
+ }
147
+
71
148
  /**
72
149
  * Load configuration from disk
73
150
  * Returns a default config if the file doesn't exist
74
151
  */
75
152
  export function loadConfig(): RecallConfig {
153
+ // First, try to migrate from old v2 location if needed
154
+ migrateFromV2Location();
155
+
76
156
  const configPath = getConfigPath();
77
157
 
78
158
  if (!fs.existsSync(configPath)) {
@@ -205,3 +205,36 @@ export function encryptContent(plaintext: string, keyBase64: string): string {
205
205
  const payload = encrypt(plaintext, keyBase64);
206
206
  return JSON.stringify(payload);
207
207
  }
208
+
209
+ /**
210
+ * Encrypt content and return in Recall format: RECALL_ENCRYPTED:v1:iv:tag:ciphertext
211
+ * This is the format expected by the v3 API.
212
+ */
213
+ export function encryptForApi(plaintext: string, keyBase64: string): string {
214
+ const payload = encrypt(plaintext, keyBase64);
215
+ return `RECALL_ENCRYPTED:v1:${payload.iv}:${payload.tag}:${payload.ciphertext}`;
216
+ }
217
+
218
+ /**
219
+ * Decrypt content in Recall format: RECALL_ENCRYPTED:v1:iv:tag:ciphertext
220
+ * This is the format returned by the v3 API.
221
+ */
222
+ export function decryptFromApi(encrypted: string, keyBase64: string): string {
223
+ if (!encrypted.startsWith('RECALL_ENCRYPTED:')) {
224
+ throw new Error('Invalid encrypted format: must start with RECALL_ENCRYPTED:');
225
+ }
226
+
227
+ const parts = encrypted.split(':');
228
+ if (parts.length !== 5) {
229
+ throw new Error('Invalid encrypted format: expected RECALL_ENCRYPTED:v1:iv:tag:ciphertext');
230
+ }
231
+
232
+ const [, version, iv, tag, ciphertext] = parts;
233
+
234
+ if (version !== 'v1') {
235
+ throw new Error(`Unsupported encryption version: ${version}`);
236
+ }
237
+
238
+ const payload: EncryptedPayload = { iv, tag, ciphertext };
239
+ return decrypt(payload, keyBase64);
240
+ }
package/src/index.ts CHANGED
@@ -33,9 +33,9 @@ import {
33
33
  // Tool definitions
34
34
  const TOOLS = [
35
35
  {
36
- name: 'recall_get_context',
36
+ name: 'recall3_get_context',
37
37
  description:
38
- "Get team brain (context.md) for the current repository. This is the distilled current state - loads automatically at every session start. Use recall_get_history for the full encyclopedia.",
38
+ "Get team brain (context.md) for the current repository. This is the distilled current state - loads automatically at every session start. Use recall3_get_history for the full encyclopedia.",
39
39
  inputSchema: {
40
40
  type: 'object',
41
41
  properties: {
@@ -48,9 +48,9 @@ const TOOLS = [
48
48
  },
49
49
  },
50
50
  {
51
- name: 'recall_get_history',
51
+ name: 'recall3_get_history',
52
52
  description:
53
- "Get detailed session history (context.md + recent sessions). This includes more context than recall_get_context but uses more tokens.",
53
+ "Get detailed session history (context.md + recent sessions). This includes more context than recall3_get_context but uses more tokens.",
54
54
  inputSchema: {
55
55
  type: 'object',
56
56
  properties: {
@@ -64,7 +64,7 @@ const TOOLS = [
64
64
  },
65
65
  },
66
66
  {
67
- name: 'recall_get_transcripts',
67
+ name: 'recall3_get_transcripts',
68
68
  description:
69
69
  "Get full session transcripts (context.md + history.md). WARNING: This can be very large and use many tokens. Only use when you need complete historical details.",
70
70
  inputSchema: {
@@ -80,7 +80,7 @@ const TOOLS = [
80
80
  },
81
81
  },
82
82
  {
83
- name: 'recall_save_session',
83
+ name: 'recall3_save_session',
84
84
  description:
85
85
  "Save a summary of what was accomplished in this coding session. This updates the team memory files.",
86
86
  inputSchema: {
@@ -127,7 +127,7 @@ const TOOLS = [
127
127
  },
128
128
  },
129
129
  {
130
- name: 'recall_log_decision',
130
+ name: 'recall3_log_decision',
131
131
  description:
132
132
  "Log an important decision made during coding. Quick way to capture why something was done.",
133
133
  inputSchema: {
@@ -179,23 +179,23 @@ class RecallMCPServer {
179
179
  try {
180
180
  let result;
181
181
  switch (name) {
182
- case 'recall_get_context':
182
+ case 'recall3_get_context':
183
183
  result = await getContext(args as unknown as GetContextArgs);
184
184
  break;
185
185
 
186
- case 'recall_get_history':
186
+ case 'recall3_get_history':
187
187
  result = await getHistory(args as unknown as GetHistoryArgs);
188
188
  break;
189
189
 
190
- case 'recall_get_transcripts':
190
+ case 'recall3_get_transcripts':
191
191
  result = await getTranscripts(args as unknown as GetTranscriptsArgs);
192
192
  break;
193
193
 
194
- case 'recall_save_session':
194
+ case 'recall3_save_session':
195
195
  result = await saveSession(args as unknown as SaveSessionArgs);
196
196
  break;
197
197
 
198
- case 'recall_log_decision':
198
+ case 'recall3_log_decision':
199
199
  result = await logDecision(args as unknown as LogDecisionArgs);
200
200
  break;
201
201