latitude-mcp-server 3.2.2 → 3.3.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/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,13 +46,26 @@ 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>;
57
+ /**
58
+ * Normalize document path for consistent comparison.
59
+ * Handles leading/trailing slashes, whitespace, and ensures consistent format.
60
+ * API may return paths with or without leading slash - this normalizes them.
61
+ */
62
+ export declare function normalizePath(path: string): string;
49
63
  /**
50
64
  * Compute diff between incoming prompts and existing prompts
51
65
  * Returns only the changes that need to be made
66
+ *
67
+ * IMPORTANT: Uses normalized paths and content hashes for fast comparison.
68
+ * Handles API inconsistencies where paths may have leading slashes.
52
69
  */
53
70
  export declare function computeDiff(incoming: Array<{
54
71
  path: string;
package/dist/api.js CHANGED
@@ -16,12 +16,15 @@ 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;
22
+ exports.normalizePath = normalizePath;
21
23
  exports.computeDiff = computeDiff;
22
24
  exports.runDocument = runDocument;
23
25
  exports.validatePromptLContent = validatePromptLContent;
24
26
  exports.deployToLive = deployToLive;
27
+ const crypto_1 = require("crypto");
25
28
  const logger_util_js_1 = require("./utils/logger.util.js");
26
29
  const config_util_js_1 = require("./utils/config.util.js");
27
30
  const promptl_ai_1 = require("promptl-ai");
@@ -278,40 +281,97 @@ async function listDocuments(versionUuid = 'live') {
278
281
  const projectId = getProjectId();
279
282
  return request(`/projects/${projectId}/versions/${versionUuid}/documents`);
280
283
  }
284
+ /**
285
+ * Compute SHA-256 hash of content (matches Latitude's contentHash)
286
+ */
287
+ function computeContentHash(content) {
288
+ return (0, crypto_1.createHash)('sha256').update(content).digest('hex');
289
+ }
281
290
  async function getDocument(path, versionUuid = 'live') {
282
291
  const projectId = getProjectId();
283
292
  const normalizedPath = path.startsWith('/') ? path.slice(1) : path;
284
293
  return request(`/projects/${projectId}/versions/${versionUuid}/documents/${normalizedPath}`);
285
294
  }
286
295
  /**
287
- * Push changes to a version in a single batch
288
- * This is the CLI-style push that sends all changes at once
296
+ * Push changes to a version
297
+ *
298
+ * IMPORTANT: The Latitude API /push endpoint has a bug where 'modified' status
299
+ * fails with "A document with the same path already exists" error for inherited
300
+ * documents in drafts. Workaround: for modified docs, we first delete then add
301
+ * in sequential push calls.
289
302
  */
290
303
  async function pushChanges(versionUuid, changes) {
291
304
  const projectId = getProjectId();
292
- // Format changes for the API
293
- const apiChanges = changes.map((c) => ({
305
+ // Separate changes by type
306
+ const modified = changes.filter((c) => c.status === 'modified');
307
+ const nonModified = changes.filter((c) => c.status !== 'modified');
308
+ let totalProcessed = 0;
309
+ // Step 1: If there are modified docs, delete them first
310
+ if (modified.length > 0) {
311
+ const deleteChanges = modified.map((c) => ({
312
+ path: c.path,
313
+ content: '',
314
+ status: 'deleted',
315
+ }));
316
+ logger.info(`Deleting ${modified.length} modified doc(s) before re-adding...`);
317
+ await request(`/projects/${projectId}/versions/${versionUuid}/push`, {
318
+ method: 'POST',
319
+ body: { changes: deleteChanges },
320
+ });
321
+ }
322
+ // Step 2: Push all adds (including modified-as-add) and deletes
323
+ const addChanges = modified.map((c) => ({
324
+ path: c.path,
325
+ content: c.content || '',
326
+ status: 'added',
327
+ }));
328
+ const otherChanges = nonModified.map((c) => ({
294
329
  path: c.path,
295
330
  content: c.content || '',
296
331
  status: c.status,
297
332
  }));
298
- logger.info(`Pushing ${changes.length} change(s) to version ${versionUuid}`);
299
- return request(`/projects/${projectId}/versions/${versionUuid}/push`, {
300
- method: 'POST',
301
- body: { changes: apiChanges },
302
- });
333
+ const allChanges = [...addChanges, ...otherChanges];
334
+ if (allChanges.length > 0) {
335
+ logger.info(`Pushing ${allChanges.length} change(s) to version ${versionUuid}`);
336
+ const result = await request(`/projects/${projectId}/versions/${versionUuid}/push`, {
337
+ method: 'POST',
338
+ body: { changes: allChanges },
339
+ });
340
+ totalProcessed = result.documentsProcessed;
341
+ }
342
+ return {
343
+ versionUuid,
344
+ documentsProcessed: totalProcessed || changes.length,
345
+ };
346
+ }
347
+ /**
348
+ * Normalize document path for consistent comparison.
349
+ * Handles leading/trailing slashes, whitespace, and ensures consistent format.
350
+ * API may return paths with or without leading slash - this normalizes them.
351
+ */
352
+ function normalizePath(path) {
353
+ return path
354
+ .trim()
355
+ .replace(/^\/+/, '') // Remove leading slashes
356
+ .replace(/\/+$/, '') // Remove trailing slashes
357
+ .replace(/\/+/g, '/'); // Collapse multiple slashes
303
358
  }
304
359
  /**
305
360
  * Compute diff between incoming prompts and existing prompts
306
361
  * Returns only the changes that need to be made
362
+ *
363
+ * IMPORTANT: Uses normalized paths and content hashes for fast comparison.
364
+ * Handles API inconsistencies where paths may have leading slashes.
307
365
  */
308
366
  function computeDiff(incoming, existing) {
309
367
  const changes = [];
310
- const existingMap = new Map(existing.map((d) => [d.path, d]));
311
- const incomingPaths = new Set(incoming.map((p) => p.path));
368
+ // Build map with NORMALIZED paths as keys, storing path + contentHash for fast comparison
369
+ const existingMap = new Map(existing.map((d) => [normalizePath(d.path), { path: d.path, contentHash: d.contentHash }]));
370
+ const incomingPaths = new Set(incoming.map((p) => normalizePath(p.path)));
312
371
  // Check each incoming prompt
313
372
  for (const prompt of incoming) {
314
- const existingDoc = existingMap.get(prompt.path);
373
+ const normalizedPath = normalizePath(prompt.path);
374
+ const existingDoc = existingMap.get(normalizedPath);
315
375
  if (!existingDoc) {
316
376
  // New prompt
317
377
  changes.push({
@@ -320,21 +380,25 @@ function computeDiff(incoming, existing) {
320
380
  status: 'added',
321
381
  });
322
382
  }
323
- else if (existingDoc.content !== prompt.content) {
324
- // Modified prompt
325
- changes.push({
326
- path: prompt.path,
327
- content: prompt.content,
328
- status: 'modified',
329
- });
383
+ else {
384
+ // Compare hashes for speed (avoid comparing large content strings)
385
+ const localHash = computeContentHash(prompt.content);
386
+ if (existingDoc.contentHash !== localHash) {
387
+ // Modified prompt - use the EXISTING path to ensure API compatibility
388
+ changes.push({
389
+ path: existingDoc.path,
390
+ content: prompt.content,
391
+ status: 'modified',
392
+ });
393
+ }
330
394
  }
331
- // If content is same, no change needed (don't include in changes)
395
+ // If same hash, no change needed (don't include in changes)
332
396
  }
333
397
  // Check for deleted prompts (exist remotely but not in incoming)
334
- for (const path of existingMap.keys()) {
335
- if (!incomingPaths.has(path)) {
398
+ for (const [normalizedExistingPath, doc] of existingMap.entries()) {
399
+ if (!incomingPaths.has(normalizedExistingPath)) {
336
400
  changes.push({
337
- path,
401
+ path: doc.path,
338
402
  content: '',
339
403
  status: 'deleted',
340
404
  });
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,23 +423,29 @@ 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
- const existingMap = new Map(existingDocs.map((d) => [d.path, d]));
428
+ // Use NORMALIZED paths as keys for reliable matching (handles /path vs path inconsistencies)
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 }]));
421
431
  // Build changes - ALWAYS overwrite if exists, add if new, NEVER delete
432
+ // Uses hash comparison for speed (avoids comparing 100KB+ content strings)
422
433
  const changes = [];
423
434
  for (const prompt of prompts) {
424
- const existingDoc = existingMap.get(prompt.name);
435
+ const normalizedName = (0, api_js_1.normalizePath)(prompt.name);
436
+ const existingDoc = existingMap.get(normalizedName);
425
437
  if (existingDoc) {
426
- // Only include if content is different
427
- 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) {
441
+ // Use the EXISTING path from API to ensure compatibility
428
442
  changes.push({
429
- path: prompt.name,
443
+ path: existingDoc.path,
430
444
  content: prompt.content,
431
445
  status: 'modified',
432
446
  });
433
447
  }
434
- // If same content, skip silently (unchanged)
448
+ // If same hash, skip silently (unchanged)
435
449
  }
436
450
  else {
437
451
  // New prompt
@@ -446,9 +460,10 @@ async function handleAddPrompt(args) {
446
460
  const added = changes.filter((c) => c.status === 'added');
447
461
  const modified = changes.filter((c) => c.status === 'modified');
448
462
  if (changes.length === 0) {
449
- const newNames = await forceRefreshAndGetNames();
463
+ // No changes - reuse existingDocs instead of another API call
464
+ const currentNames = existingDocs.map(d => d.path);
450
465
  return formatSuccess('No Changes Needed', `All ${prompts.length} prompt(s) are already up to date.\n\n` +
451
- `**Current LIVE prompts (${newNames.length}):** ${newNames.map(n => `\`${n}\``).join(', ')}`);
466
+ `**Current LIVE prompts (${currentNames.length}):** ${currentNames.map(n => `\`${n}\``).join(', ')}`);
452
467
  }
453
468
  // Push all changes in one batch
454
469
  try {
@@ -501,18 +516,17 @@ async function handlePullPrompts(args) {
501
516
  for (const file of existingFiles) {
502
517
  (0, fs_1.unlinkSync)((0, path_1.join)(outputDir, file));
503
518
  }
504
- // Get all prompts from LIVE
519
+ // Get all prompts from LIVE (includes full content)
505
520
  const docs = await (0, api_js_1.listDocuments)('live');
506
521
  if (docs.length === 0) {
507
522
  return formatSuccess('No Prompts to Pull', 'The project has no prompts.');
508
523
  }
509
- // Fetch full content and write files
524
+ // Write files directly - listDocuments already returns full content
510
525
  const written = [];
511
526
  for (const doc of docs) {
512
- const fullDoc = await (0, api_js_1.getDocument)(doc.path, 'live');
513
527
  const filename = `${doc.path.replace(/\//g, '_')}.promptl`;
514
528
  const filepath = (0, path_1.join)(outputDir, filename);
515
- (0, fs_1.writeFileSync)(filepath, fullDoc.content, 'utf-8');
529
+ (0, fs_1.writeFileSync)(filepath, doc.content, 'utf-8');
516
530
  written.push(filename);
517
531
  }
518
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.2",
3
+ "version": "3.3.0",
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
- }