@gethmy/mcp 2.5.0 → 2.5.2
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 +3 -20
- package/dist/cli.js +1396 -32545
- package/dist/index.js +970 -28746
- package/dist/lib/api-client.js +11 -0
- package/package.json +3 -2
- package/src/api-client.ts +51 -0
- package/src/server.ts +66 -2
- package/src/skills.ts +67 -485
- package/src/tui/setup.ts +57 -40
- package/dist/http.js +0 -1959
- package/dist/remote.js +0 -32328
- package/dist/server.js +0 -31967
- package/src/__tests__/auto-session.test.ts +0 -912
- package/src/__tests__/graph-expansion.test.ts +0 -285
- package/src/__tests__/integration-memory-crud.test.ts +0 -948
- package/src/__tests__/integration-memory-system.test.ts +0 -321
- package/src/__tests__/mcp-integration.test.ts +0 -141
- package/src/__tests__/memory-floor.test.ts +0 -126
- package/src/__tests__/memory-park.test.ts +0 -213
- package/src/__tests__/memory-session.test.ts +0 -77
- package/src/__tests__/prompt-builder.test.ts +0 -739
- package/src/__tests__/remote-routing.test.ts +0 -285
- package/src/__tests__/skills.test.ts +0 -111
- package/src/__tests__/tool-dispatch.test.ts +0 -260
|
@@ -1,285 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Routing/auth tests for the remote MCP server (`src/remote.ts`).
|
|
3
|
-
*
|
|
4
|
-
* Covers the four session-routing branches mandated by the MCP Streamable
|
|
5
|
-
* HTTP spec — the regression that surfaced as "Harmony MCP not responding"
|
|
6
|
-
* after a server restart, OAuth refresh, or stale-session GC was a missing
|
|
7
|
-
* 404 branch for unknown `Mcp-Session-Id` values.
|
|
8
|
-
*
|
|
9
|
-
* These tests stub `globalThis.fetch` so we don't depend on harmony-api
|
|
10
|
-
* being reachable.
|
|
11
|
-
*
|
|
12
|
-
* Run with: bun test packages/mcp-server/src/__tests__/remote-routing.test.ts
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import {
|
|
16
|
-
afterAll,
|
|
17
|
-
afterEach,
|
|
18
|
-
beforeAll,
|
|
19
|
-
beforeEach,
|
|
20
|
-
describe,
|
|
21
|
-
expect,
|
|
22
|
-
test,
|
|
23
|
-
} from "bun:test";
|
|
24
|
-
|
|
25
|
-
const ORIGINAL_FETCH = globalThis.fetch;
|
|
26
|
-
|
|
27
|
-
// Fixed values used across tests
|
|
28
|
-
const TEST_USER = "user-alpha";
|
|
29
|
-
const OTHER_USER = "user-beta";
|
|
30
|
-
const TEST_WORKSPACE = "ws-1";
|
|
31
|
-
const VALID_TOKEN = "hmy_at_alpha_valid";
|
|
32
|
-
const REFRESHED_TOKEN = "hmy_at_alpha_refreshed";
|
|
33
|
-
const OTHER_USER_TOKEN = "hmy_at_beta_valid";
|
|
34
|
-
const REVOKED_TOKEN = "hmy_at_revoked";
|
|
35
|
-
|
|
36
|
-
function makeFetchStub() {
|
|
37
|
-
return async (
|
|
38
|
-
input: string | URL | Request,
|
|
39
|
-
init?: RequestInit,
|
|
40
|
-
): Promise<Response> => {
|
|
41
|
-
const url = typeof input === "string" ? input : input.toString();
|
|
42
|
-
const apiKey =
|
|
43
|
-
(init?.headers as Record<string, string> | undefined)?.["X-API-Key"] ??
|
|
44
|
-
"";
|
|
45
|
-
|
|
46
|
-
if (url.endsWith("/v1/auth/context")) {
|
|
47
|
-
const map: Record<
|
|
48
|
-
string,
|
|
49
|
-
{ userId: string; workspaceId: string | null; source: string } | null
|
|
50
|
-
> = {
|
|
51
|
-
[VALID_TOKEN]: {
|
|
52
|
-
userId: TEST_USER,
|
|
53
|
-
workspaceId: TEST_WORKSPACE,
|
|
54
|
-
source: "oauth",
|
|
55
|
-
},
|
|
56
|
-
[REFRESHED_TOKEN]: {
|
|
57
|
-
userId: TEST_USER,
|
|
58
|
-
workspaceId: TEST_WORKSPACE,
|
|
59
|
-
source: "oauth",
|
|
60
|
-
},
|
|
61
|
-
[OTHER_USER_TOKEN]: {
|
|
62
|
-
userId: OTHER_USER,
|
|
63
|
-
workspaceId: "ws-2",
|
|
64
|
-
source: "oauth",
|
|
65
|
-
},
|
|
66
|
-
};
|
|
67
|
-
const ctx = map[apiKey];
|
|
68
|
-
if (!ctx) {
|
|
69
|
-
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
|
70
|
-
status: 401,
|
|
71
|
-
headers: { "Content-Type": "application/json" },
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
return new Response(JSON.stringify(ctx), {
|
|
75
|
-
status: 200,
|
|
76
|
-
headers: { "Content-Type": "application/json" },
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Anything else — return a benign 404 so unrelated lookups don't blow up.
|
|
81
|
-
return new Response("{}", {
|
|
82
|
-
status: 404,
|
|
83
|
-
headers: { "Content-Type": "application/json" },
|
|
84
|
-
});
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
let fetchHandler: (req: Request) => Promise<Response>;
|
|
89
|
-
let _sessionsForTests: Map<string, unknown>;
|
|
90
|
-
|
|
91
|
-
beforeAll(async () => {
|
|
92
|
-
globalThis.fetch = makeFetchStub() as unknown as typeof fetch;
|
|
93
|
-
// Import after the stub is in place (module init is synchronous-only.)
|
|
94
|
-
const mod = await import("../remote.js");
|
|
95
|
-
fetchHandler = mod.fetchHandler as (req: Request) => Promise<Response>;
|
|
96
|
-
_sessionsForTests = mod._sessionsForTests as Map<string, unknown>;
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
afterAll(() => {
|
|
100
|
-
globalThis.fetch = ORIGINAL_FETCH;
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
beforeEach(() => {
|
|
104
|
-
// Refresh the fetch stub each test (preserves across the cache TTL).
|
|
105
|
-
globalThis.fetch = makeFetchStub() as unknown as typeof fetch;
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
afterEach(() => {
|
|
109
|
-
// Wipe sessions between tests so state doesn't leak.
|
|
110
|
-
_sessionsForTests.clear();
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
const INIT_BODY = {
|
|
114
|
-
jsonrpc: "2.0",
|
|
115
|
-
id: 1,
|
|
116
|
-
method: "initialize",
|
|
117
|
-
params: {
|
|
118
|
-
protocolVersion: "2025-06-18",
|
|
119
|
-
capabilities: {},
|
|
120
|
-
clientInfo: { name: "test", version: "0.0.1" },
|
|
121
|
-
},
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
const TOOLS_LIST_BODY = {
|
|
125
|
-
jsonrpc: "2.0",
|
|
126
|
-
id: 2,
|
|
127
|
-
method: "tools/list",
|
|
128
|
-
params: {},
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
function makePost(
|
|
132
|
-
body: unknown,
|
|
133
|
-
opts: { token?: string; sessionId?: string } = {},
|
|
134
|
-
): Request {
|
|
135
|
-
const headers: Record<string, string> = {
|
|
136
|
-
"Content-Type": "application/json",
|
|
137
|
-
Accept: "application/json, text/event-stream",
|
|
138
|
-
};
|
|
139
|
-
if (opts.token) headers.Authorization = `Bearer ${opts.token}`;
|
|
140
|
-
if (opts.sessionId) headers["Mcp-Session-Id"] = opts.sessionId;
|
|
141
|
-
return new Request("http://localhost/mcp", {
|
|
142
|
-
method: "POST",
|
|
143
|
-
headers,
|
|
144
|
-
body: JSON.stringify(body),
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
describe("remote MCP routing", () => {
|
|
149
|
-
test("no Authorization header → 401 + WWW-Authenticate", async () => {
|
|
150
|
-
const res = await fetchHandler(
|
|
151
|
-
new Request("http://localhost/mcp", { method: "POST" }),
|
|
152
|
-
);
|
|
153
|
-
expect(res.status).toBe(401);
|
|
154
|
-
const wwwAuth = res.headers.get("WWW-Authenticate") ?? "";
|
|
155
|
-
expect(wwwAuth).toContain("Bearer");
|
|
156
|
-
expect(wwwAuth).toContain("resource_metadata=");
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
test("invalid bearer on initialize → 401 invalid_token", async () => {
|
|
160
|
-
const res = await fetchHandler(
|
|
161
|
-
makePost(INIT_BODY, { token: REVOKED_TOKEN }),
|
|
162
|
-
);
|
|
163
|
-
expect(res.status).toBe(401);
|
|
164
|
-
expect(res.headers.get("WWW-Authenticate") ?? "").toContain(
|
|
165
|
-
"invalid_token",
|
|
166
|
-
);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
test("POST without session id and not initialize → 400", async () => {
|
|
170
|
-
const res = await fetchHandler(
|
|
171
|
-
makePost(TOOLS_LIST_BODY, { token: VALID_TOKEN }),
|
|
172
|
-
);
|
|
173
|
-
expect(res.status).toBe(400);
|
|
174
|
-
const body = (await res.json()) as { error: { message: string } };
|
|
175
|
-
expect(body.error.message).toMatch(/Mcp-Session-Id header required/i);
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
test("POST with unknown session id → 404 (forces client re-init)", async () => {
|
|
179
|
-
const res = await fetchHandler(
|
|
180
|
-
makePost(TOOLS_LIST_BODY, {
|
|
181
|
-
token: VALID_TOKEN,
|
|
182
|
-
sessionId: "ghost-session-id",
|
|
183
|
-
}),
|
|
184
|
-
);
|
|
185
|
-
// This is the spec-mandated behavior that fixes "Harmony MCP not responding".
|
|
186
|
-
expect(res.status).toBe(404);
|
|
187
|
-
const body = (await res.json()) as { error: { code: number } };
|
|
188
|
-
expect(body.error.code).toBe(-32001);
|
|
189
|
-
expect(res.headers.get("Mcp-Session-Id")).toBe("ghost-session-id");
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
test("initialize succeeds and registers a session", async () => {
|
|
193
|
-
const res = await fetchHandler(makePost(INIT_BODY, { token: VALID_TOKEN }));
|
|
194
|
-
expect(res.status).toBe(200);
|
|
195
|
-
const sid = res.headers.get("mcp-session-id");
|
|
196
|
-
expect(sid).toBeTruthy();
|
|
197
|
-
expect(_sessionsForTests.has(sid!)).toBe(true);
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
test("rotated token (same user) is accepted on hot-swap", async () => {
|
|
201
|
-
// Bootstrap a session.
|
|
202
|
-
const initRes = await fetchHandler(
|
|
203
|
-
makePost(INIT_BODY, { token: VALID_TOKEN }),
|
|
204
|
-
);
|
|
205
|
-
const sid = initRes.headers.get("mcp-session-id")!;
|
|
206
|
-
|
|
207
|
-
// Send a follow-up with the *new* token + same session id.
|
|
208
|
-
// We're not asserting on the body — just that the rotation didn't
|
|
209
|
-
// produce a 401/404, which it would if hot-swap were broken.
|
|
210
|
-
const follow = await fetchHandler(
|
|
211
|
-
makePost(TOOLS_LIST_BODY, {
|
|
212
|
-
token: REFRESHED_TOKEN,
|
|
213
|
-
sessionId: sid,
|
|
214
|
-
}),
|
|
215
|
-
);
|
|
216
|
-
expect(follow.status).not.toBe(401);
|
|
217
|
-
expect(follow.status).not.toBe(404);
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
test("token from a different user is REJECTED on hot-swap", async () => {
|
|
221
|
-
const initRes = await fetchHandler(
|
|
222
|
-
makePost(INIT_BODY, { token: VALID_TOKEN }),
|
|
223
|
-
);
|
|
224
|
-
const sid = initRes.headers.get("mcp-session-id")!;
|
|
225
|
-
|
|
226
|
-
// Attempt to ride the session with another user's bearer.
|
|
227
|
-
const hijack = await fetchHandler(
|
|
228
|
-
makePost(TOOLS_LIST_BODY, {
|
|
229
|
-
token: OTHER_USER_TOKEN,
|
|
230
|
-
sessionId: sid,
|
|
231
|
-
}),
|
|
232
|
-
);
|
|
233
|
-
expect(hijack.status).toBe(401);
|
|
234
|
-
const wwwAuth = hijack.headers.get("WWW-Authenticate") ?? "";
|
|
235
|
-
expect(wwwAuth).toContain("invalid_token");
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
test("GET without valid session id → 400", async () => {
|
|
239
|
-
const res = await fetchHandler(
|
|
240
|
-
new Request("http://localhost/mcp", {
|
|
241
|
-
method: "GET",
|
|
242
|
-
headers: {
|
|
243
|
-
Authorization: `Bearer ${VALID_TOKEN}`,
|
|
244
|
-
Accept: "text/event-stream",
|
|
245
|
-
},
|
|
246
|
-
}),
|
|
247
|
-
);
|
|
248
|
-
// GET without a session id falls into "session required" — 400.
|
|
249
|
-
expect(res.status).toBe(400);
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
test("GET with unknown session id → 404", async () => {
|
|
253
|
-
const res = await fetchHandler(
|
|
254
|
-
new Request("http://localhost/mcp", {
|
|
255
|
-
method: "GET",
|
|
256
|
-
headers: {
|
|
257
|
-
Authorization: `Bearer ${VALID_TOKEN}`,
|
|
258
|
-
Accept: "text/event-stream",
|
|
259
|
-
"Mcp-Session-Id": "ghost",
|
|
260
|
-
},
|
|
261
|
-
}),
|
|
262
|
-
);
|
|
263
|
-
expect(res.status).toBe(404);
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
test("/.well-known/oauth-protected-resource is unauthenticated", async () => {
|
|
267
|
-
const res = await fetchHandler(
|
|
268
|
-
new Request("http://localhost/.well-known/oauth-protected-resource"),
|
|
269
|
-
);
|
|
270
|
-
expect(res.status).toBe(200);
|
|
271
|
-
const body = (await res.json()) as {
|
|
272
|
-
authorization_servers: string[];
|
|
273
|
-
bearer_methods_supported: string[];
|
|
274
|
-
};
|
|
275
|
-
expect(body.authorization_servers.length).toBeGreaterThan(0);
|
|
276
|
-
expect(body.bearer_methods_supported).toContain("header");
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
test("/health is unauthenticated", async () => {
|
|
280
|
-
const res = await fetchHandler(new Request("http://localhost/health"));
|
|
281
|
-
expect(res.status).toBe(200);
|
|
282
|
-
const body = (await res.json()) as { status: string };
|
|
283
|
-
expect(body.status).toBe("ok");
|
|
284
|
-
});
|
|
285
|
-
});
|
|
@@ -1,111 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,260 +0,0 @@
|
|
|
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
|
-
});
|