latitude-mcp-server 3.2.3 → 3.3.1

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/dist/api.d.ts CHANGED
@@ -33,6 +33,10 @@ export declare class LatitudeApiError extends Error {
33
33
  */
34
34
  export declare function getProjectId(): string;
35
35
  export declare function listDocuments(versionUuid?: string): Promise<Document[]>;
36
+ /**
37
+ * Compute SHA-256 hash of content (matches Latitude's contentHash)
38
+ */
39
+ export declare function computeContentHash(content: string): string;
36
40
  export declare function getDocument(path: string, versionUuid?: string): Promise<Document>;
37
41
  /**
38
42
  * Push response from the API
@@ -42,8 +46,12 @@ interface PushResponse {
42
46
  documentsProcessed: number;
43
47
  }
44
48
  /**
45
- * Push changes to a version in a single batch
46
- * This is the CLI-style push that sends all changes at once
49
+ * Push changes to a version
50
+ *
51
+ * IMPORTANT: The Latitude API /push endpoint has a bug where 'modified' status
52
+ * fails with "A document with the same path already exists" error for inherited
53
+ * documents in drafts. Workaround: for modified docs, we first delete then add
54
+ * in sequential push calls.
47
55
  */
48
56
  export declare function pushChanges(versionUuid: string, changes: DocumentChange[]): Promise<PushResponse>;
49
57
  /**
@@ -56,8 +64,8 @@ export declare function normalizePath(path: string): string;
56
64
  * Compute diff between incoming prompts and existing prompts
57
65
  * Returns only the changes that need to be made
58
66
  *
59
- * IMPORTANT: Uses normalized paths to handle API inconsistencies where
60
- * paths may be returned with or without leading slashes.
67
+ * IMPORTANT: Uses normalized paths and content hashes for fast comparison.
68
+ * Handles API inconsistencies where paths may have leading slashes.
61
69
  */
62
70
  export declare function computeDiff(incoming: Array<{
63
71
  path: string;
package/dist/api.js CHANGED
@@ -16,6 +16,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
16
16
  exports.LatitudeApiError = void 0;
17
17
  exports.getProjectId = getProjectId;
18
18
  exports.listDocuments = listDocuments;
19
+ exports.computeContentHash = computeContentHash;
19
20
  exports.getDocument = getDocument;
20
21
  exports.pushChanges = pushChanges;
21
22
  exports.normalizePath = normalizePath;
@@ -23,6 +24,7 @@ exports.computeDiff = computeDiff;
23
24
  exports.runDocument = runDocument;
24
25
  exports.validatePromptLContent = validatePromptLContent;
25
26
  exports.deployToLive = deployToLive;
27
+ const crypto_1 = require("crypto");
26
28
  const logger_util_js_1 = require("./utils/logger.util.js");
27
29
  const config_util_js_1 = require("./utils/config.util.js");
28
30
  const promptl_ai_1 = require("promptl-ai");
@@ -277,7 +279,26 @@ async function publishVersion(versionUuid, title) {
277
279
  }
278
280
  async function listDocuments(versionUuid = 'live') {
279
281
  const projectId = getProjectId();
280
- return request(`/projects/${projectId}/versions/${versionUuid}/documents`);
282
+ try {
283
+ return await request(`/projects/${projectId}/versions/${versionUuid}/documents`);
284
+ }
285
+ catch (error) {
286
+ // Handle new projects with no LIVE version yet
287
+ // The API returns 404 "NotFoundError" with "head commit not found" message
288
+ if (error instanceof LatitudeApiError &&
289
+ error.statusCode === 404 &&
290
+ versionUuid === 'live') {
291
+ logger.info('No LIVE version exists yet (new project) - treating as empty');
292
+ return [];
293
+ }
294
+ throw error;
295
+ }
296
+ }
297
+ /**
298
+ * Compute SHA-256 hash of content (matches Latitude's contentHash)
299
+ */
300
+ function computeContentHash(content) {
301
+ return (0, crypto_1.createHash)('sha256').update(content).digest('hex');
281
302
  }
282
303
  async function getDocument(path, versionUuid = 'live') {
283
304
  const projectId = getProjectId();
@@ -285,22 +306,56 @@ async function getDocument(path, versionUuid = 'live') {
285
306
  return request(`/projects/${projectId}/versions/${versionUuid}/documents/${normalizedPath}`);
286
307
  }
287
308
  /**
288
- * Push changes to a version in a single batch
289
- * This is the CLI-style push that sends all changes at once
309
+ * Push changes to a version
310
+ *
311
+ * IMPORTANT: The Latitude API /push endpoint has a bug where 'modified' status
312
+ * fails with "A document with the same path already exists" error for inherited
313
+ * documents in drafts. Workaround: for modified docs, we first delete then add
314
+ * in sequential push calls.
290
315
  */
291
316
  async function pushChanges(versionUuid, changes) {
292
317
  const projectId = getProjectId();
293
- // Format changes for the API
294
- const apiChanges = changes.map((c) => ({
318
+ // Separate changes by type
319
+ const modified = changes.filter((c) => c.status === 'modified');
320
+ const nonModified = changes.filter((c) => c.status !== 'modified');
321
+ let totalProcessed = 0;
322
+ // Step 1: If there are modified docs, delete them first
323
+ if (modified.length > 0) {
324
+ const deleteChanges = modified.map((c) => ({
325
+ path: c.path,
326
+ content: '',
327
+ status: 'deleted',
328
+ }));
329
+ logger.info(`Deleting ${modified.length} modified doc(s) before re-adding...`);
330
+ await request(`/projects/${projectId}/versions/${versionUuid}/push`, {
331
+ method: 'POST',
332
+ body: { changes: deleteChanges },
333
+ });
334
+ }
335
+ // Step 2: Push all adds (including modified-as-add) and deletes
336
+ const addChanges = modified.map((c) => ({
337
+ path: c.path,
338
+ content: c.content || '',
339
+ status: 'added',
340
+ }));
341
+ const otherChanges = nonModified.map((c) => ({
295
342
  path: c.path,
296
343
  content: c.content || '',
297
344
  status: c.status,
298
345
  }));
299
- logger.info(`Pushing ${changes.length} change(s) to version ${versionUuid}`);
300
- return request(`/projects/${projectId}/versions/${versionUuid}/push`, {
301
- method: 'POST',
302
- body: { changes: apiChanges },
303
- });
346
+ const allChanges = [...addChanges, ...otherChanges];
347
+ if (allChanges.length > 0) {
348
+ logger.info(`Pushing ${allChanges.length} change(s) to version ${versionUuid}`);
349
+ const result = await request(`/projects/${projectId}/versions/${versionUuid}/push`, {
350
+ method: 'POST',
351
+ body: { changes: allChanges },
352
+ });
353
+ totalProcessed = result.documentsProcessed;
354
+ }
355
+ return {
356
+ versionUuid,
357
+ documentsProcessed: totalProcessed || changes.length,
358
+ };
304
359
  }
305
360
  /**
306
361
  * Normalize document path for consistent comparison.
@@ -318,13 +373,13 @@ function normalizePath(path) {
318
373
  * Compute diff between incoming prompts and existing prompts
319
374
  * Returns only the changes that need to be made
320
375
  *
321
- * IMPORTANT: Uses normalized paths to handle API inconsistencies where
322
- * paths may be returned with or without leading slashes.
376
+ * IMPORTANT: Uses normalized paths and content hashes for fast comparison.
377
+ * Handles API inconsistencies where paths may have leading slashes.
323
378
  */
324
379
  function computeDiff(incoming, existing) {
325
380
  const changes = [];
326
- // Build map with NORMALIZED paths as keys for reliable matching
327
- const existingMap = new Map(existing.map((d) => [normalizePath(d.path), d]));
381
+ // Build map with NORMALIZED paths as keys, storing path + contentHash for fast comparison
382
+ const existingMap = new Map(existing.map((d) => [normalizePath(d.path), { path: d.path, contentHash: d.contentHash }]));
328
383
  const incomingPaths = new Set(incoming.map((p) => normalizePath(p.path)));
329
384
  // Check each incoming prompt
330
385
  for (const prompt of incoming) {
@@ -338,15 +393,19 @@ function computeDiff(incoming, existing) {
338
393
  status: 'added',
339
394
  });
340
395
  }
341
- else if (existingDoc.content !== prompt.content) {
342
- // Modified prompt - use the EXISTING path to ensure API compatibility
343
- changes.push({
344
- path: existingDoc.path,
345
- content: prompt.content,
346
- status: 'modified',
347
- });
396
+ else {
397
+ // Compare hashes for speed (avoid comparing large content strings)
398
+ const localHash = computeContentHash(prompt.content);
399
+ if (existingDoc.contentHash !== localHash) {
400
+ // Modified prompt - use the EXISTING path to ensure API compatibility
401
+ changes.push({
402
+ path: existingDoc.path,
403
+ content: prompt.content,
404
+ status: 'modified',
405
+ });
406
+ }
348
407
  }
349
- // If content is same, no change needed (don't include in changes)
408
+ // If same hash, no change needed (don't include in changes)
350
409
  }
351
410
  // Check for deleted prompts (exist remotely but not in incoming)
352
411
  for (const [normalizedExistingPath, doc] of existingMap.entries()) {
package/dist/tools.js CHANGED
@@ -28,6 +28,8 @@ async function refreshPromptCache() {
28
28
  try {
29
29
  const docs = await (0, api_js_1.listDocuments)('live');
30
30
  cachedPromptNames = docs.map((d) => d.path);
31
+ // Also cache full documents for variable extraction (avoids extra API calls)
32
+ cachedDocuments = docs.map((d) => ({ path: d.path, content: d.content }));
31
33
  cacheLastUpdated = new Date();
32
34
  logger.debug(`Cache updated: ${cachedPromptNames.length} prompts`);
33
35
  return cachedPromptNames;
@@ -128,10 +130,14 @@ function extractVariables(content) {
128
130
  }
129
131
  return Array.from(variables);
130
132
  }
133
+ // Cache for full documents (path + content) to avoid repeated API calls
134
+ let cachedDocuments = [];
131
135
  /**
132
136
  * Build dynamic description for run_prompt with prompt names and their variables
137
+ * Uses cached documents to avoid individual API calls per prompt
133
138
  */
134
139
  async function buildRunPromptDescription() {
140
+ // Use cached documents if available, otherwise just show names
135
141
  const names = await getCachedPromptNames();
136
142
  let desc = 'Execute a prompt with parameters.';
137
143
  if (names.length === 0) {
@@ -139,12 +145,13 @@ async function buildRunPromptDescription() {
139
145
  return desc;
140
146
  }
141
147
  desc += `\n\n**Available prompts (${names.length}):**`;
142
- // Fetch each prompt to get its variables (limit to avoid too long description)
148
+ // Use cached documents for variable extraction (no extra API calls)
143
149
  const maxToShow = Math.min(names.length, 10);
150
+ const docMap = new Map(cachedDocuments.map(d => [d.path, d.content]));
144
151
  for (let i = 0; i < maxToShow; i++) {
145
- try {
146
- const doc = await (0, api_js_1.getDocument)(names[i], 'live');
147
- const vars = extractVariables(doc.content);
152
+ const content = docMap.get(names[i]);
153
+ if (content) {
154
+ const vars = extractVariables(content);
148
155
  if (vars.length > 0) {
149
156
  desc += `\n- \`${names[i]}\` (params: ${vars.map(v => `\`${v}\``).join(', ')})`;
150
157
  }
@@ -152,7 +159,7 @@ async function buildRunPromptDescription() {
152
159
  desc += `\n- \`${names[i]}\` (no params)`;
153
160
  }
154
161
  }
155
- catch {
162
+ else {
156
163
  desc += `\n- \`${names[i]}\``;
157
164
  }
158
165
  }
@@ -332,9 +339,10 @@ async function handlePushPrompts(args) {
332
339
  const modified = changes.filter((c) => c.status === 'modified');
333
340
  const deleted = changes.filter((c) => c.status === 'deleted');
334
341
  if (changes.length === 0) {
335
- const newNames = await forceRefreshAndGetNames();
342
+ // No changes - reuse existingDocs instead of another API call
343
+ const currentNames = existingDocs.map(d => d.path);
336
344
  return formatSuccess('No Changes Needed', `All ${prompts.length} prompt(s) are already up to date.\n\n` +
337
- `**Current LIVE prompts (${newNames.length}):** ${newNames.map(n => `\`${n}\``).join(', ')}`);
345
+ `**Current LIVE prompts (${currentNames.length}):** ${currentNames.map(n => `\`${n}\``).join(', ')}`);
338
346
  }
339
347
  // Push all changes in one batch
340
348
  try {
@@ -415,18 +423,21 @@ async function handleAddPrompt(args) {
415
423
  return formatValidationErrors(validation.errors);
416
424
  }
417
425
  logger.info(`All ${prompts.length} prompt(s) passed validation`);
418
- // Get existing prompts
426
+ // Get existing prompts (includes contentHash for fast comparison)
419
427
  const existingDocs = await (0, api_js_1.listDocuments)('live');
420
428
  // Use NORMALIZED paths as keys for reliable matching (handles /path vs path inconsistencies)
421
- const existingMap = new Map(existingDocs.map((d) => [(0, api_js_1.normalizePath)(d.path), d]));
429
+ // Map to { path, contentHash } for fast hash-based comparison
430
+ const existingMap = new Map(existingDocs.map((d) => [(0, api_js_1.normalizePath)(d.path), { path: d.path, contentHash: d.contentHash }]));
422
431
  // Build changes - ALWAYS overwrite if exists, add if new, NEVER delete
432
+ // Uses hash comparison for speed (avoids comparing 100KB+ content strings)
423
433
  const changes = [];
424
434
  for (const prompt of prompts) {
425
435
  const normalizedName = (0, api_js_1.normalizePath)(prompt.name);
426
436
  const existingDoc = existingMap.get(normalizedName);
427
437
  if (existingDoc) {
428
- // Only include if content is different
429
- if (existingDoc.content !== prompt.content) {
438
+ // Compare hashes instead of full content (much faster)
439
+ const localHash = (0, api_js_1.computeContentHash)(prompt.content);
440
+ if (existingDoc.contentHash !== localHash) {
430
441
  // Use the EXISTING path from API to ensure compatibility
431
442
  changes.push({
432
443
  path: existingDoc.path,
@@ -434,7 +445,7 @@ async function handleAddPrompt(args) {
434
445
  status: 'modified',
435
446
  });
436
447
  }
437
- // If same content, skip silently (unchanged)
448
+ // If same hash, skip silently (unchanged)
438
449
  }
439
450
  else {
440
451
  // New prompt
@@ -449,9 +460,10 @@ async function handleAddPrompt(args) {
449
460
  const added = changes.filter((c) => c.status === 'added');
450
461
  const modified = changes.filter((c) => c.status === 'modified');
451
462
  if (changes.length === 0) {
452
- const newNames = await forceRefreshAndGetNames();
463
+ // No changes - reuse existingDocs instead of another API call
464
+ const currentNames = existingDocs.map(d => d.path);
453
465
  return formatSuccess('No Changes Needed', `All ${prompts.length} prompt(s) are already up to date.\n\n` +
454
- `**Current LIVE prompts (${newNames.length}):** ${newNames.map(n => `\`${n}\``).join(', ')}`);
466
+ `**Current LIVE prompts (${currentNames.length}):** ${currentNames.map(n => `\`${n}\``).join(', ')}`);
455
467
  }
456
468
  // Push all changes in one batch
457
469
  try {
@@ -504,18 +516,17 @@ async function handlePullPrompts(args) {
504
516
  for (const file of existingFiles) {
505
517
  (0, fs_1.unlinkSync)((0, path_1.join)(outputDir, file));
506
518
  }
507
- // Get all prompts from LIVE
519
+ // Get all prompts from LIVE (includes full content)
508
520
  const docs = await (0, api_js_1.listDocuments)('live');
509
521
  if (docs.length === 0) {
510
522
  return formatSuccess('No Prompts to Pull', 'The project has no prompts.');
511
523
  }
512
- // Fetch full content and write files
524
+ // Write files directly - listDocuments already returns full content
513
525
  const written = [];
514
526
  for (const doc of docs) {
515
- const fullDoc = await (0, api_js_1.getDocument)(doc.path, 'live');
516
527
  const filename = `${doc.path.replace(/\//g, '_')}.promptl`;
517
528
  const filepath = (0, path_1.join)(outputDir, filename);
518
- (0, fs_1.writeFileSync)(filepath, fullDoc.content, 'utf-8');
529
+ (0, fs_1.writeFileSync)(filepath, doc.content, 'utf-8');
519
530
  written.push(filename);
520
531
  }
521
532
  // Update cache
package/dist/types.d.ts CHANGED
@@ -27,6 +27,7 @@ export interface Document {
27
27
  uuid: string;
28
28
  path: string;
29
29
  content: string;
30
+ contentHash?: string;
30
31
  config?: Record<string, unknown>;
31
32
  parameters?: Record<string, unknown>;
32
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "latitude-mcp-server",
3
- "version": "3.2.3",
3
+ "version": "3.3.1",
4
4
  "description": "Simplified MCP server for Latitude.so prompt management - 8 focused tools for push, pull, run, and manage prompts",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/.releaserc.json DELETED
@@ -1,34 +0,0 @@
1
- {
2
- "branches": ["main"],
3
- "plugins": [
4
- "@semantic-release/commit-analyzer",
5
- "@semantic-release/release-notes-generator",
6
- "@semantic-release/changelog",
7
- [
8
- "@semantic-release/exec",
9
- {
10
- "prepareCmd": "node scripts/update-version.js ${nextRelease.version} && npm run build && chmod +x dist/index.js"
11
- }
12
- ],
13
- [
14
- "@semantic-release/npm",
15
- {
16
- "npmPublish": true,
17
- "pkgRoot": "."
18
- }
19
- ],
20
- [
21
- "@semantic-release/git",
22
- {
23
- "assets": [
24
- "package.json",
25
- "CHANGELOG.md",
26
- "src/index.ts",
27
- "src/cli/index.ts"
28
- ],
29
- "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
30
- }
31
- ],
32
- "@semantic-release/github"
33
- ]
34
- }