@rubytech/create-maxy-code 0.1.60 → 0.1.62

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.
Files changed (163) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/PLUGIN.md +1 -1
  3. package/payload/platform/plugins/brochures/skills/property-brochure/SKILL.md +26 -3
  4. package/payload/platform/plugins/brochures/skills/property-brochure/references/registers.md +1 -1
  5. package/payload/platform/plugins/docs/references/admin-session.md +2 -2
  6. package/payload/platform/plugins/docs/references/internals.md +1 -1
  7. package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
  8. package/payload/platform/plugins/epc/.claude-plugin/plugin.json +17 -0
  9. package/payload/platform/plugins/epc/PLUGIN.md +85 -0
  10. package/payload/platform/plugins/epc/mcp/dist/index.d.ts +2 -0
  11. package/payload/platform/plugins/epc/mcp/dist/index.d.ts.map +1 -0
  12. package/payload/platform/plugins/epc/mcp/dist/index.js +117 -0
  13. package/payload/platform/plugins/epc/mcp/dist/index.js.map +1 -0
  14. package/payload/platform/plugins/epc/mcp/dist/lib/crypto.d.ts +3 -0
  15. package/payload/platform/plugins/epc/mcp/dist/lib/crypto.d.ts.map +1 -0
  16. package/payload/platform/plugins/epc/mcp/dist/lib/crypto.js +72 -0
  17. package/payload/platform/plugins/epc/mcp/dist/lib/crypto.js.map +1 -0
  18. package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.d.ts +60 -0
  19. package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.d.ts.map +1 -0
  20. package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.js +181 -0
  21. package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.js.map +1 -0
  22. package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.d.ts +3 -0
  23. package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.d.ts.map +1 -0
  24. package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.js +49 -0
  25. package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.js.map +1 -0
  26. package/payload/platform/plugins/epc/mcp/dist/lib/key-store.d.ts +15 -0
  27. package/payload/platform/plugins/epc/mcp/dist/lib/key-store.d.ts.map +1 -0
  28. package/payload/platform/plugins/epc/mcp/dist/lib/key-store.js +130 -0
  29. package/payload/platform/plugins/epc/mcp/dist/lib/key-store.js.map +1 -0
  30. package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.d.ts +5 -0
  31. package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.d.ts.map +1 -0
  32. package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.js +38 -0
  33. package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.js.map +1 -0
  34. package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.d.ts +4 -0
  35. package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.d.ts.map +1 -0
  36. package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.js +9 -0
  37. package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.js.map +1 -0
  38. package/payload/platform/plugins/epc/mcp/dist/tools/key-list.d.ts +4 -0
  39. package/payload/platform/plugins/epc/mcp/dist/tools/key-list.d.ts.map +1 -0
  40. package/payload/platform/plugins/epc/mcp/dist/tools/key-list.js +10 -0
  41. package/payload/platform/plugins/epc/mcp/dist/tools/key-list.js.map +1 -0
  42. package/payload/platform/plugins/epc/mcp/dist/tools/key-register.d.ts +5 -0
  43. package/payload/platform/plugins/epc/mcp/dist/tools/key-register.d.ts.map +1 -0
  44. package/payload/platform/plugins/epc/mcp/dist/tools/key-register.js +36 -0
  45. package/payload/platform/plugins/epc/mcp/dist/tools/key-register.js.map +1 -0
  46. package/payload/platform/plugins/epc/mcp/dist/tools/lookup.d.ts +10 -0
  47. package/payload/platform/plugins/epc/mcp/dist/tools/lookup.d.ts.map +1 -0
  48. package/payload/platform/plugins/epc/mcp/dist/tools/lookup.js +30 -0
  49. package/payload/platform/plugins/epc/mcp/dist/tools/lookup.js.map +1 -0
  50. package/payload/platform/plugins/epc/mcp/package-lock.json +2566 -0
  51. package/payload/platform/plugins/epc/mcp/package.json +21 -0
  52. package/payload/platform/plugins/epc/mcp/src/__tests__/epc-api.test.ts +251 -0
  53. package/payload/platform/plugins/epc/mcp/src/__tests__/key-roundtrip.test.ts +113 -0
  54. package/payload/platform/plugins/epc/mcp/src/__tests__/lookup.test.ts +181 -0
  55. package/payload/platform/plugins/epc/mcp/src/__tests__/schema-parity.test.ts +54 -0
  56. package/payload/platform/plugins/epc/mcp/src/index.ts +156 -0
  57. package/payload/platform/plugins/epc/mcp/src/lib/crypto.ts +79 -0
  58. package/payload/platform/plugins/epc/mcp/src/lib/epc-api.ts +241 -0
  59. package/payload/platform/plugins/epc/mcp/src/lib/file-crypto.ts +55 -0
  60. package/payload/platform/plugins/epc/mcp/src/lib/key-store.ts +172 -0
  61. package/payload/platform/plugins/epc/mcp/src/lib/neo4j.ts +47 -0
  62. package/payload/platform/plugins/epc/mcp/src/tools/key-deregister.ts +9 -0
  63. package/payload/platform/plugins/epc/mcp/src/tools/key-list.ts +10 -0
  64. package/payload/platform/plugins/epc/mcp/src/tools/key-register.ts +44 -0
  65. package/payload/platform/plugins/epc/mcp/src/tools/lookup.ts +43 -0
  66. package/payload/platform/plugins/epc/mcp/tsconfig.json +20 -0
  67. package/payload/platform/plugins/epc/mcp/vitest.config.ts +8 -0
  68. package/payload/platform/plugins/preval/.claude-plugin/plugin.json +1 -1
  69. package/payload/platform/plugins/preval/PLUGIN.md +25 -17
  70. package/payload/platform/plugins/preval/skills/property-preval/SKILL.md +107 -39
  71. package/payload/platform/plugins/preval/skills/property-preval/references/render.py +449 -84
  72. package/payload/platform/plugins/preval/skills/property-preval/references/template-inputs.schema.json +126 -80
  73. package/payload/platform/plugins/preval/skills/property-preval/references/template.html +183 -124
  74. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  75. package/payload/platform/services/claude-session-manager/dist/http-server.js +6 -1
  76. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  77. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +7 -0
  78. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  79. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +8 -0
  80. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  81. package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
  82. package/payload/platform/templates/agents/admin/SOUL.md +1 -1
  83. package/payload/premium-plugins/real-agent/BUNDLE.md +4 -2
  84. package/payload/premium-plugins/real-agent/agents/valuer.md +1 -1
  85. package/payload/premium-plugins/real-agent/plugins/.claude-plugin/marketplace.json +5 -0
  86. package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/SKILL.md +26 -3
  87. package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/references/registers.md +1 -1
  88. package/payload/premium-plugins/real-agent/plugins/epc/.claude-plugin/plugin.json +17 -0
  89. package/payload/premium-plugins/real-agent/plugins/epc/PLUGIN.md +85 -0
  90. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.d.ts +2 -0
  91. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.d.ts.map +1 -0
  92. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.js +117 -0
  93. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.js.map +1 -0
  94. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.d.ts +3 -0
  95. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.d.ts.map +1 -0
  96. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.js +72 -0
  97. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.js.map +1 -0
  98. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.d.ts +60 -0
  99. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.d.ts.map +1 -0
  100. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.js +181 -0
  101. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.js.map +1 -0
  102. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.d.ts +3 -0
  103. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.d.ts.map +1 -0
  104. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.js +49 -0
  105. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.js.map +1 -0
  106. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.d.ts +15 -0
  107. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.d.ts.map +1 -0
  108. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.js +130 -0
  109. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.js.map +1 -0
  110. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.d.ts +5 -0
  111. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.d.ts.map +1 -0
  112. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.js +38 -0
  113. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.js.map +1 -0
  114. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.d.ts +4 -0
  115. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.d.ts.map +1 -0
  116. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.js +9 -0
  117. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.js.map +1 -0
  118. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.d.ts +4 -0
  119. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.d.ts.map +1 -0
  120. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.js +10 -0
  121. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.js.map +1 -0
  122. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.d.ts +5 -0
  123. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.d.ts.map +1 -0
  124. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.js +36 -0
  125. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.js.map +1 -0
  126. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.d.ts +10 -0
  127. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.d.ts.map +1 -0
  128. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.js +30 -0
  129. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.js.map +1 -0
  130. package/payload/premium-plugins/real-agent/plugins/epc/mcp/package-lock.json +2566 -0
  131. package/payload/premium-plugins/real-agent/plugins/epc/mcp/package.json +21 -0
  132. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/epc-api.test.ts +251 -0
  133. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/key-roundtrip.test.ts +113 -0
  134. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/lookup.test.ts +181 -0
  135. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/schema-parity.test.ts +54 -0
  136. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/index.ts +156 -0
  137. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/crypto.ts +79 -0
  138. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/epc-api.ts +241 -0
  139. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/file-crypto.ts +55 -0
  140. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/key-store.ts +172 -0
  141. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/neo4j.ts +47 -0
  142. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/key-deregister.ts +9 -0
  143. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/key-list.ts +10 -0
  144. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/key-register.ts +44 -0
  145. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/lookup.ts +43 -0
  146. package/payload/premium-plugins/real-agent/plugins/epc/mcp/tsconfig.json +20 -0
  147. package/payload/premium-plugins/real-agent/plugins/epc/mcp/vitest.config.ts +8 -0
  148. package/payload/premium-plugins/real-agent/plugins/preval/.claude-plugin/plugin.json +1 -1
  149. package/payload/premium-plugins/real-agent/plugins/preval/PLUGIN.md +25 -17
  150. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/SKILL.md +107 -39
  151. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/render.py +449 -84
  152. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template-inputs.schema.json +126 -80
  153. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template.html +183 -124
  154. package/payload/server/public/assets/{admin-Bp-BjBCX.js → admin-DhN3G8W7.js} +1 -1
  155. package/payload/server/public/assets/{data-BGUAGVkV.js → data-B2ZVXOcE.js} +1 -1
  156. package/payload/server/public/assets/{graph-MvYxZOBF.js → graph-BAMGPHrK.js} +1 -1
  157. package/payload/server/public/assets/{graph-labels-D865qb3K.js → graph-labels-D9eBbvxo.js} +1 -1
  158. package/payload/server/public/assets/{page-C2b1nlOc.js → page-CV27Al6Z.js} +1 -1
  159. package/payload/server/public/assets/{page--hOVRrgN.js → page-DjdVMWCz.js} +1 -1
  160. package/payload/server/public/data.html +3 -3
  161. package/payload/server/public/graph.html +3 -3
  162. package/payload/server/public/index.html +4 -4
  163. package/payload/server/server.js +3 -16
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@maxy/epc",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "start": "node dist/index.js",
10
+ "test": "vitest run"
11
+ },
12
+ "dependencies": {
13
+ "@modelcontextprotocol/sdk": "^1.12.1",
14
+ "neo4j-driver": "^5.28.1"
15
+ },
16
+ "devDependencies": {
17
+ "typescript": "^5.7.0",
18
+ "@types/node": "^22.0.0",
19
+ "vitest": "^4.1.2"
20
+ }
21
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * epc-api.ts behaviour with mocked fetch + mocked neo4j store. Covers:
3
+ * - missing-key path throws KeyNotRegisteredError
4
+ * - 4xx upstream (non-404) maps to EpcError with status preserved
5
+ * - 5xx upstream maps to EpcError with status preserved
6
+ * - 404 maps to { status: 404, rows: [] } (caller turns into ok+empty)
7
+ * - non-JSON body maps to BadResponseError
8
+ * - malformed JSON (missing rows) maps to BadResponseError
9
+ * - happy path returns parsed rows
10
+ * - groupAndPickMostRecent picks the latest lodgement-date per UPRN
11
+ */
12
+
13
+ import { afterEach, describe, expect, it, vi } from "vitest";
14
+
15
+ vi.mock("../lib/neo4j.js", () => {
16
+ let stored: { encryptedKey: string } | null = null;
17
+ return {
18
+ getSession: () => ({
19
+ run: vi.fn(async (cypher: string, params: Record<string, unknown>) => {
20
+ if (/MATCH \(k:EpcApiKey/.test(cypher) && /RETURN/.test(cypher)) {
21
+ return {
22
+ records: stored
23
+ ? [
24
+ {
25
+ get: (k: string) => {
26
+ if (k === "encryptedKey") return stored!.encryptedKey;
27
+ if (k === "createdAt") return "2026-01-01T00:00:00Z";
28
+ if (k === "updatedAt") return "2026-01-01T00:00:00Z";
29
+ return null;
30
+ },
31
+ },
32
+ ]
33
+ : [],
34
+ };
35
+ }
36
+ if (/MERGE \(k:EpcApiKey/.test(cypher)) {
37
+ stored = { encryptedKey: String(params.encryptedKey) };
38
+ return { records: [] };
39
+ }
40
+ if (/DELETE k/.test(cypher)) {
41
+ const count = stored ? 1 : 0;
42
+ stored = null;
43
+ return {
44
+ records: [{ get: (k: string) => (k === "deleted" ? { toNumber: () => count } : null) }],
45
+ };
46
+ }
47
+ return { records: [] };
48
+ }),
49
+ close: vi.fn(async () => {}),
50
+ }),
51
+ closeDriver: vi.fn(async () => {}),
52
+ };
53
+ });
54
+
55
+ vi.mock("../lib/crypto.js", () => ({
56
+ encrypt: (s: string) => `enc:${s}`,
57
+ decrypt: (s: string) => s.replace(/^enc:/, ""),
58
+ }));
59
+
60
+ import {
61
+ epcSearch,
62
+ groupAndPickMostRecent,
63
+ KeyNotRegisteredError,
64
+ EpcError,
65
+ BadResponseError,
66
+ M2_TO_SQFT,
67
+ } from "../lib/epc-api.js";
68
+ import { keyRegister } from "../tools/key-register.js";
69
+
70
+ const originalFetch = globalThis.fetch;
71
+
72
+ function mockFetch(impl: (url: string, init?: RequestInit) => Response | Promise<Response>) {
73
+ globalThis.fetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) =>
74
+ impl(String(url), init),
75
+ ) as unknown as typeof fetch;
76
+ }
77
+
78
+ afterEach(() => {
79
+ globalThis.fetch = originalFetch;
80
+ vi.restoreAllMocks();
81
+ });
82
+
83
+ async function registerTestKey(accountId = "acc-1") {
84
+ // Register-time probe gets 200 -> validated.
85
+ mockFetch(() => new Response(JSON.stringify({ rows: [] }), { status: 200 }));
86
+ await keyRegister({ apiKey: "tester@example.com:abc12345", accountId });
87
+ }
88
+
89
+ describe("epcSearch", () => {
90
+ it("throws KeyNotRegisteredError when no key is stored", async () => {
91
+ await expect(
92
+ epcSearch({
93
+ toolName: "epc-lookup-by-address",
94
+ accountId: "ghost-account",
95
+ identifier: "1 The Street",
96
+ params: { address: "1 The Street" },
97
+ }),
98
+ ).rejects.toBeInstanceOf(KeyNotRegisteredError);
99
+ });
100
+
101
+ it("returns parsed rows on success", async () => {
102
+ await registerTestKey();
103
+ mockFetch(
104
+ () =>
105
+ new Response(
106
+ JSON.stringify({
107
+ rows: [{ "lmk-key": "abc", uprn: "1", "total-floor-area": "70.0" }],
108
+ }),
109
+ { status: 200 },
110
+ ),
111
+ );
112
+ const { status, rows } = await epcSearch({
113
+ toolName: "epc-lookup-by-address",
114
+ accountId: "acc-1",
115
+ identifier: "1 The Street",
116
+ params: { address: "1 The Street", postcode: "CM23 5SR" },
117
+ });
118
+ expect(status).toBe(200);
119
+ expect(rows).toHaveLength(1);
120
+ expect(rows[0].uprn).toBe("1");
121
+ });
122
+
123
+ it("returns empty rows on HTTP 404 (no EPC on file)", async () => {
124
+ await registerTestKey();
125
+ mockFetch(() => new Response("not found", { status: 404 }));
126
+ const { status, rows } = await epcSearch({
127
+ toolName: "epc-lookup-by-address",
128
+ accountId: "acc-1",
129
+ identifier: "999 Nowhere",
130
+ params: { address: "999 Nowhere" },
131
+ });
132
+ expect(status).toBe(404);
133
+ expect(rows).toEqual([]);
134
+ });
135
+
136
+ it("maps HTTP 4xx (non-404) to EpcError preserving status", async () => {
137
+ await registerTestKey();
138
+ mockFetch(() => new Response("rate limited", { status: 429 }));
139
+ try {
140
+ await epcSearch({
141
+ toolName: "epc-lookup-by-address",
142
+ accountId: "acc-1",
143
+ identifier: "1 The Street",
144
+ params: { address: "1 The Street" },
145
+ });
146
+ throw new Error("expected EpcError");
147
+ } catch (err) {
148
+ expect(err).toBeInstanceOf(EpcError);
149
+ expect((err as EpcError).status).toBe(429);
150
+ }
151
+ });
152
+
153
+ it("maps HTTP 5xx to EpcError preserving status", async () => {
154
+ await registerTestKey();
155
+ mockFetch(() => new Response("upstream down", { status: 500 }));
156
+ try {
157
+ await epcSearch({
158
+ toolName: "epc-lookup-by-address",
159
+ accountId: "acc-1",
160
+ identifier: "1 The Street",
161
+ params: { address: "1 The Street" },
162
+ });
163
+ throw new Error("expected EpcError");
164
+ } catch (err) {
165
+ expect(err).toBeInstanceOf(EpcError);
166
+ expect((err as EpcError).status).toBe(500);
167
+ }
168
+ });
169
+
170
+ it("maps non-JSON body to BadResponseError", async () => {
171
+ await registerTestKey();
172
+ mockFetch(() => new Response("<html>maintenance</html>", { status: 200 }));
173
+ await expect(
174
+ epcSearch({
175
+ toolName: "epc-lookup-by-address",
176
+ accountId: "acc-1",
177
+ identifier: "1 The Street",
178
+ params: { address: "1 The Street" },
179
+ }),
180
+ ).rejects.toBeInstanceOf(BadResponseError);
181
+ });
182
+
183
+ it("maps JSON without rows array to BadResponseError", async () => {
184
+ await registerTestKey();
185
+ mockFetch(() => new Response(JSON.stringify({ error: "schema drift" }), { status: 200 }));
186
+ await expect(
187
+ epcSearch({
188
+ toolName: "epc-lookup-by-address",
189
+ accountId: "acc-1",
190
+ identifier: "1 The Street",
191
+ params: { address: "1 The Street" },
192
+ }),
193
+ ).rejects.toBeInstanceOf(BadResponseError);
194
+ });
195
+
196
+ it("sends Authorization: Basic header", async () => {
197
+ await registerTestKey();
198
+ let seenAuth: string | undefined;
199
+ mockFetch((_url, init) => {
200
+ const headers = init?.headers as Record<string, string> | undefined;
201
+ seenAuth = headers?.Authorization;
202
+ return new Response(JSON.stringify({ rows: [] }), { status: 200 });
203
+ });
204
+ await epcSearch({
205
+ toolName: "epc-lookup-by-address",
206
+ accountId: "acc-1",
207
+ identifier: "1 The Street",
208
+ params: { address: "1 The Street" },
209
+ });
210
+ expect(seenAuth).toMatch(/^Basic /);
211
+ const credential = Buffer.from(seenAuth!.replace(/^Basic /, ""), "base64").toString("utf-8");
212
+ expect(credential).toBe("tester@example.com:abc12345");
213
+ });
214
+ });
215
+
216
+ describe("groupAndPickMostRecent", () => {
217
+ it("picks the latest lodgement-date per UPRN and converts m² to sqft", () => {
218
+ const rows = [
219
+ { uprn: "100", "lodgement-date": "2018-04-01", "total-floor-area": "70.0", address1: "1 The Street", postcode: "CM23 5SR", "current-energy-rating": "D", "potential-energy-rating": "C" },
220
+ { uprn: "100", "lodgement-date": "2024-09-15", "total-floor-area": "72.5", address1: "1 The Street", postcode: "CM23 5SR", "current-energy-rating": "C", "potential-energy-rating": "B" },
221
+ { uprn: "200", "lodgement-date": "2021-01-01", "total-floor-area": "55.0", address1: "2 The Street", postcode: "CM23 5SR", "current-energy-rating": "E", "potential-energy-rating": "C" },
222
+ ];
223
+ const matches = groupAndPickMostRecent(rows);
224
+ expect(matches).toHaveLength(2);
225
+ const m100 = matches.find((m) => m.uprn === "100")!;
226
+ expect(m100.lodgement_date).toBe("2024-09-15");
227
+ expect(m100.total_floor_area_m2).toBe(72.5);
228
+ expect(m100.total_floor_area_sqft).toBe(Number((72.5 * M2_TO_SQFT).toFixed(2)));
229
+ expect(m100.current_energy_rating).toBe("C");
230
+ const m200 = matches.find((m) => m.uprn === "200")!;
231
+ expect(m200.total_floor_area_m2).toBe(55.0);
232
+ });
233
+
234
+ it("falls back to address+postcode when UPRN is absent", () => {
235
+ const rows = [
236
+ { "lodgement-date": "2020-01-01", "total-floor-area": "60", address1: "5 The Lane", postcode: "CM23 1AA" },
237
+ { "lodgement-date": "2022-06-01", "total-floor-area": "61", address1: "5 The Lane", postcode: "CM23 1AA" },
238
+ ];
239
+ const matches = groupAndPickMostRecent(rows);
240
+ expect(matches).toHaveLength(1);
241
+ expect(matches[0].lodgement_date).toBe("2022-06-01");
242
+ expect(matches[0].uprn).toBeNull();
243
+ });
244
+
245
+ it("tolerates missing total-floor-area", () => {
246
+ const rows = [{ uprn: "300", "lodgement-date": "2023-01-01" }];
247
+ const matches = groupAndPickMostRecent(rows);
248
+ expect(matches[0].total_floor_area_m2).toBeNull();
249
+ expect(matches[0].total_floor_area_sqft).toBeNull();
250
+ });
251
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * EPC key register / list / deregister round-trip against a mocked Neo4j
3
+ * session, mocked crypto, and mocked fetch for the register-time probe
4
+ * against the EPC register.
5
+ */
6
+
7
+ import { afterEach, describe, expect, it, vi } from "vitest";
8
+
9
+ vi.mock("../lib/neo4j.js", () => {
10
+ let stored: { encryptedKey: string; createdAt: string; updatedAt: string } | null = null;
11
+ return {
12
+ getSession: () => ({
13
+ run: vi.fn(async (cypher: string, params: Record<string, unknown>) => {
14
+ if (/CREATE INDEX/.test(cypher)) return { records: [] };
15
+ if (/MERGE \(k:EpcApiKey/.test(cypher)) {
16
+ const now = String(params.now ?? new Date().toISOString());
17
+ stored = {
18
+ encryptedKey: String(params.encryptedKey),
19
+ createdAt: stored?.createdAt ?? now,
20
+ updatedAt: now,
21
+ };
22
+ return { records: [] };
23
+ }
24
+ if (/MATCH \(k:EpcApiKey/.test(cypher) && /DELETE/.test(cypher)) {
25
+ const count = stored ? 1 : 0;
26
+ stored = null;
27
+ return {
28
+ records: [{ get: (k: string) => (k === "deleted" ? { toNumber: () => count } : null) }],
29
+ };
30
+ }
31
+ if (/MATCH \(k:EpcApiKey/.test(cypher) && /RETURN/.test(cypher)) {
32
+ return {
33
+ records: stored
34
+ ? [
35
+ {
36
+ get: (k: string) => {
37
+ if (k === "encryptedKey") return stored!.encryptedKey;
38
+ if (k === "createdAt") return stored!.createdAt;
39
+ if (k === "updatedAt") return stored!.updatedAt;
40
+ return null;
41
+ },
42
+ },
43
+ ]
44
+ : [],
45
+ };
46
+ }
47
+ return { records: [] };
48
+ }),
49
+ close: vi.fn(async () => {}),
50
+ }),
51
+ closeDriver: vi.fn(async () => {}),
52
+ };
53
+ });
54
+
55
+ vi.mock("../lib/crypto.js", () => ({
56
+ encrypt: (s: string) => `enc:${s}`,
57
+ decrypt: (s: string) => s.replace(/^enc:/, ""),
58
+ }));
59
+
60
+ import { keyRegister } from "../tools/key-register.js";
61
+ import { keyDeregister } from "../tools/key-deregister.js";
62
+ import { keyList } from "../tools/key-list.js";
63
+
64
+ const originalFetch = globalThis.fetch;
65
+
66
+ afterEach(() => {
67
+ globalThis.fetch = originalFetch;
68
+ vi.restoreAllMocks();
69
+ });
70
+
71
+ describe("epc key round-trip", () => {
72
+ it("rejects an unregistered account on list", async () => {
73
+ const out = await keyList({ accountId: "acc-empty" });
74
+ expect(out).toMatch(/No EPC API key registered/);
75
+ });
76
+
77
+ it("rejects deregister when no key is stored", async () => {
78
+ await expect(keyDeregister({ accountId: "acc-empty-2" })).rejects.toThrow(
79
+ /No EPC API key/,
80
+ );
81
+ });
82
+
83
+ it("rejects credentials without the required email:api-key shape", async () => {
84
+ await expect(
85
+ keyRegister({ apiKey: "just-the-key", accountId: "acc-bad" }),
86
+ ).rejects.toThrow(/email:api-key/);
87
+ });
88
+
89
+ it("rejects registration when EPC returns 401", async () => {
90
+ globalThis.fetch = vi.fn(
91
+ async () => new Response("unauthorized", { status: 401 }),
92
+ ) as unknown as typeof fetch;
93
+ await expect(
94
+ keyRegister({ apiKey: "tester@example.com:bad-key", accountId: "acc-x" }),
95
+ ).rejects.toThrow(/rejected the credential/);
96
+ });
97
+
98
+ it("registers, lists, then deregisters successfully and never reveals the credential", async () => {
99
+ globalThis.fetch = vi.fn(
100
+ async () => new Response(JSON.stringify({ rows: [] }), { status: 200 }),
101
+ ) as unknown as typeof fetch;
102
+ await keyRegister({ apiKey: "tester@example.com:good-key-12345", accountId: "acc-r" });
103
+
104
+ const listed = await keyList({ accountId: "acc-r" });
105
+ expect(listed).toMatch(/EPC API key registered/);
106
+ expect(listed).not.toContain("good-key-12345");
107
+ expect(listed).not.toContain("tester@example.com");
108
+
109
+ await keyDeregister({ accountId: "acc-r" });
110
+ const afterDelete = await keyList({ accountId: "acc-r" });
111
+ expect(afterDelete).toMatch(/No EPC API key/);
112
+ });
113
+ });
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Per-tool coverage for the two lookup adapters. Asserts:
3
+ * - happy path: URL carries the expected query params, response surfaces
4
+ * a single grouped match per UPRN with m² + sqft
5
+ * - 4xx upstream (non-404) → EpcError with status preserved
6
+ * - 404 upstream → ok response with empty matches
7
+ * - non-JSON body → BadResponseError
8
+ * - missing-key → KeyNotRegisteredError
9
+ */
10
+
11
+ import { afterEach, describe, expect, it, vi } from "vitest";
12
+
13
+ vi.mock("../lib/neo4j.js", () => {
14
+ const stored = new Map<string, string>();
15
+ return {
16
+ getSession: () => ({
17
+ run: vi.fn(async (cypher: string, params: Record<string, unknown>) => {
18
+ const accountId = String(params.accountId ?? "");
19
+ if (/MATCH \(k:EpcApiKey/.test(cypher) && /RETURN/.test(cypher)) {
20
+ const encryptedKey = stored.get(accountId);
21
+ return {
22
+ records: encryptedKey
23
+ ? [
24
+ {
25
+ get: (k: string) => {
26
+ if (k === "encryptedKey") return encryptedKey;
27
+ if (k === "createdAt") return "2026-01-01T00:00:00Z";
28
+ if (k === "updatedAt") return "2026-01-01T00:00:00Z";
29
+ return null;
30
+ },
31
+ },
32
+ ]
33
+ : [],
34
+ };
35
+ }
36
+ if (/MERGE \(k:EpcApiKey/.test(cypher)) {
37
+ stored.set(accountId, String(params.encryptedKey));
38
+ return { records: [] };
39
+ }
40
+ if (/DELETE k/.test(cypher)) {
41
+ const had = stored.delete(accountId);
42
+ return {
43
+ records: [
44
+ {
45
+ get: (k: string) =>
46
+ k === "deleted" ? { toNumber: () => (had ? 1 : 0) } : null,
47
+ },
48
+ ],
49
+ };
50
+ }
51
+ return { records: [] };
52
+ }),
53
+ close: vi.fn(async () => {}),
54
+ }),
55
+ closeDriver: vi.fn(async () => {}),
56
+ };
57
+ });
58
+
59
+ vi.mock("../lib/crypto.js", () => ({
60
+ encrypt: (s: string) => `enc:${s}`,
61
+ decrypt: (s: string) => s.replace(/^enc:/, ""),
62
+ }));
63
+
64
+ import { epcLookupByAddress, epcLookupByUprn } from "../tools/lookup.js";
65
+ import { KeyNotRegisteredError, EpcError, BadResponseError } from "../lib/epc-api.js";
66
+ import { keyRegister } from "../tools/key-register.js";
67
+
68
+ const originalFetch = globalThis.fetch;
69
+
70
+ function mockFetch(impl: (url: string) => Response | Promise<Response>) {
71
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => impl(String(url))) as unknown as typeof fetch;
72
+ }
73
+
74
+ afterEach(() => {
75
+ globalThis.fetch = originalFetch;
76
+ vi.restoreAllMocks();
77
+ });
78
+
79
+ async function registerKey(accountId: string) {
80
+ mockFetch(() => new Response(JSON.stringify({ rows: [] }), { status: 200 }));
81
+ await keyRegister({ apiKey: "tester@example.com:abc12345", accountId });
82
+ }
83
+
84
+ interface Case {
85
+ label: string;
86
+ call: (accountId: string) => Promise<string>;
87
+ expectedQuery: string[];
88
+ happyRow: Record<string, unknown>;
89
+ }
90
+
91
+ const cases: Case[] = [
92
+ {
93
+ label: "epc-lookup-by-address",
94
+ call: (accountId) =>
95
+ epcLookupByAddress({ accountId, address: "47 Phoenix Drive", postcode: "CM23 5SR" }),
96
+ expectedQuery: ["address=47", "postcode=CM23"],
97
+ happyRow: {
98
+ uprn: "100090940384",
99
+ "lodgement-date": "2023-05-15",
100
+ "total-floor-area": "75.0",
101
+ address1: "47 PHOENIX DRIVE",
102
+ posttown: "BISHOP'S STORTFORD",
103
+ postcode: "CM23 5SR",
104
+ "current-energy-rating": "C",
105
+ "potential-energy-rating": "B",
106
+ },
107
+ },
108
+ {
109
+ label: "epc-lookup-by-uprn",
110
+ call: (accountId) => epcLookupByUprn({ accountId, uprn: "100090940384" }),
111
+ expectedQuery: ["uprn=100090940384"],
112
+ happyRow: {
113
+ uprn: "100090940384",
114
+ "lodgement-date": "2023-05-15",
115
+ "total-floor-area": "75.0",
116
+ address1: "47 PHOENIX DRIVE",
117
+ postcode: "CM23 5SR",
118
+ "current-energy-rating": "C",
119
+ "potential-energy-rating": "B",
120
+ },
121
+ },
122
+ ];
123
+
124
+ describe("epc lookup adapters", () => {
125
+ for (const c of cases) {
126
+ describe(c.label, () => {
127
+ const acct = `acct-${c.label}`;
128
+
129
+ it("happy path returns one ok-shaped match with m² and sqft", async () => {
130
+ await registerKey(acct);
131
+ let captured = "";
132
+ mockFetch((url) => {
133
+ captured = url;
134
+ return new Response(JSON.stringify({ rows: [c.happyRow] }), { status: 200 });
135
+ });
136
+ const text = await c.call(acct);
137
+ for (const q of c.expectedQuery) {
138
+ expect(captured).toContain(q);
139
+ }
140
+ const parsed = JSON.parse(text);
141
+ expect(parsed.status).toBe("ok");
142
+ expect(parsed.matches).toHaveLength(1);
143
+ expect(parsed.matches[0].total_floor_area_m2).toBe(75.0);
144
+ expect(parsed.matches[0].total_floor_area_sqft).toBeGreaterThan(800);
145
+ expect(parsed.matches[0].current_energy_rating).toBe("C");
146
+ expect(parsed.matches[0].uprn).toBe("100090940384");
147
+ });
148
+
149
+ it("404 surfaces as ok with empty matches", async () => {
150
+ await registerKey(acct);
151
+ mockFetch(() => new Response("not found", { status: 404 }));
152
+ const text = await c.call(acct);
153
+ const parsed = JSON.parse(text);
154
+ expect(parsed.status).toBe("ok");
155
+ expect(parsed.matches).toEqual([]);
156
+ });
157
+
158
+ it("maps HTTP 429 to EpcError preserving status", async () => {
159
+ await registerKey(acct);
160
+ mockFetch(() => new Response("rate limit", { status: 429 }));
161
+ try {
162
+ await c.call(acct);
163
+ throw new Error("expected EpcError");
164
+ } catch (err) {
165
+ expect(err).toBeInstanceOf(EpcError);
166
+ expect((err as EpcError).status).toBe(429);
167
+ }
168
+ });
169
+
170
+ it("maps non-JSON body to BadResponseError", async () => {
171
+ await registerKey(acct);
172
+ mockFetch(() => new Response("<html/>", { status: 200 }));
173
+ await expect(c.call(acct)).rejects.toBeInstanceOf(BadResponseError);
174
+ });
175
+
176
+ it("throws KeyNotRegisteredError when no key is stored", async () => {
177
+ await expect(c.call(`${acct}-ghost`)).rejects.toBeInstanceOf(KeyNotRegisteredError);
178
+ });
179
+ });
180
+ }
181
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Schema parity: every tool declared in PLUGIN.md frontmatter must have a
3
+ * handler registered in index.ts, and every handler in index.ts must be
4
+ * declared in PLUGIN.md. Blocks silent drift between the manifest the
5
+ * platform reads and the tool surface the server exposes.
6
+ */
7
+
8
+ import { describe, expect, it } from "vitest";
9
+ import { readFileSync } from "node:fs";
10
+ import { resolve, dirname } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ const here = dirname(fileURLToPath(import.meta.url));
14
+ const PLUGIN_ROOT = resolve(here, "../../..");
15
+
16
+ function readPluginMdTools(): string[] {
17
+ const md = readFileSync(resolve(PLUGIN_ROOT, "PLUGIN.md"), "utf-8");
18
+ const fmMatch = md.match(/^---\n([\s\S]*?)\n---/);
19
+ if (!fmMatch) throw new Error("PLUGIN.md frontmatter not found");
20
+ const tools: string[] = [];
21
+ for (const line of fmMatch[1].split("\n")) {
22
+ const m = line.match(/^\s+-\s+name:\s+(\S+)/);
23
+ if (m) tools.push(m[1]);
24
+ }
25
+ return tools;
26
+ }
27
+
28
+ function readIndexTools(): string[] {
29
+ const src = readFileSync(resolve(PLUGIN_ROOT, "mcp/src/index.ts"), "utf-8");
30
+ const matches = src.matchAll(/server\.tool\(\s*"([^"]+)"/g);
31
+ return Array.from(matches, (m) => m[1]);
32
+ }
33
+
34
+ describe("schema parity", () => {
35
+ it("PLUGIN.md frontmatter and index.ts register the same tool set", () => {
36
+ const declared = readPluginMdTools().sort();
37
+ const registered = readIndexTools().sort();
38
+ expect(registered).toEqual(declared);
39
+ });
40
+
41
+ it("declares all 5 tools (3 key-mgmt + 2 lookup)", () => {
42
+ const declared = readPluginMdTools();
43
+ expect(declared).toHaveLength(5);
44
+ for (const name of [
45
+ "epc-key-register",
46
+ "epc-key-deregister",
47
+ "epc-key-list",
48
+ "epc-lookup-by-address",
49
+ "epc-lookup-by-uprn",
50
+ ]) {
51
+ expect(declared).toContain(name);
52
+ }
53
+ });
54
+ });