@gethmy/mcp 2.4.7 → 2.5.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/README.md +34 -1
- package/dist/cli.js +20826 -18366
- package/dist/index.js +20924 -18464
- package/dist/lib/api-client.js +122 -925
- package/package.json +2 -2
- package/src/__tests__/mcp-integration.test.ts +141 -0
- package/src/__tests__/memory-floor.test.ts +126 -0
- package/src/__tests__/memory-park.test.ts +213 -0
- package/src/__tests__/memory-session.test.ts +77 -0
- package/src/__tests__/prompt-builder.test.ts +234 -0
- package/src/__tests__/skills.test.ts +111 -0
- package/src/__tests__/tool-dispatch.test.ts +260 -0
- package/src/api-client.ts +129 -96
- package/src/memory-floor.ts +264 -0
- package/src/memory-park.ts +252 -0
- package/src/memory-session.ts +61 -0
- package/src/prompt-builder.ts +93 -0
- package/src/server.ts +351 -1467
- package/src/__tests__/active-learning.test.ts +0 -483
- package/src/__tests__/agent-performance-profiles.test.ts +0 -468
- package/src/__tests__/context-assembly.test.ts +0 -506
- package/src/__tests__/lifecycle-maintenance.test.ts +0 -238
- package/src/__tests__/memory-audit.test.ts +0 -528
- package/src/__tests__/pattern-detection.test.ts +0 -438
- package/src/active-learning.ts +0 -1165
- package/src/consolidation.ts +0 -383
- package/src/context-assembly.ts +0 -1175
- package/src/lifecycle-maintenance.ts +0 -120
- package/src/memory-audit.ts +0 -578
- package/src/memory-cleanup.ts +0 -902
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { HarmonyApiClient } from "../api-client.js";
|
|
2
3
|
import {
|
|
3
4
|
type CardData,
|
|
5
|
+
computeContentHash,
|
|
4
6
|
generatePrompt,
|
|
5
7
|
getAvailableCategories,
|
|
6
8
|
getAvailableVariants,
|
|
7
9
|
getRoleFraming,
|
|
8
10
|
inferCategoryFromLabels,
|
|
9
11
|
type MemoryData,
|
|
12
|
+
PROMPT_TEMPLATE_VERSION,
|
|
13
|
+
type PromptCohortRow,
|
|
14
|
+
proposePromptVariant,
|
|
10
15
|
} from "../prompt-builder.js";
|
|
11
16
|
|
|
12
17
|
function makeCard(overrides: Partial<CardData> = {}): CardData {
|
|
@@ -503,3 +508,232 @@ describe("utility exports", () => {
|
|
|
503
508
|
}
|
|
504
509
|
});
|
|
505
510
|
});
|
|
511
|
+
|
|
512
|
+
// ─── AGP P2: snapshot identity (promptId, contentHash, version) ──────
|
|
513
|
+
|
|
514
|
+
describe("generatePrompt — AGP P2 snapshot fields", () => {
|
|
515
|
+
test("returns promptId, contentHash, version", () => {
|
|
516
|
+
const result = generatePrompt({
|
|
517
|
+
card: makeCard(),
|
|
518
|
+
variant: "execute",
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
expect(typeof result.promptId).toBe("string");
|
|
522
|
+
expect(result.promptId.length).toBeGreaterThan(0);
|
|
523
|
+
expect(typeof result.contentHash).toBe("string");
|
|
524
|
+
// sha256 hex = 64 chars
|
|
525
|
+
expect(result.contentHash).toMatch(/^[0-9a-f]{64}$/);
|
|
526
|
+
expect(result.version).toBe(PROMPT_TEMPLATE_VERSION);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test("identical inputs produce identical contentHash", () => {
|
|
530
|
+
const a = generatePrompt({ card: makeCard(), variant: "analysis" });
|
|
531
|
+
const b = generatePrompt({ card: makeCard(), variant: "analysis" });
|
|
532
|
+
expect(a.contentHash).toBe(b.contentHash);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test("different inputs produce different contentHash", () => {
|
|
536
|
+
const a = generatePrompt({ card: makeCard(), variant: "analysis" });
|
|
537
|
+
const b = generatePrompt({
|
|
538
|
+
card: makeCard({ title: "Different title" }),
|
|
539
|
+
variant: "analysis",
|
|
540
|
+
});
|
|
541
|
+
expect(a.contentHash).not.toBe(b.contentHash);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test("each call produces a fresh promptId", () => {
|
|
545
|
+
const a = generatePrompt({ card: makeCard(), variant: "execute" });
|
|
546
|
+
const b = generatePrompt({ card: makeCard(), variant: "execute" });
|
|
547
|
+
expect(a.promptId).not.toBe(b.promptId);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("computeContentHash matches generated prompt hash", () => {
|
|
551
|
+
const result = generatePrompt({ card: makeCard(), variant: "execute" });
|
|
552
|
+
expect(computeContentHash(result.prompt)).toBe(result.contentHash);
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
// ─── AGP P2: persistence wiring via api-client ────────────────────────
|
|
557
|
+
//
|
|
558
|
+
// The api-client wraps generatePrompt() with a best-effort POST to
|
|
559
|
+
// /prompt-history. We unit-test by stubbing `request` so we don't need
|
|
560
|
+
// a live API.
|
|
561
|
+
|
|
562
|
+
describe("api-client.generateCardPrompt — prompt_history persistence", () => {
|
|
563
|
+
test("POSTs a prompt-history row with matching contentHash", async () => {
|
|
564
|
+
const recorded: Array<{ method: string; path: string; body?: unknown }> =
|
|
565
|
+
[];
|
|
566
|
+
const client = new HarmonyApiClient({
|
|
567
|
+
apiUrl: "http://test",
|
|
568
|
+
apiKey: "test-key",
|
|
569
|
+
});
|
|
570
|
+
(client as any).request = async (
|
|
571
|
+
method: string,
|
|
572
|
+
path: string,
|
|
573
|
+
body?: unknown,
|
|
574
|
+
) => {
|
|
575
|
+
recorded.push({ method, path, body });
|
|
576
|
+
// Minimal stubs for the subset of routes generateCardPrompt hits.
|
|
577
|
+
if (path.startsWith("/cards/") && method === "GET") {
|
|
578
|
+
return {
|
|
579
|
+
card: {
|
|
580
|
+
id: "card-1",
|
|
581
|
+
short_id: 7,
|
|
582
|
+
title: "Test card",
|
|
583
|
+
description: "desc",
|
|
584
|
+
priority: "medium",
|
|
585
|
+
done: false,
|
|
586
|
+
labels: [],
|
|
587
|
+
subtasks: [],
|
|
588
|
+
links: [],
|
|
589
|
+
project_id: "proj-1",
|
|
590
|
+
column_id: "col-1",
|
|
591
|
+
},
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
if (path.startsWith("/board/")) {
|
|
595
|
+
return { columns: [] };
|
|
596
|
+
}
|
|
597
|
+
if (path.startsWith("/memory/")) {
|
|
598
|
+
return { entities: [] };
|
|
599
|
+
}
|
|
600
|
+
if (path === "/prompt-history") {
|
|
601
|
+
return { entry: { id: "ph-1" } };
|
|
602
|
+
}
|
|
603
|
+
return {};
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
const result = await client.generateCardPrompt({
|
|
607
|
+
cardId: "card-1",
|
|
608
|
+
workspaceId: "ws-1",
|
|
609
|
+
projectId: "proj-1",
|
|
610
|
+
variant: "execute",
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
expect(result.contentHash).toMatch(/^[0-9a-f]{64}$/);
|
|
614
|
+
|
|
615
|
+
const phCall = recorded.find(
|
|
616
|
+
(c) => c.method === "POST" && c.path === "/prompt-history",
|
|
617
|
+
);
|
|
618
|
+
expect(phCall).toBeDefined();
|
|
619
|
+
const body = phCall!.body as Record<string, unknown>;
|
|
620
|
+
expect(body.cardId).toBe("card-1");
|
|
621
|
+
expect(body.contentHash).toBe(result.contentHash);
|
|
622
|
+
expect(body.templateVersion).toBe(PROMPT_TEMPLATE_VERSION);
|
|
623
|
+
expect(body.confidence).toBe(0.5);
|
|
624
|
+
expect(body.variant).toBe("execute");
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test("returns successfully when persistence fails (best-effort)", async () => {
|
|
628
|
+
const client = new HarmonyApiClient({
|
|
629
|
+
apiUrl: "http://test",
|
|
630
|
+
apiKey: "test-key",
|
|
631
|
+
});
|
|
632
|
+
(client as any).request = async (method: string, path: string) => {
|
|
633
|
+
if (path.startsWith("/cards/") && method === "GET") {
|
|
634
|
+
return {
|
|
635
|
+
card: {
|
|
636
|
+
id: "card-1",
|
|
637
|
+
short_id: 7,
|
|
638
|
+
title: "Test card",
|
|
639
|
+
description: null,
|
|
640
|
+
priority: "medium",
|
|
641
|
+
done: false,
|
|
642
|
+
labels: [],
|
|
643
|
+
subtasks: [],
|
|
644
|
+
links: [],
|
|
645
|
+
project_id: "proj-1",
|
|
646
|
+
column_id: "col-1",
|
|
647
|
+
},
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
if (path.startsWith("/board/")) return { columns: [] };
|
|
651
|
+
if (path.startsWith("/memory/")) return { entities: [] };
|
|
652
|
+
if (path === "/prompt-history") {
|
|
653
|
+
throw new Error("network down");
|
|
654
|
+
}
|
|
655
|
+
return {};
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
const result = await client.generateCardPrompt({
|
|
659
|
+
cardId: "card-1",
|
|
660
|
+
workspaceId: "ws-1",
|
|
661
|
+
variant: "analysis",
|
|
662
|
+
});
|
|
663
|
+
// Generation still succeeds even when logging fails.
|
|
664
|
+
expect(result.prompt.length).toBeGreaterThan(0);
|
|
665
|
+
expect(result.contentHash).toMatch(/^[0-9a-f]{64}$/);
|
|
666
|
+
});
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
// ─── AGP P2: variant proposal helper (logged-only) ────────────────────
|
|
670
|
+
|
|
671
|
+
describe("proposePromptVariant", () => {
|
|
672
|
+
function row(overrides: Partial<PromptCohortRow> = {}): PromptCohortRow {
|
|
673
|
+
return {
|
|
674
|
+
status: "completed",
|
|
675
|
+
progressPercent: 100,
|
|
676
|
+
hadBlockers: false,
|
|
677
|
+
...overrides,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
test("returns null when cohort is smaller than 10", async () => {
|
|
682
|
+
const cohort = Array.from({ length: 5 }, () =>
|
|
683
|
+
row({ status: "paused", progressPercent: 30 }),
|
|
684
|
+
);
|
|
685
|
+
const suggestion = await proposePromptVariant("hash-1", async () => cohort);
|
|
686
|
+
expect(suggestion).toBeNull();
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
test("returns null when completion rate is acceptable (>=0.4)", async () => {
|
|
690
|
+
const cohort = [
|
|
691
|
+
...Array.from({ length: 5 }, () => row()),
|
|
692
|
+
...Array.from({ length: 5 }, () =>
|
|
693
|
+
row({ status: "paused", progressPercent: 50 }),
|
|
694
|
+
),
|
|
695
|
+
];
|
|
696
|
+
const suggestion = await proposePromptVariant("hash-2", async () => cohort);
|
|
697
|
+
expect(suggestion).toBeNull();
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
test("returns suggestion when cohort is >=10 and completion rate <0.4", async () => {
|
|
701
|
+
// 2 successes / 10 sessions = 0.2 completion rate, below 0.4 threshold.
|
|
702
|
+
const cohort = [
|
|
703
|
+
...Array.from({ length: 2 }, () => row()),
|
|
704
|
+
...Array.from({ length: 8 }, () =>
|
|
705
|
+
row({ status: "paused", progressPercent: 30, hadBlockers: false }),
|
|
706
|
+
),
|
|
707
|
+
];
|
|
708
|
+
const suggestion = await proposePromptVariant("hash-3", async () => cohort);
|
|
709
|
+
expect(suggestion).not.toBeNull();
|
|
710
|
+
expect(suggestion!.contentHash).toBe("hash-3");
|
|
711
|
+
expect(suggestion!.cohortSize).toBe(10);
|
|
712
|
+
expect(suggestion!.completionRate).toBeCloseTo(0.2, 5);
|
|
713
|
+
expect(suggestion!.framingHint).toContain("action-forcing");
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
test("returns blocker-flavoured framing hint when blockers dominate", async () => {
|
|
717
|
+
// 1 success, 9 blocked sessions → completionRate=0.1, blockerRate=0.9
|
|
718
|
+
const cohort = [
|
|
719
|
+
row(),
|
|
720
|
+
...Array.from({ length: 9 }, () =>
|
|
721
|
+
row({ status: "paused", progressPercent: 25, hadBlockers: true }),
|
|
722
|
+
),
|
|
723
|
+
];
|
|
724
|
+
const suggestion = await proposePromptVariant("hash-4", async () => cohort);
|
|
725
|
+
expect(suggestion).not.toBeNull();
|
|
726
|
+
expect(suggestion!.framingHint).toContain("diagnostic");
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
test("blockers count against completion rate", async () => {
|
|
730
|
+
// Even at 100% status="completed"+100%, blockers should disqualify them
|
|
731
|
+
// from being counted as success.
|
|
732
|
+
const cohort = Array.from({ length: 10 }, () =>
|
|
733
|
+
row({ status: "completed", progressPercent: 100, hadBlockers: true }),
|
|
734
|
+
);
|
|
735
|
+
const suggestion = await proposePromptVariant("hash-5", async () => cohort);
|
|
736
|
+
expect(suggestion).not.toBeNull();
|
|
737
|
+
expect(suggestion!.completionRate).toBe(0);
|
|
738
|
+
});
|
|
739
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verification harness for skill rendering. Card #182.
|
|
3
|
+
*
|
|
4
|
+
* Pins the contract that backs `~/.claude/skills/` delivery: frontmatter shape,
|
|
5
|
+
* version-marker injection, agent-id substitution. If any of these drift the
|
|
6
|
+
* MCP bridge installs broken skills silently, so this file is the floor.
|
|
7
|
+
*
|
|
8
|
+
* Run with: bun test packages/mcp-server/src/__tests__/skills.test.ts
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from "bun:test";
|
|
12
|
+
import {
|
|
13
|
+
buildSkillFile,
|
|
14
|
+
SKILL_DEFINITIONS,
|
|
15
|
+
SKILLS_VERSION,
|
|
16
|
+
} from "../skills.js";
|
|
17
|
+
|
|
18
|
+
const SKILL_IDS = Object.keys(SKILL_DEFINITIONS);
|
|
19
|
+
const VERSION_MARKER_RE = /<!-- skills-version:(\d+) -->\s*$/;
|
|
20
|
+
|
|
21
|
+
describe("SKILL_DEFINITIONS", () => {
|
|
22
|
+
test("registry is non-empty", () => {
|
|
23
|
+
expect(SKILL_IDS.length).toBeGreaterThan(0);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("SKILLS_VERSION is a numeric string", () => {
|
|
27
|
+
expect(SKILLS_VERSION).toMatch(/^\d+$/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test.each(SKILL_IDS)("definition %s has required fields", (skillId) => {
|
|
31
|
+
const skill = SKILL_DEFINITIONS[skillId];
|
|
32
|
+
expect(skill.name).toBe(skillId);
|
|
33
|
+
expect(skill.description.length).toBeGreaterThan(0);
|
|
34
|
+
expect(skill.argumentHint.length).toBeGreaterThan(0);
|
|
35
|
+
expect(skill.content.length).toBeGreaterThan(0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("all skill names are unique", () => {
|
|
39
|
+
const names = SKILL_IDS.map((id) => SKILL_DEFINITIONS[id].name);
|
|
40
|
+
expect(new Set(names).size).toBe(names.length);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("buildSkillFile()", () => {
|
|
45
|
+
test.each(SKILL_IDS)("renders %s with valid frontmatter", (skillId) => {
|
|
46
|
+
const out = buildSkillFile(skillId);
|
|
47
|
+
const skill = SKILL_DEFINITIONS[skillId];
|
|
48
|
+
|
|
49
|
+
expect(out.startsWith("---\n")).toBe(true);
|
|
50
|
+
expect(out).toContain(`\nname: ${skill.name}\n`);
|
|
51
|
+
expect(out).toContain(`\ndescription: ${skill.description}\n`);
|
|
52
|
+
expect(out).toContain(`\nargument-hint: ${skill.argumentHint}\n`);
|
|
53
|
+
expect(out).toContain("---\n\n");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test.each(SKILL_IDS)("appends version marker for %s", (skillId) => {
|
|
57
|
+
const out = buildSkillFile(skillId);
|
|
58
|
+
const match = out.match(VERSION_MARKER_RE);
|
|
59
|
+
|
|
60
|
+
expect(match).not.toBeNull();
|
|
61
|
+
expect(match?.[1]).toBe(SKILLS_VERSION);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test.each(SKILL_IDS)("embeds skill content for %s", (skillId) => {
|
|
65
|
+
const out = buildSkillFile(skillId);
|
|
66
|
+
const content = SKILL_DEFINITIONS[skillId].content;
|
|
67
|
+
|
|
68
|
+
expect(out).toContain(content.split("\n")[0]);
|
|
69
|
+
const lastLine = content.trim().split("\n").at(-1) ?? "";
|
|
70
|
+
expect(out).toContain(lastLine);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("is deterministic — same input yields same output", () => {
|
|
74
|
+
const a = buildSkillFile("hmy");
|
|
75
|
+
const b = buildSkillFile("hmy");
|
|
76
|
+
expect(a).toBe(b);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("throws on unknown skill id", () => {
|
|
80
|
+
expect(() => buildSkillFile("does-not-exist")).toThrow(/Unknown skill/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("agent-id substitution", () => {
|
|
84
|
+
test("agentId='claude' replaces placeholder phrases in hmy", () => {
|
|
85
|
+
const withAgent = buildSkillFile("hmy", "claude");
|
|
86
|
+
expect(withAgent).not.toContain("Your agent identifier");
|
|
87
|
+
expect(withAgent).not.toContain("Your agent name");
|
|
88
|
+
expect(withAgent).toContain("claude-code");
|
|
89
|
+
expect(withAgent).toContain("Claude Code");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("agentId omitted leaves placeholders untouched", () => {
|
|
93
|
+
const raw = buildSkillFile("hmy");
|
|
94
|
+
const hasPlaceholder =
|
|
95
|
+
raw.includes("Your agent identifier") ||
|
|
96
|
+
raw.includes("Your agent name");
|
|
97
|
+
const skillContent = SKILL_DEFINITIONS.hmy.content;
|
|
98
|
+
const sourceHasPlaceholder =
|
|
99
|
+
skillContent.includes("Your agent identifier") ||
|
|
100
|
+
skillContent.includes("Your agent name");
|
|
101
|
+
|
|
102
|
+
expect(hasPlaceholder).toBe(sourceHasPlaceholder);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("unknown agentId is treated as no-op", () => {
|
|
106
|
+
const raw = buildSkillFile("hmy");
|
|
107
|
+
const unknownAgent = buildSkillFile("hmy", "some-other-agent");
|
|
108
|
+
expect(unknownAgent).toBe(raw);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verification harness for MCP tool dispatch. Card #182.
|
|
3
|
+
*
|
|
4
|
+
* Pins the contract between Harmony's MCP server and any MCP client (Claude
|
|
5
|
+
* Code, Codex, Cursor, etc.). If the dispatch shape drifts, agents lose tools
|
|
6
|
+
* silently, so this is the floor:
|
|
7
|
+
*
|
|
8
|
+
* 1. TOOLS registry is well-formed (shape, names, schemas).
|
|
9
|
+
* 2. ListTools handler emits the same set TOOLS declares.
|
|
10
|
+
* 3. CallTool handler routes valid names and isErrors unknown ones.
|
|
11
|
+
* 4. ListResources / ReadResource cover the published URIs.
|
|
12
|
+
* 5. Tool ↔ skill name spaces are disjoint.
|
|
13
|
+
*
|
|
14
|
+
* Run with: bun test packages/mcp-server/src/__tests__/tool-dispatch.test.ts
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { beforeAll, describe, expect, test } from "bun:test";
|
|
18
|
+
import {
|
|
19
|
+
CallToolRequestSchema,
|
|
20
|
+
ListResourcesRequestSchema,
|
|
21
|
+
ListToolsRequestSchema,
|
|
22
|
+
ReadResourceRequestSchema,
|
|
23
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
24
|
+
import {
|
|
25
|
+
RESOURCES,
|
|
26
|
+
registerHandlers,
|
|
27
|
+
TOOLS,
|
|
28
|
+
type ToolDeps,
|
|
29
|
+
} from "../server.js";
|
|
30
|
+
import { SKILL_DEFINITIONS } from "../skills.js";
|
|
31
|
+
|
|
32
|
+
const TOOL_NAMES = Object.keys(TOOLS);
|
|
33
|
+
|
|
34
|
+
// ── Fake transport ────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
type Schema = unknown;
|
|
37
|
+
type Handler = (req: unknown) => Promise<unknown>;
|
|
38
|
+
|
|
39
|
+
class FakeServer {
|
|
40
|
+
handlers = new Map<Schema, Handler>();
|
|
41
|
+
setRequestHandler(schema: Schema, handler: Handler): void {
|
|
42
|
+
this.handlers.set(schema, handler);
|
|
43
|
+
}
|
|
44
|
+
call<T = unknown>(schema: Schema, request: unknown): Promise<T> {
|
|
45
|
+
const handler = this.handlers.get(schema);
|
|
46
|
+
if (!handler) throw new Error("Handler not registered");
|
|
47
|
+
return handler(request) as Promise<T>;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeDeps(overrides: Partial<ToolDeps> = {}): ToolDeps {
|
|
52
|
+
return {
|
|
53
|
+
getClient: () => ({}) as never,
|
|
54
|
+
isConfigured: () => true,
|
|
55
|
+
getActiveProjectId: () => "11111111-1111-1111-1111-111111111111",
|
|
56
|
+
getActiveWorkspaceId: () => "22222222-2222-2222-2222-222222222222",
|
|
57
|
+
setActiveProject: () => {},
|
|
58
|
+
setActiveWorkspace: () => {},
|
|
59
|
+
getApiUrl: () => "http://localhost",
|
|
60
|
+
getMemoryDir: () => null,
|
|
61
|
+
getUserEmail: () => null,
|
|
62
|
+
saveConfig: () => {},
|
|
63
|
+
resetClient: () => {},
|
|
64
|
+
...overrides,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Static registry shape ────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
describe("TOOLS registry", () => {
|
|
71
|
+
test("is non-empty", () => {
|
|
72
|
+
expect(TOOL_NAMES.length).toBeGreaterThan(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("every name is unique", () => {
|
|
76
|
+
expect(new Set(TOOL_NAMES).size).toBe(TOOL_NAMES.length);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test.each(TOOL_NAMES)("%s is namespaced under harmony_", (name) => {
|
|
80
|
+
expect(name).toMatch(/^harmony_[a-z0-9_]+$/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test.each(TOOL_NAMES)("%s has description and inputSchema", (name) => {
|
|
84
|
+
const tool = (
|
|
85
|
+
TOOLS as Record<string, { description?: unknown; inputSchema?: unknown }>
|
|
86
|
+
)[name];
|
|
87
|
+
expect(typeof tool.description).toBe("string");
|
|
88
|
+
expect((tool.description as string).length).toBeGreaterThan(0);
|
|
89
|
+
expect(typeof tool.inputSchema).toBe("object");
|
|
90
|
+
expect(tool.inputSchema).not.toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test.each(TOOL_NAMES)("%s inputSchema is a JSON Schema object", (name) => {
|
|
94
|
+
const schema = (
|
|
95
|
+
TOOLS as Record<
|
|
96
|
+
string,
|
|
97
|
+
{ inputSchema: { type?: string; properties?: unknown } }
|
|
98
|
+
>
|
|
99
|
+
)[name].inputSchema;
|
|
100
|
+
expect(schema.type).toBe("object");
|
|
101
|
+
if ("properties" in schema && schema.properties !== undefined) {
|
|
102
|
+
expect(typeof schema.properties).toBe("object");
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ── Skill ↔ tool boundary ────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
describe("skill ↔ tool namespace boundary", () => {
|
|
110
|
+
test("skill names and tool names are disjoint", () => {
|
|
111
|
+
const skillNames = new Set(Object.keys(SKILL_DEFINITIONS));
|
|
112
|
+
const collisions = TOOL_NAMES.filter((name) => skillNames.has(name));
|
|
113
|
+
expect(collisions).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ── Dispatch round-trip ──────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
describe("registerHandlers — ListTools", () => {
|
|
120
|
+
let server: FakeServer;
|
|
121
|
+
|
|
122
|
+
beforeAll(() => {
|
|
123
|
+
server = new FakeServer();
|
|
124
|
+
registerHandlers(server as never, makeDeps());
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("registers all four request handlers", () => {
|
|
128
|
+
expect(server.handlers.has(ListToolsRequestSchema)).toBe(true);
|
|
129
|
+
expect(server.handlers.has(CallToolRequestSchema)).toBe(true);
|
|
130
|
+
expect(server.handlers.has(ListResourcesRequestSchema)).toBe(true);
|
|
131
|
+
expect(server.handlers.has(ReadResourceRequestSchema)).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("emits one entry per TOOLS row, with name+description+inputSchema", async () => {
|
|
135
|
+
const result = await server.call<{
|
|
136
|
+
tools: Array<{ name: string; description: string; inputSchema: unknown }>;
|
|
137
|
+
}>(ListToolsRequestSchema, { method: "tools/list" });
|
|
138
|
+
|
|
139
|
+
expect(result.tools.length).toBe(TOOL_NAMES.length);
|
|
140
|
+
|
|
141
|
+
const emittedNames = result.tools.map((t) => t.name).sort();
|
|
142
|
+
expect(emittedNames).toEqual([...TOOL_NAMES].sort());
|
|
143
|
+
|
|
144
|
+
for (const tool of result.tools) {
|
|
145
|
+
expect(typeof tool.name).toBe("string");
|
|
146
|
+
expect(typeof tool.description).toBe("string");
|
|
147
|
+
expect(typeof tool.inputSchema).toBe("object");
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("registerHandlers — CallTool", () => {
|
|
153
|
+
let server: FakeServer;
|
|
154
|
+
let workspaceId: string | null;
|
|
155
|
+
let projectId: string | null;
|
|
156
|
+
|
|
157
|
+
beforeAll(() => {
|
|
158
|
+
server = new FakeServer();
|
|
159
|
+
workspaceId = "22222222-2222-2222-2222-222222222222";
|
|
160
|
+
projectId = "11111111-1111-1111-1111-111111111111";
|
|
161
|
+
const deps = makeDeps({
|
|
162
|
+
getActiveWorkspaceId: () => workspaceId,
|
|
163
|
+
getActiveProjectId: () => projectId,
|
|
164
|
+
});
|
|
165
|
+
registerHandlers(server as never, deps);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("routes harmony_get_context and returns active ids", async () => {
|
|
169
|
+
const result = await server.call<{
|
|
170
|
+
content: Array<{ type: string; text: string }>;
|
|
171
|
+
isError?: boolean;
|
|
172
|
+
}>(CallToolRequestSchema, {
|
|
173
|
+
method: "tools/call",
|
|
174
|
+
params: { name: "harmony_get_context", arguments: {} },
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(result.isError).toBeUndefined();
|
|
178
|
+
expect(result.content[0].type).toBe("text");
|
|
179
|
+
const payload = JSON.parse(result.content[0].text);
|
|
180
|
+
expect(payload.success).toBe(true);
|
|
181
|
+
expect(payload.context.activeWorkspaceId).toBe(workspaceId);
|
|
182
|
+
expect(payload.context.activeProjectId).toBe(projectId);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("unknown tool name returns isError without throwing", async () => {
|
|
186
|
+
const result = await server.call<{
|
|
187
|
+
content: Array<{ type: string; text: string }>;
|
|
188
|
+
isError?: boolean;
|
|
189
|
+
}>(CallToolRequestSchema, {
|
|
190
|
+
method: "tools/call",
|
|
191
|
+
params: { name: "harmony_does_not_exist", arguments: {} },
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(result.isError).toBe(true);
|
|
195
|
+
expect(result.content[0].text.toLowerCase()).toContain("error");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("not-configured rejects authenticated tools", async () => {
|
|
199
|
+
const unconfigured = new FakeServer();
|
|
200
|
+
registerHandlers(
|
|
201
|
+
unconfigured as never,
|
|
202
|
+
makeDeps({ isConfigured: () => false }),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const result = await unconfigured.call<{
|
|
206
|
+
content: Array<{ type: string; text: string }>;
|
|
207
|
+
isError?: boolean;
|
|
208
|
+
}>(CallToolRequestSchema, {
|
|
209
|
+
method: "tools/call",
|
|
210
|
+
params: { name: "harmony_get_context", arguments: {} },
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(result.isError).toBe(true);
|
|
214
|
+
expect(result.content[0].text).toContain("Not configured");
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ── Resources ────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
describe("registerHandlers — Resources", () => {
|
|
221
|
+
let server: FakeServer;
|
|
222
|
+
|
|
223
|
+
beforeAll(() => {
|
|
224
|
+
server = new FakeServer();
|
|
225
|
+
registerHandlers(server as never, makeDeps());
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("ListResources mirrors the static RESOURCES array", async () => {
|
|
229
|
+
const result = await server.call<{ resources: typeof RESOURCES }>(
|
|
230
|
+
ListResourcesRequestSchema,
|
|
231
|
+
{ method: "resources/list" },
|
|
232
|
+
);
|
|
233
|
+
expect(result.resources).toEqual(RESOURCES);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("ReadResource serves harmony://context as JSON", async () => {
|
|
237
|
+
const result = await server.call<{
|
|
238
|
+
contents: Array<{ uri: string; mimeType: string; text: string }>;
|
|
239
|
+
}>(ReadResourceRequestSchema, {
|
|
240
|
+
method: "resources/read",
|
|
241
|
+
params: { uri: "harmony://context" },
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(result.contents[0].uri).toBe("harmony://context");
|
|
245
|
+
expect(result.contents[0].mimeType).toBe("application/json");
|
|
246
|
+
const parsed = JSON.parse(result.contents[0].text);
|
|
247
|
+
expect(parsed).toHaveProperty("configured");
|
|
248
|
+
expect(parsed).toHaveProperty("activeWorkspaceId");
|
|
249
|
+
expect(parsed).toHaveProperty("activeProjectId");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("ReadResource on unknown URI throws", async () => {
|
|
253
|
+
await expect(
|
|
254
|
+
server.call(ReadResourceRequestSchema, {
|
|
255
|
+
method: "resources/read",
|
|
256
|
+
params: { uri: "harmony://nope" },
|
|
257
|
+
}),
|
|
258
|
+
).rejects.toThrow(/Unknown resource/);
|
|
259
|
+
});
|
|
260
|
+
});
|