@gethmy/mcp 2.2.4 → 2.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.
@@ -0,0 +1,455 @@
1
+ /**
2
+ * Unified Memory Cleanup
3
+ *
4
+ * Orchestrates a 5-stage cleanup pipeline: prune stale drafts, consolidate
5
+ * similar memories, detect orphans, detect duplicates, and backfill embeddings.
6
+ *
7
+ * All stages are non-fatal — individual failures are collected but never block
8
+ * the remaining stages. Defaults to dry-run mode (preview only).
9
+ */
10
+ import { evaluateLifecycle } from "@harmony/memory";
11
+ import { consolidateMemories, } from "./consolidation.js";
12
+ import { findSimilarEntities } from "./graph-expansion.js";
13
+ const ALL_STEPS = [
14
+ "prune",
15
+ "consolidate",
16
+ "orphans",
17
+ "duplicates",
18
+ "backfill",
19
+ ];
20
+ const MS_PER_DAY = 1000 * 60 * 60 * 24;
21
+ const MAX_ENTITIES_FETCH = 200;
22
+ const DUPLICATE_SIMILARITY_THRESHOLD = 0.85;
23
+ const CONCURRENCY_LIMIT = 5;
24
+ // ---------------------------------------------------------------------------
25
+ // Main orchestrator
26
+ // ---------------------------------------------------------------------------
27
+ export async function runMemoryCleanup(client, workspaceId, projectId, options) {
28
+ const dryRun = options?.dryRun !== false;
29
+ const steps = options?.steps ?? ALL_STEPS;
30
+ const maxAgeDays = options?.maxAgeDays ?? 30;
31
+ const minClusterSize = options?.minClusterSize ?? 3;
32
+ const orphanAgeDays = options?.orphanAgeDays ?? 14;
33
+ const report = {
34
+ success: true,
35
+ dryRun,
36
+ timestamp: new Date().toISOString(),
37
+ workspace: { id: workspaceId, projectId },
38
+ summary: { totalEntities: 0, issuesFound: 0, actionsTaken: 0 },
39
+ steps: {},
40
+ errors: [],
41
+ healthReport: "",
42
+ };
43
+ // Fetch all entities once (shared across steps)
44
+ let entities = [];
45
+ try {
46
+ const listResult = await client.listMemoryEntities({
47
+ workspace_id: workspaceId,
48
+ project_id: projectId,
49
+ limit: MAX_ENTITIES_FETCH,
50
+ });
51
+ entities = (listResult.entities || []);
52
+ report.summary.totalEntities = entities.length;
53
+ }
54
+ catch (err) {
55
+ report.errors.push({
56
+ step: "init",
57
+ message: `Failed to fetch entities: ${err.message}`,
58
+ });
59
+ report.success = false;
60
+ report.healthReport = generateHealthReport(report);
61
+ return report;
62
+ }
63
+ // Stage 1: Prune stale drafts
64
+ if (steps.includes("prune")) {
65
+ try {
66
+ report.steps.prune = runPruneStep(entities, maxAgeDays);
67
+ if (!dryRun) {
68
+ for (const item of report.steps.prune.items) {
69
+ try {
70
+ await client.deleteMemoryEntity(item.id);
71
+ report.steps.prune.pruned++;
72
+ }
73
+ catch (err) {
74
+ report.errors.push({
75
+ step: "prune",
76
+ message: `Failed to delete ${item.id}: ${err.message}`,
77
+ });
78
+ }
79
+ }
80
+ report.summary.actionsTaken += report.steps.prune.pruned;
81
+ }
82
+ report.summary.issuesFound += report.steps.prune.staleDraftsFound;
83
+ }
84
+ catch (err) {
85
+ report.errors.push({
86
+ step: "prune",
87
+ message: err.message,
88
+ });
89
+ }
90
+ }
91
+ // Stage 2: Consolidate similar memories
92
+ if (steps.includes("consolidate")) {
93
+ try {
94
+ const result = await consolidateMemories(client, workspaceId, projectId, {
95
+ dryRun,
96
+ minClusterSize,
97
+ });
98
+ report.steps.consolidate = {
99
+ clustersFound: result.clustersFound,
100
+ entitiesProcessed: result.entitiesProcessed,
101
+ consolidated: result.consolidated,
102
+ details: result.details,
103
+ };
104
+ report.summary.issuesFound += result.clustersFound;
105
+ if (!dryRun)
106
+ report.summary.actionsTaken += result.consolidated;
107
+ }
108
+ catch (err) {
109
+ report.errors.push({
110
+ step: "consolidate",
111
+ message: err.message,
112
+ });
113
+ }
114
+ }
115
+ // Stage 3: Detect orphans
116
+ if (steps.includes("orphans")) {
117
+ try {
118
+ report.steps.orphans = await runOrphanStep(client, entities, orphanAgeDays);
119
+ if (!dryRun) {
120
+ for (const item of report.steps.orphans.items) {
121
+ try {
122
+ await client.deleteMemoryEntity(item.id);
123
+ report.steps.orphans.removed++;
124
+ }
125
+ catch (err) {
126
+ report.errors.push({
127
+ step: "orphans",
128
+ message: `Failed to delete ${item.id}: ${err.message}`,
129
+ });
130
+ }
131
+ }
132
+ report.summary.actionsTaken += report.steps.orphans.removed;
133
+ }
134
+ report.summary.issuesFound += report.steps.orphans.orphansFound;
135
+ }
136
+ catch (err) {
137
+ report.errors.push({
138
+ step: "orphans",
139
+ message: err.message,
140
+ });
141
+ }
142
+ }
143
+ // Stage 4: Detect duplicates
144
+ if (steps.includes("duplicates")) {
145
+ try {
146
+ report.steps.duplicates = await runDuplicateStep(client, entities, workspaceId, projectId);
147
+ if (!dryRun) {
148
+ for (const pair of report.steps.duplicates.pairs) {
149
+ try {
150
+ await client.deleteMemoryEntity(pair.removeId);
151
+ report.steps.duplicates.resolved++;
152
+ }
153
+ catch (err) {
154
+ report.errors.push({
155
+ step: "duplicates",
156
+ message: `Failed to delete ${pair.removeId}: ${err.message}`,
157
+ });
158
+ }
159
+ }
160
+ report.summary.actionsTaken += report.steps.duplicates.resolved;
161
+ }
162
+ report.summary.issuesFound += report.steps.duplicates.duplicatePairsFound;
163
+ }
164
+ catch (err) {
165
+ report.errors.push({
166
+ step: "duplicates",
167
+ message: err.message,
168
+ });
169
+ }
170
+ }
171
+ // Stage 5: Backfill embeddings
172
+ if (steps.includes("backfill")) {
173
+ try {
174
+ if (dryRun) {
175
+ // In dry-run, just report that backfill would run
176
+ report.steps.backfill = {
177
+ processed: 0,
178
+ remaining: -1,
179
+ errors: [],
180
+ };
181
+ }
182
+ else {
183
+ const result = await client.backfillEmbeddings(workspaceId);
184
+ report.steps.backfill = {
185
+ processed: result.processed,
186
+ remaining: result.remaining,
187
+ errors: result.errors || [],
188
+ };
189
+ report.summary.actionsTaken += result.processed;
190
+ }
191
+ }
192
+ catch (err) {
193
+ report.errors.push({
194
+ step: "backfill",
195
+ message: err.message,
196
+ });
197
+ }
198
+ }
199
+ report.healthReport = generateHealthReport(report);
200
+ return report;
201
+ }
202
+ // ---------------------------------------------------------------------------
203
+ // Step implementations
204
+ // ---------------------------------------------------------------------------
205
+ function runPruneStep(entities, maxAgeDays) {
206
+ const now = Date.now();
207
+ const drafts = entities.filter((e) => e.memory_tier === "draft");
208
+ const stale = [];
209
+ for (const entity of drafts) {
210
+ const ageDays = (now - new Date(entity.created_at).getTime()) / MS_PER_DAY;
211
+ if (ageDays < maxAgeDays)
212
+ continue;
213
+ const lifecycle = evaluateLifecycle(entity);
214
+ stale.push({
215
+ id: entity.id,
216
+ title: entity.title,
217
+ ageDays: Math.round(ageDays),
218
+ decayScore: Math.round(lifecycle.decay.score * 100) / 100,
219
+ });
220
+ }
221
+ return { staleDraftsFound: stale.length, pruned: 0, items: stale };
222
+ }
223
+ async function runOrphanStep(client, entities, orphanAgeDays) {
224
+ const now = Date.now();
225
+ const result = { orphansFound: 0, removed: 0, items: [] };
226
+ // Pre-filter: only check entities that look like orphan candidates
227
+ const candidates = entities.filter((e) => {
228
+ if (e.memory_tier === "reference")
229
+ return false;
230
+ if (e.access_count >= 2)
231
+ return false;
232
+ const ageDays = (now - new Date(e.created_at).getTime()) / MS_PER_DAY;
233
+ return ageDays >= orphanAgeDays;
234
+ });
235
+ // Check relations in concurrent batches
236
+ for (let i = 0; i < candidates.length; i += CONCURRENCY_LIMIT) {
237
+ const batch = candidates.slice(i, i + CONCURRENCY_LIMIT);
238
+ const results = await Promise.allSettled(batch.map(async (entity) => {
239
+ const related = await client.getRelatedEntities(entity.id);
240
+ const totalRelations = (related.outgoing?.length || 0) + (related.incoming?.length || 0);
241
+ if (totalRelations > 0)
242
+ return null;
243
+ const ageDays = (now - new Date(entity.created_at).getTime()) / MS_PER_DAY;
244
+ return {
245
+ id: entity.id,
246
+ title: entity.title,
247
+ type: entity.type,
248
+ tier: entity.memory_tier,
249
+ ageDays: Math.round(ageDays),
250
+ accessCount: entity.access_count,
251
+ };
252
+ }));
253
+ for (const r of results) {
254
+ if (r.status === "fulfilled" && r.value) {
255
+ result.items.push(r.value);
256
+ result.orphansFound++;
257
+ }
258
+ }
259
+ }
260
+ return result;
261
+ }
262
+ async function runDuplicateStep(client, entities, workspaceId, projectId) {
263
+ const result = {
264
+ duplicatePairsFound: 0,
265
+ resolved: 0,
266
+ pairs: [],
267
+ };
268
+ const seenPairs = new Set();
269
+ const flaggedForRemoval = new Set();
270
+ const entityMap = new Map(entities.map((e) => [e.id, e]));
271
+ const similarityMap = new Map();
272
+ for (let i = 0; i < entities.length; i += CONCURRENCY_LIMIT) {
273
+ const batch = entities.slice(i, i + CONCURRENCY_LIMIT);
274
+ const results = await Promise.allSettled(batch.map(async (entity) => {
275
+ const similar = await findSimilarEntities(client, entity.title, entity.content, workspaceId, { projectId, limit: 5, minRrfScore: 0.05, excludeIds: [entity.id] });
276
+ return { entityId: entity.id, similar };
277
+ }));
278
+ for (const r of results) {
279
+ if (r.status === "fulfilled") {
280
+ similarityMap.set(r.value.entityId, r.value.similar);
281
+ }
282
+ }
283
+ }
284
+ // Process pairs sequentially (flaggedForRemoval creates dependencies)
285
+ for (const entity of entities) {
286
+ if (flaggedForRemoval.has(entity.id))
287
+ continue;
288
+ const similar = similarityMap.get(entity.id) || [];
289
+ for (const match of similar) {
290
+ if (flaggedForRemoval.has(match.id))
291
+ continue;
292
+ const pairKey = [entity.id, match.id].sort().join(":");
293
+ if (seenPairs.has(pairKey))
294
+ continue;
295
+ seenPairs.add(pairKey);
296
+ const sim = titleSimilarity(entity.title, match.title);
297
+ if (sim < DUPLICATE_SIMILARITY_THRESHOLD)
298
+ continue;
299
+ // Keep the one with higher confidence, more accesses, or higher tier
300
+ const entityScore = entityQualityScore(entity);
301
+ const matchEntity = entityMap.get(match.id);
302
+ const matchScore = matchEntity
303
+ ? entityQualityScore(matchEntity)
304
+ : match.confidence;
305
+ const [keep, remove] = entityScore >= matchScore
306
+ ? [entity, { id: match.id, title: match.title }]
307
+ : [{ id: match.id, title: match.title }, entity];
308
+ flaggedForRemoval.add(remove.id);
309
+ result.pairs.push({
310
+ keepId: keep.id,
311
+ keepTitle: keep.title,
312
+ removeId: remove.id,
313
+ removeTitle: remove.title,
314
+ similarity: Math.round(sim * 100) / 100,
315
+ });
316
+ result.duplicatePairsFound++;
317
+ }
318
+ }
319
+ return result;
320
+ }
321
+ // ---------------------------------------------------------------------------
322
+ // Helpers
323
+ // ---------------------------------------------------------------------------
324
+ const TIER_WEIGHTS = {
325
+ reference: 3,
326
+ episode: 2,
327
+ draft: 1,
328
+ };
329
+ function entityQualityScore(entity) {
330
+ return (entity.confidence +
331
+ (TIER_WEIGHTS[entity.memory_tier] || 0) +
332
+ Math.min(entity.access_count, 10) * 0.1);
333
+ }
334
+ function titleSimilarity(a, b) {
335
+ const na = a.toLowerCase().trim();
336
+ const nb = b.toLowerCase().trim();
337
+ if (na === nb)
338
+ return 1;
339
+ const wordsA = new Set(na.split(/\W+/).filter(Boolean));
340
+ const wordsB = new Set(nb.split(/\W+/).filter(Boolean));
341
+ if (wordsA.size === 0 || wordsB.size === 0)
342
+ return 0;
343
+ let intersection = 0;
344
+ for (const w of wordsA) {
345
+ if (wordsB.has(w))
346
+ intersection++;
347
+ }
348
+ // Jaccard similarity
349
+ const union = wordsA.size + wordsB.size - intersection;
350
+ return union > 0 ? intersection / union : 0;
351
+ }
352
+ // ---------------------------------------------------------------------------
353
+ // Health report renderer
354
+ // ---------------------------------------------------------------------------
355
+ function generateHealthReport(report) {
356
+ const mode = report.dryRun ? "Dry Run (preview)" : "Executed";
357
+ const lines = [
358
+ "# Memory Health Report\n",
359
+ `**Mode:** ${mode} | **Entities:** ${report.summary.totalEntities} | **Issues:** ${report.summary.issuesFound} | **Actions:** ${report.summary.actionsTaken}`,
360
+ "",
361
+ ];
362
+ if (report.summary.totalEntities >= MAX_ENTITIES_FETCH) {
363
+ lines.push(`> **Note:** Entity count hit the ${MAX_ENTITIES_FETCH} fetch limit. Some entities may not have been analyzed.\n`);
364
+ }
365
+ // Prune
366
+ if (report.steps.prune) {
367
+ const p = report.steps.prune;
368
+ lines.push("## Stale Drafts");
369
+ if (p.staleDraftsFound === 0) {
370
+ lines.push("No stale drafts found.\n");
371
+ }
372
+ else {
373
+ lines.push(`Found **${p.staleDraftsFound}** stale drafts${!report.dryRun ? ` (pruned ${p.pruned})` : ""}:`);
374
+ lines.push("| Title | Age | Decay |");
375
+ lines.push("|-------|-----|-------|");
376
+ for (const item of p.items.slice(0, 20)) {
377
+ lines.push(`| ${item.title} | ${item.ageDays}d | ${item.decayScore} |`);
378
+ }
379
+ lines.push("");
380
+ }
381
+ }
382
+ // Consolidate
383
+ if (report.steps.consolidate) {
384
+ const c = report.steps.consolidate;
385
+ lines.push("## Consolidation");
386
+ if (c.clustersFound === 0) {
387
+ lines.push(`Scanned ${c.entitiesProcessed} draft/episode entities — no clusters found.\n`);
388
+ }
389
+ else {
390
+ lines.push(`Found **${c.clustersFound}** clusters across ${c.entitiesProcessed} entities:`);
391
+ for (const d of c.details.slice(0, 10)) {
392
+ lines.push(`- **${d.mergedTitle}** — ${d.clusterSize} entities`);
393
+ }
394
+ lines.push("");
395
+ }
396
+ }
397
+ // Orphans
398
+ if (report.steps.orphans) {
399
+ const o = report.steps.orphans;
400
+ lines.push("## Orphaned Entities");
401
+ if (o.orphansFound === 0) {
402
+ lines.push("No orphans found.\n");
403
+ }
404
+ else {
405
+ lines.push(`Found **${o.orphansFound}** orphans${!report.dryRun ? ` (removed ${o.removed})` : ""}:`);
406
+ lines.push("| Title | Type | Tier | Age | Accesses |");
407
+ lines.push("|-------|------|------|-----|----------|");
408
+ for (const item of o.items.slice(0, 20)) {
409
+ lines.push(`| ${item.title} | ${item.type} | ${item.tier} | ${item.ageDays}d | ${item.accessCount} |`);
410
+ }
411
+ lines.push("");
412
+ }
413
+ }
414
+ // Duplicates
415
+ if (report.steps.duplicates) {
416
+ const d = report.steps.duplicates;
417
+ lines.push("## Near-Duplicates");
418
+ if (d.duplicatePairsFound === 0) {
419
+ lines.push("No duplicates found.\n");
420
+ }
421
+ else {
422
+ lines.push(`Found **${d.duplicatePairsFound}** duplicate pairs${!report.dryRun ? ` (resolved ${d.resolved})` : ""}:`);
423
+ for (const pair of d.pairs.slice(0, 20)) {
424
+ lines.push(`- "${pair.keepTitle}" ~ "${pair.removeTitle}" (${Math.round(pair.similarity * 100)}% similar, keep first)`);
425
+ }
426
+ lines.push("");
427
+ }
428
+ }
429
+ // Backfill
430
+ if (report.steps.backfill) {
431
+ const b = report.steps.backfill;
432
+ lines.push("## Embedding Coverage");
433
+ if (report.dryRun) {
434
+ lines.push("Backfill will run when executed with `dryRun: false`.\n");
435
+ }
436
+ else if (b.remaining === 0) {
437
+ lines.push(`All embeddings up to date (processed ${b.processed}).\n`);
438
+ }
439
+ else {
440
+ lines.push(`Processed ${b.processed} entities. ${b.remaining} still need embeddings.\n`);
441
+ }
442
+ }
443
+ // Errors
444
+ if (report.errors.length > 0) {
445
+ lines.push("## Errors");
446
+ for (const e of report.errors) {
447
+ lines.push(`- **${e.step}:** ${e.message}`);
448
+ }
449
+ lines.push("");
450
+ }
451
+ if (report.dryRun) {
452
+ lines.push("---\n*Run with `dryRun: false` to execute cleanup.*");
453
+ }
454
+ return lines.join("\n");
455
+ }
@@ -301,7 +301,11 @@ export function generatePrompt(options) {
301
301
  roleFraming.focus.forEach((f) => {
302
302
  sections.push(`- ${f}`);
303
303
  });
304
- sections.push(`- **Memory:** When you discover important domain knowledge, architectural decisions, or infrastructure details, store them via \`harmony_remember\`. Focus on durable knowledge that future agents would benefit from not ephemeral task details (those are auto-extracted from your session).`);
304
+ sections.push(`- **Memory:** Store reusable knowledge via \`harmony_remember\`. Only store what a future agent couldn't easily discover from the code itself, applies beyond this specific card, and includes a "because" (not just what, but why).`);
305
+ sections.push(` - GOOD: "BoardContext card state must use moveCard action, never direct setState — optimistic updates depend on action ordering"`);
306
+ sections.push(` - GOOD: "Mobile bottom bar is 64px, overlaps fixed-position drawers — always add pb-16 to drawer content"`);
307
+ sections.push(` - BAD: "Fixed the login button" (no reusable knowledge — the fix is in the code)`);
308
+ sections.push(` - BAD: "Completed card #42" (ephemeral, auto-tracked by session)`);
305
309
  // Output suggestions
306
310
  sections.push(`\n## Suggested Outputs`);
307
311
  roleFraming.outputSuggestions.forEach((s) => {
@@ -11,6 +11,7 @@ import { consolidateMemories } from "./consolidation.js";
11
11
  import { assembleContext, cacheManifest, computeRelevanceScore, getCachedManifest, mapToContextEntity, recordContextFeedback, trackSessionAssembly, } from "./context-assembly.js";
12
12
  import { autoExpandGraph } from "./graph-expansion.js";
13
13
  import { runLifecycleMaintenance } from "./lifecycle-maintenance.js";
14
+ import { runMemoryCleanup } from "./memory-cleanup.js";
14
15
  import { onboardNewUser } from "./onboard.js";
15
16
  const memorySessions = new Map();
16
17
  function initMemorySession(cardId, agentIdentifier, agentName) {
@@ -1450,6 +1451,47 @@ const TOOLS = {
1450
1451
  required: [],
1451
1452
  },
1452
1453
  },
1454
+ harmony_cleanup_memories: {
1455
+ description: "Run a unified memory cleanup: prune stale drafts, consolidate similar memories, detect orphans and duplicates, and backfill embeddings. Returns a health report. Dry-run by default — run with dryRun=false to execute.",
1456
+ inputSchema: {
1457
+ type: "object",
1458
+ properties: {
1459
+ workspaceId: {
1460
+ type: "string",
1461
+ description: "Workspace ID (optional if context set)",
1462
+ },
1463
+ projectId: {
1464
+ type: "string",
1465
+ description: "Project ID (optional)",
1466
+ },
1467
+ dryRun: {
1468
+ type: "boolean",
1469
+ description: "Preview cleanup without executing changes (default: true)",
1470
+ },
1471
+ steps: {
1472
+ type: "array",
1473
+ items: {
1474
+ type: "string",
1475
+ enum: ["prune", "consolidate", "orphans", "duplicates", "backfill"],
1476
+ },
1477
+ description: "Which cleanup steps to run (default: all). Options: prune, consolidate, orphans, duplicates, backfill.",
1478
+ },
1479
+ maxAgeDays: {
1480
+ type: "number",
1481
+ description: "Max age in days for stale draft pruning (default: 30)",
1482
+ },
1483
+ minClusterSize: {
1484
+ type: "number",
1485
+ description: "Min cluster size for consolidation (default: 3)",
1486
+ },
1487
+ orphanAgeDays: {
1488
+ type: "number",
1489
+ description: "Min age in days for orphan detection (default: 14)",
1490
+ },
1491
+ },
1492
+ required: [],
1493
+ },
1494
+ },
1453
1495
  };
1454
1496
  // Resource URIs
1455
1497
  const RESOURCES = [
@@ -3215,6 +3257,41 @@ async function handleToolCall(name, args, deps) {
3215
3257
  message: `Processed ${entitiesProcessed} entities, created ${relationsCreated} new relations.`,
3216
3258
  };
3217
3259
  }
3260
+ case "harmony_cleanup_memories": {
3261
+ const workspaceId = args.workspaceId || deps.getActiveWorkspaceId();
3262
+ if (!workspaceId) {
3263
+ throw new Error("No workspace specified. Use harmony_set_workspace_context or provide workspaceId.");
3264
+ }
3265
+ const projectId = args.projectId || deps.getActiveProjectId() || undefined;
3266
+ const validSteps = [
3267
+ "prune",
3268
+ "consolidate",
3269
+ "orphans",
3270
+ "duplicates",
3271
+ "backfill",
3272
+ ];
3273
+ const rawSteps = args.steps;
3274
+ const steps = rawSteps?.filter((s) => validSteps.includes(s));
3275
+ if (rawSteps && steps && steps.length < rawSteps.length) {
3276
+ const invalid = rawSteps.filter((s) => !validSteps.includes(s));
3277
+ // Will appear in report.errors via the healthReport
3278
+ console.warn(`Unknown cleanup steps ignored: ${invalid.join(", ")}`);
3279
+ }
3280
+ const report = await runMemoryCleanup(client, workspaceId, projectId, {
3281
+ dryRun: args.dryRun,
3282
+ steps,
3283
+ maxAgeDays: args.maxAgeDays,
3284
+ minClusterSize: args.minClusterSize,
3285
+ orphanAgeDays: args.orphanAgeDays,
3286
+ });
3287
+ return {
3288
+ success: report.success,
3289
+ dryRun: report.dryRun,
3290
+ summary: report.summary,
3291
+ errors: report.errors,
3292
+ healthReport: report.healthReport,
3293
+ };
3294
+ }
3218
3295
  default:
3219
3296
  throw new Error(`Unknown tool: ${name}`);
3220
3297
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/mcp",
3
- "version": "2.2.4",
3
+ "version": "2.3.1",
4
4
  "description": "MCP server for Harmony Kanban board - enables AI coding agents to manage your boards",
5
5
  "publishConfig": {
6
6
  "access": "public"