@digital-science-dsl/dimensions-analytics-mcp 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/http-main.js +2 -0
  3. package/dist/http-main.js.map +1 -1
  4. package/dist/main.d.ts +0 -5
  5. package/dist/main.d.ts.map +1 -1
  6. package/dist/main.js +3 -1
  7. package/dist/main.js.map +1 -1
  8. package/dist/mcp/auto-update.d.ts +63 -0
  9. package/dist/mcp/auto-update.d.ts.map +1 -0
  10. package/dist/mcp/auto-update.js +295 -0
  11. package/dist/mcp/auto-update.js.map +1 -0
  12. package/dist/mcp/examples/dsl-examples.d.ts +2 -2
  13. package/dist/mcp/examples/dsl-examples.d.ts.map +1 -1
  14. package/dist/mcp/examples/dsl-examples.js +5 -1
  15. package/dist/mcp/examples/dsl-examples.js.map +1 -1
  16. package/dist/mcp/examples/usage-scenarios.d.ts.map +1 -1
  17. package/dist/mcp/examples/usage-scenarios.js +38 -0
  18. package/dist/mcp/examples/usage-scenarios.js.map +1 -1
  19. package/dist/mcp/http-server.js +3 -3
  20. package/dist/mcp/http-server.js.map +1 -1
  21. package/dist/mcp/middleware/field-aliases.d.ts +1 -1
  22. package/dist/mcp/middleware/field-aliases.d.ts.map +1 -1
  23. package/dist/mcp/middleware/field-aliases.js +0 -6
  24. package/dist/mcp/middleware/field-aliases.js.map +1 -1
  25. package/dist/mcp/resources/schema.d.ts +1 -1
  26. package/dist/mcp/resources/schema.d.ts.map +1 -1
  27. package/dist/mcp/resources/schema.js +11 -11
  28. package/dist/mcp/resources/schema.js.map +1 -1
  29. package/dist/mcp/server.d.ts +2 -2
  30. package/dist/mcp/server.d.ts.map +1 -1
  31. package/dist/mcp/server.js +8 -4
  32. package/dist/mcp/server.js.map +1 -1
  33. package/dist/mcp/tools/analytics.d.ts +1 -1
  34. package/dist/mcp/tools/analytics.d.ts.map +1 -1
  35. package/dist/mcp/tools/analytics.js +21 -21
  36. package/dist/mcp/tools/analytics.js.map +1 -1
  37. package/dist/mcp/tools/fetch-search-pages.d.ts +1 -1
  38. package/dist/mcp/tools/fetch-search-pages.d.ts.map +1 -1
  39. package/dist/mcp/tools/fetch-search-pages.js +4 -9
  40. package/dist/mcp/tools/fetch-search-pages.js.map +1 -1
  41. package/dist/mcp/tools/functions.d.ts +1 -1
  42. package/dist/mcp/tools/functions.d.ts.map +1 -1
  43. package/dist/mcp/tools/functions.js +8 -13
  44. package/dist/mcp/tools/functions.js.map +1 -1
  45. package/dist/mcp/tools/lookup.d.ts +1 -1
  46. package/dist/mcp/tools/lookup.d.ts.map +1 -1
  47. package/dist/mcp/tools/lookup.js +12 -17
  48. package/dist/mcp/tools/lookup.js.map +1 -1
  49. package/dist/mcp/tools/query.d.ts +1 -1
  50. package/dist/mcp/tools/query.d.ts.map +1 -1
  51. package/dist/mcp/tools/query.js +4 -9
  52. package/dist/mcp/tools/query.js.map +1 -1
  53. package/dist/mcp/tools/schema.d.ts +1 -1
  54. package/dist/mcp/tools/schema.d.ts.map +1 -1
  55. package/dist/mcp/tools/schema.js +7 -11
  56. package/dist/mcp/tools/schema.js.map +1 -1
  57. package/dist/mcp/tools/search.d.ts +1 -1
  58. package/dist/mcp/tools/search.d.ts.map +1 -1
  59. package/dist/mcp/tools/search.js +4 -9
  60. package/dist/mcp/tools/search.js.map +1 -1
  61. package/dist/mcp/tools/similar-documents.d.ts +26 -0
  62. package/dist/mcp/tools/similar-documents.d.ts.map +1 -0
  63. package/dist/mcp/tools/similar-documents.js +166 -0
  64. package/dist/mcp/tools/similar-documents.js.map +1 -0
  65. package/dist/mcp/usage-tracking.d.ts +1 -1
  66. package/dist/mcp/usage-tracking.d.ts.map +1 -1
  67. package/dist/mcp/utils.d.ts +1 -2
  68. package/dist/mcp/utils.d.ts.map +1 -1
  69. package/dist/mcp/utils.js +0 -4
  70. package/dist/mcp/utils.js.map +1 -1
  71. package/package.json +6 -2
  72. package/src/http-main.ts +3 -0
  73. package/src/main.ts +4 -2
  74. package/src/mcp/auto-update.ts +379 -0
  75. package/src/mcp/examples/dsl-examples.ts +5 -1
  76. package/src/mcp/examples/usage-scenarios.ts +38 -0
  77. package/src/mcp/http-server.ts +3 -3
  78. package/src/mcp/middleware/field-aliases.ts +1 -2
  79. package/src/mcp/resources/schema.ts +12 -12
  80. package/src/mcp/server.ts +9 -5
  81. package/src/mcp/tools/analytics.ts +26 -26
  82. package/src/mcp/tools/fetch-search-pages.ts +5 -6
  83. package/src/mcp/tools/functions.ts +9 -10
  84. package/src/mcp/tools/lookup.ts +13 -14
  85. package/src/mcp/tools/query.ts +5 -6
  86. package/src/mcp/tools/schema.ts +8 -9
  87. package/src/mcp/tools/search.ts +5 -6
  88. package/src/mcp/tools/similar-documents.ts +231 -0
  89. package/src/mcp/usage-tracking.ts +1 -1
  90. package/src/mcp/utils.ts +1 -3
  91. package/test/examples/dsl-examples.test.ts +8 -0
  92. package/test/examples/usage-scenarios.test.ts +5 -3
  93. package/test/integration/harness.ts +1 -3
  94. package/test/integration/hosted-client.ts +1 -3
  95. package/test/integration/hosted.e2e.test.ts +1 -1
  96. package/test/integration/suites/index.ts +2 -0
  97. package/test/integration/suites/similar-documents.integration.ts +51 -0
  98. package/test/integration/verify-usage-scenarios.ts +1 -3
  99. package/test/mcp/auto-update.test.ts +347 -0
  100. package/test/middleware/field-aliases.test.ts +1 -1
  101. package/test/resources/schema.test.ts +5 -1
  102. package/test/server.test.ts +17 -3
  103. package/test/tools/similar-documents.test.ts +173 -0
  104. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,347 @@
1
+ /**
2
+ * @module test/mcp/auto-update
3
+ */
4
+
5
+ import { join } from "node:path";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import {
8
+ type AutoUpdateIo,
9
+ DEFAULT_UPDATE_INTERVAL_MS,
10
+ detectInstallLayout,
11
+ envFlagOff,
12
+ type InstallLayout,
13
+ NPM_PACKAGE,
14
+ PREFIX_DIR_NAME,
15
+ parseSemver,
16
+ readCurrentVersion,
17
+ resolveNpmBin,
18
+ resolvePackageRoot,
19
+ runUpdateCheck,
20
+ stampPathFor,
21
+ UPDATING_DOCS_URL,
22
+ type UpdateStamp,
23
+ upgradeGuidance,
24
+ upgradeRecipe,
25
+ versionRelation,
26
+ } from "../../src/mcp/auto-update.js";
27
+
28
+ const HOME = "/Users/test";
29
+ const PREFIX_ROOT = join(
30
+ HOME,
31
+ PREFIX_DIR_NAME,
32
+ "node_modules",
33
+ "@digital-science-dsl",
34
+ "dimensions-analytics-mcp",
35
+ );
36
+
37
+ function fakeIo(overrides: Partial<AutoUpdateIo> & { stamps?: Map<string, UpdateStamp> } = {}): {
38
+ io: AutoUpdateIo;
39
+ logs: string[];
40
+ stamps: Map<string, UpdateStamp>;
41
+ reexec: ReturnType<typeof vi.fn>;
42
+ npmInstall: ReturnType<typeof vi.fn>;
43
+ } {
44
+ const logs: string[] = [];
45
+ const stamps = overrides.stamps ?? new Map<string, UpdateStamp>();
46
+ const reexec = vi.fn();
47
+ const npmInstall = vi.fn<(version: string) => boolean>().mockReturnValue(true);
48
+ const stampPath = join(HOME, PREFIX_DIR_NAME, ".auto-update.json");
49
+
50
+ const io: AutoUpdateIo = {
51
+ now: () => 1_000_000,
52
+ env: {},
53
+ layout: "prefix",
54
+ currentVersion: "1.2.0",
55
+ allowInstall: true,
56
+ stampPath,
57
+ readStamp: (path) => stamps.get(path),
58
+ writeStamp: (path, stamp) => {
59
+ stamps.set(path, stamp);
60
+ },
61
+ npmView: () => "1.3.0",
62
+ npmInstall,
63
+ log: (message) => {
64
+ logs.push(message);
65
+ },
66
+ reexec,
67
+ ...overrides,
68
+ };
69
+
70
+ return { io, logs, stamps, reexec, npmInstall };
71
+ }
72
+
73
+ describe("envFlagOff", () => {
74
+ it("treats 0/false/off/no as off", () => {
75
+ expect(envFlagOff("0")).toBe(true);
76
+ expect(envFlagOff("false")).toBe(true);
77
+ expect(envFlagOff("OFF")).toBe(true);
78
+ expect(envFlagOff("no")).toBe(true);
79
+ expect(envFlagOff("1")).toBe(false);
80
+ expect(envFlagOff(undefined)).toBe(false);
81
+ });
82
+ });
83
+
84
+ describe("detectInstallLayout", () => {
85
+ it("detects the guided prefix install", () => {
86
+ expect(detectInstallLayout(PREFIX_ROOT, HOME)).toBe("prefix");
87
+ expect(detectInstallLayout(`${PREFIX_ROOT}/dist`, HOME)).toBe("prefix");
88
+ });
89
+
90
+ it("detects npx cache paths", () => {
91
+ expect(
92
+ detectInstallLayout(
93
+ "/Users/test/.npm/_npx/abc123/node_modules/@digital-science-dsl/dimensions-analytics-mcp",
94
+ HOME,
95
+ ),
96
+ ).toBe("npx");
97
+ });
98
+
99
+ it("detects global installs", () => {
100
+ expect(
101
+ detectInstallLayout(
102
+ "/usr/local/lib/node_modules/@digital-science-dsl/dimensions-analytics-mcp",
103
+ HOME,
104
+ ),
105
+ ).toBe("global");
106
+ });
107
+
108
+ it("detects source / workspace checkouts", () => {
109
+ expect(
110
+ detectInstallLayout("/Users/akovari/devel/dimensions-analytics-mcp/apps/mcp", HOME),
111
+ ).toBe("source");
112
+ });
113
+
114
+ it("normalizes Windows prefix paths", () => {
115
+ expect(
116
+ detectInstallLayout(
117
+ "C:\\Users\\test\\.dimensions-analytics-mcp\\node_modules\\@digital-science-dsl\\dimensions-analytics-mcp",
118
+ "C:\\Users\\test",
119
+ ),
120
+ ).toBe("prefix");
121
+ });
122
+ });
123
+
124
+ describe("versionRelation", () => {
125
+ it("classifies same, patch/minor, and major", () => {
126
+ expect(versionRelation("1.2.0", "1.2.0")).toBe("same");
127
+ expect(versionRelation("1.2.0", "1.2.1")).toBe("newer-same-major");
128
+ expect(versionRelation("1.2.0", "1.3.0")).toBe("newer-same-major");
129
+ expect(versionRelation("1.2.0", "2.0.0")).toBe("newer-major");
130
+ expect(versionRelation("1.3.0", "1.2.0")).toBe("older");
131
+ expect(versionRelation("nope", "1.0.0")).toBe("incomparable");
132
+ });
133
+ });
134
+
135
+ describe("parseSemver", () => {
136
+ it("reads leading v and ignores pre-release suffix for the numeric core", () => {
137
+ expect(parseSemver("v1.2.3")).toEqual({ major: 1, minor: 2, patch: 3 });
138
+ expect(parseSemver("1.2.3-beta.1")).toEqual({ major: 1, minor: 2, patch: 3 });
139
+ });
140
+ });
141
+
142
+ describe("upgradeGuidance", () => {
143
+ const layouts: InstallLayout[] = ["prefix", "npx", "global", "source", "hosted"];
144
+
145
+ it.each(layouts)("includes docs link and a recipe for %s", (layout) => {
146
+ const text = upgradeGuidance(layout, "1.0.3", "1.3.0");
147
+ expect(text).toContain("1.0.3");
148
+ expect(text).toContain("1.3.0");
149
+ expect(text).toContain(UPDATING_DOCS_URL);
150
+ expect(text).toContain(upgradeRecipe(layout).split("\n")[0]);
151
+ });
152
+
153
+ it("tells prefix users to re-run the installer or npm --prefix", () => {
154
+ const text = upgradeGuidance("prefix", "1.0.3", "1.3.0");
155
+ expect(text).toContain("install.sh");
156
+ expect(text).toContain("install.ps1");
157
+ expect(text).toContain(`npm install ${NPM_PACKAGE}@latest --prefix ~/${PREFIX_DIR_NAME}`);
158
+ });
159
+
160
+ it("tells npx users to pin @latest", () => {
161
+ expect(upgradeGuidance("npx", "1.0.3", "1.3.0")).toContain(`${NPM_PACKAGE}@latest`);
162
+ expect(upgradeGuidance("npx", "1.0.3", "1.3.0")).toContain("npx cache");
163
+ });
164
+
165
+ it("tells global users to npm install -g @latest", () => {
166
+ expect(upgradeGuidance("global", "1.0.3", "1.3.0")).toContain(
167
+ `npm install -g ${NPM_PACKAGE}@latest`,
168
+ );
169
+ });
170
+
171
+ it("tells source checkouts to pull and rebuild", () => {
172
+ expect(upgradeGuidance("source", "1.0.3", "1.3.0")).toContain(
173
+ "git pull && pnpm install && pnpm run build",
174
+ );
175
+ });
176
+
177
+ it("tells hosted operators to bump the pin, not run the stdio installer", () => {
178
+ const text = upgradeGuidance("hosted", "1.0.3", "1.3.0");
179
+ expect(text).toContain("deployed image");
180
+ expect(text).toContain("Do not run the stdio installer");
181
+ });
182
+
183
+ it("notes that majors are not auto-applied", () => {
184
+ expect(upgradeGuidance("prefix", "1.2.0", "2.0.0", { major: true })).toContain(
185
+ "auto-update will not apply it",
186
+ );
187
+ });
188
+ });
189
+
190
+ describe("stampPathFor", () => {
191
+ it("uses the prefix dir for stdio and tmpdir for hosted", () => {
192
+ expect(stampPathFor("prefix", HOME, "/tmp")).toBe(
193
+ join(HOME, PREFIX_DIR_NAME, ".auto-update.json"),
194
+ );
195
+ expect(stampPathFor("hosted", HOME, "/tmp")).toBe(
196
+ "/tmp/dimensions-analytics-mcp-auto-update.json",
197
+ );
198
+ });
199
+ });
200
+
201
+ describe("resolveNpmBin", () => {
202
+ it("prefers npm next to node", () => {
203
+ expect(resolveNpmBin("/usr/local/bin/node", "darwin", (p) => p === "/usr/local/bin/npm")).toBe(
204
+ "/usr/local/bin/npm",
205
+ );
206
+ });
207
+
208
+ it("falls back to npm.cmd on Windows", () => {
209
+ expect(resolveNpmBin("C:\\Program Files\\nodejs\\node.exe", "win32", () => false)).toBe(
210
+ "npm.cmd",
211
+ );
212
+ });
213
+ });
214
+
215
+ describe("resolvePackageRoot / readCurrentVersion", () => {
216
+ it("reads this workspace package version from import.meta.url", () => {
217
+ const root = resolvePackageRoot();
218
+ expect(readCurrentVersion(root)).toMatch(/^\d+\.\d+\.\d+/);
219
+ });
220
+ });
221
+
222
+ describe("runUpdateCheck", () => {
223
+ afterEach(() => {
224
+ vi.restoreAllMocks();
225
+ });
226
+
227
+ it("skips when DIMENSIONS_MCP_UPDATED=1", () => {
228
+ const { io, logs, stamps } = fakeIo({ env: { DIMENSIONS_MCP_UPDATED: "1" } });
229
+ expect(runUpdateCheck(io)).toBe("skipped");
230
+ expect(logs).toEqual([]);
231
+ expect(stamps.size).toBe(0);
232
+ });
233
+
234
+ it("skips when DIMENSIONS_MCP_UPDATE_CHECK is off", () => {
235
+ const { io, logs } = fakeIo({ env: { DIMENSIONS_MCP_UPDATE_CHECK: "0" } });
236
+ expect(runUpdateCheck(io)).toBe("skipped");
237
+ expect(logs).toEqual([]);
238
+ });
239
+
240
+ it("skips when the stamp is still fresh", () => {
241
+ const stampPath = join(HOME, PREFIX_DIR_NAME, ".auto-update.json");
242
+ const { io, logs } = fakeIo({
243
+ now: () => 50_000,
244
+ stamps: new Map([[stampPath, { checkedAt: 40_000 }]]),
245
+ env: { DIMENSIONS_MCP_AUTO_UPDATE_INTERVAL_MS: String(DEFAULT_UPDATE_INTERVAL_MS) },
246
+ });
247
+ expect(runUpdateCheck(io)).toBe("skipped");
248
+ expect(logs).toEqual([]);
249
+ });
250
+
251
+ it("does not warn when npm view fails", () => {
252
+ const { io, logs, stamps } = fakeIo({ npmView: () => undefined });
253
+ expect(runUpdateCheck(io)).toBe("current");
254
+ expect(logs).toEqual([]);
255
+ expect([...stamps.values()][0]?.skippedReason).toBe("view-failed");
256
+ });
257
+
258
+ it("does not warn when already on latest", () => {
259
+ const { io, logs, reexec } = fakeIo({ npmView: () => "1.2.0", currentVersion: "1.2.0" });
260
+ expect(runUpdateCheck(io)).toBe("current");
261
+ expect(logs).toEqual([]);
262
+ expect(reexec).not.toHaveBeenCalled();
263
+ });
264
+
265
+ it("installs and re-execs for prefix same-major updates", () => {
266
+ const { io, logs, reexec, npmInstall, stamps } = fakeIo();
267
+ expect(runUpdateCheck(io)).toBe("updated");
268
+ expect(npmInstall).toHaveBeenCalledWith("1.3.0");
269
+ expect(reexec).toHaveBeenCalledOnce();
270
+ expect(logs).toEqual([]);
271
+ expect([...stamps.values()][0]).toMatchObject({ from: "1.2.0", to: "1.3.0" });
272
+ });
273
+
274
+ it("writes the stamp before re-exec on success", () => {
275
+ const order: string[] = [];
276
+ const { io } = fakeIo({
277
+ writeStamp: () => {
278
+ order.push("stamp");
279
+ },
280
+ reexec: () => {
281
+ order.push("reexec");
282
+ },
283
+ });
284
+ runUpdateCheck(io);
285
+ expect(order).toEqual(["stamp", "reexec"]);
286
+ });
287
+
288
+ it("warns and does not install when auto-update is disabled", () => {
289
+ const { io, logs, reexec, npmInstall } = fakeIo({
290
+ env: { DIMENSIONS_MCP_AUTO_UPDATE: "0" },
291
+ });
292
+ expect(runUpdateCheck(io)).toBe("warned");
293
+ expect(npmInstall).not.toHaveBeenCalled();
294
+ expect(reexec).not.toHaveBeenCalled();
295
+ expect(logs[0]).toContain("install.sh");
296
+ });
297
+
298
+ it("warns on major versions and does not install", () => {
299
+ const { io, logs, npmInstall, reexec } = fakeIo({ npmView: () => "2.0.0" });
300
+ expect(runUpdateCheck(io)).toBe("warned");
301
+ expect(npmInstall).not.toHaveBeenCalled();
302
+ expect(reexec).not.toHaveBeenCalled();
303
+ expect(logs[0]).toContain("auto-update will not apply it");
304
+ });
305
+
306
+ it("warns with the npx recipe and never installs", () => {
307
+ const { io, logs, npmInstall, reexec } = fakeIo({ layout: "npx", allowInstall: true });
308
+ expect(runUpdateCheck(io)).toBe("warned");
309
+ expect(npmInstall).not.toHaveBeenCalled();
310
+ expect(reexec).not.toHaveBeenCalled();
311
+ expect(logs[0]).toContain("@latest");
312
+ expect(logs[0]).toContain("npx cache");
313
+ });
314
+
315
+ it("warns with the global recipe", () => {
316
+ const { io, logs, npmInstall } = fakeIo({ layout: "global" });
317
+ expect(runUpdateCheck(io)).toBe("warned");
318
+ expect(npmInstall).not.toHaveBeenCalled();
319
+ expect(logs[0]).toContain("npm install -g");
320
+ });
321
+
322
+ it("warns with the source recipe", () => {
323
+ const { io, logs, npmInstall } = fakeIo({ layout: "source" });
324
+ expect(runUpdateCheck(io)).toBe("warned");
325
+ expect(npmInstall).not.toHaveBeenCalled();
326
+ expect(logs[0]).toContain("git pull && pnpm install && pnpm run build");
327
+ });
328
+
329
+ it("hosted layout never installs or re-execs", () => {
330
+ const { io, logs, npmInstall, reexec } = fakeIo({
331
+ layout: "hosted",
332
+ allowInstall: false,
333
+ });
334
+ expect(runUpdateCheck(io)).toBe("warned");
335
+ expect(npmInstall).not.toHaveBeenCalled();
336
+ expect(reexec).not.toHaveBeenCalled();
337
+ expect(logs[0]).toContain("deployed image");
338
+ });
339
+
340
+ it("warns when prefix install fails", () => {
341
+ const { io, logs, reexec, npmInstall } = fakeIo();
342
+ npmInstall.mockReturnValue(false);
343
+ expect(runUpdateCheck(io)).toBe("warned");
344
+ expect(reexec).not.toHaveBeenCalled();
345
+ expect(logs[0]).toContain("Automatic update failed");
346
+ });
347
+ });
@@ -1,4 +1,4 @@
1
- import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
1
+ import type { CallToolResult } from "@modelcontextprotocol/server";
2
2
  import { describe, expect, it } from "vitest";
3
3
  import {
4
4
  buildReverseAliasMap,
@@ -12,7 +12,11 @@ describe("registerSchemaResources", () => {
12
12
  const context = { store: testSchemaStore() };
13
13
  const resources: Array<{ name: string; uri: string | { uriTemplate?: string } }> = [];
14
14
  const server = {
15
- resource: (name: string, uri: string | { uriTemplate?: string }, ..._rest: unknown[]) => {
15
+ registerResource: (
16
+ name: string,
17
+ uri: string | { uriTemplate?: string },
18
+ ..._rest: unknown[]
19
+ ) => {
16
20
  resources.push({ name, uri });
17
21
  },
18
22
  };
@@ -2,9 +2,7 @@
2
2
  * Tests for the MCP server factory function and startup lifecycle.
3
3
  * @module test/server
4
4
  */
5
-
6
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
7
- import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
5
+ import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
8
6
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9
7
  import {
10
8
  buildServerInstructions,
@@ -63,6 +61,7 @@ describe("createMcpServerAsync", () => {
63
61
  expect(instructions).toContain("search_organizations for institution lookup");
64
62
  expect(instructions).toContain("search_source_titles");
65
63
  expect(instructions).toContain("search_reports");
64
+ expect(instructions).toContain("similar_documents");
66
65
  expect(instructions).toContain("fetch_search_pages");
67
66
  expect(instructions).toContain("dimensions://schema/policy");
68
67
  });
@@ -137,9 +136,24 @@ describe("MCP protocol", () => {
137
136
  const toolNames = tools.tools.map((t) => t.name);
138
137
  expect(toolNames).toContain("search_publications");
139
138
  expect(toolNames).toContain("fetch_search_pages");
139
+ expect(toolNames).toContain("similar_documents");
140
140
  expect(toolNames).toContain("execute_dsl");
141
141
  expect(toolNames).not.toContain("validate_dsl");
142
142
 
143
+ const jsonSchema2020 = "https://json-schema.org/draft/2020-12/schema";
144
+ for (const tool of tools.tools) {
145
+ const inputSchema = tool.inputSchema as { $schema?: string } | undefined;
146
+ const outputSchema = tool.outputSchema as { $schema?: string } | undefined;
147
+ expect(inputSchema?.$schema ?? jsonSchema2020).not.toContain("draft-07");
148
+ expect(outputSchema?.$schema ?? jsonSchema2020).not.toContain("draft-07");
149
+ }
150
+ for (const name of ["search_publications", "execute_dsl", "facet_query"]) {
151
+ const tool = tools.tools.find((t) => t.name === name);
152
+ const outputSchema = tool?.outputSchema as { $schema?: string } | undefined;
153
+ expect(outputSchema, `${name} should advertise outputSchema`).toBeDefined();
154
+ expect(outputSchema?.$schema).toBe(jsonSchema2020);
155
+ }
156
+
143
157
  const resources = await client.listResources();
144
158
  const uris = resources.resources.map((r) => r.uri);
145
159
  expect(uris.some((u) => u.startsWith("dimensions://schema"))).toBe(true);
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Tests for the similar_documents MCP tool.
3
+ * @module test/tools/similar-documents
4
+ */
5
+
6
+ import { beforeEach, describe, expect, it } from "vitest";
7
+ import { registerSimilarDocumentsTool } from "../../src/mcp/tools/similar-documents.js";
8
+ import {
9
+ callTool,
10
+ createMockClient,
11
+ createMockServer,
12
+ parseToolResult,
13
+ } from "../helpers/tool-test-harness.js";
14
+
15
+ /** API-shaped mock response for parseEntityResponse. */
16
+ function apiRows(
17
+ entity: string,
18
+ rows: Record<string, unknown>[],
19
+ totalCount?: number,
20
+ ): Record<string, unknown> {
21
+ return {
22
+ [entity]: rows,
23
+ _stats: { total_count: totalCount ?? rows.length },
24
+ };
25
+ }
26
+
27
+ const SAMPLE_ABSTRACT =
28
+ "After spinal cord injury, macrophages infiltrate the lesion site and contribute to both tissue damage and repair.";
29
+
30
+ describe("similar_documents tool", () => {
31
+ let handlers: ReturnType<typeof createMockServer>["handlers"];
32
+ let client: ReturnType<typeof createMockClient>;
33
+
34
+ beforeEach(() => {
35
+ const { server, handlers: h } = createMockServer();
36
+ client = createMockClient();
37
+ handlers = h;
38
+ registerSimilarDocumentsTool(server as never, client as never);
39
+ });
40
+
41
+ it("registers similar_documents", () => {
42
+ expect(handlers.has("similar_documents")).toBe(true);
43
+ });
44
+
45
+ it("builds similar_documents DSL with default score sort and limit 20", async () => {
46
+ client.rawQuery.mockResolvedValue(apiRows("publications", [{ id: "pub.1", title: "A" }], 1));
47
+
48
+ await callTool(handlers, "similar_documents", {
49
+ entityType: "publications",
50
+ text: SAMPLE_ABSTRACT,
51
+ });
52
+
53
+ expect(client.rawQuery).toHaveBeenCalledTimes(1);
54
+ const dsl = client.rawQuery.mock.calls[0][0] as string;
55
+ expect(dsl).toContain("search publications for similar_documents(");
56
+ expect(dsl).toContain(SAMPLE_ABSTRACT);
57
+ expect(dsl).toContain("sort by score desc");
58
+ expect(dsl).toContain("limit 20");
59
+ });
60
+
61
+ it("applies year filters on publications and custom limit/fields", async () => {
62
+ client.rawQuery.mockResolvedValue(apiRows("publications", [], 0));
63
+
64
+ await callTool(handlers, "similar_documents", {
65
+ entityType: "publications",
66
+ text: SAMPLE_ABSTRACT,
67
+ yearFrom: 2018,
68
+ yearTo: 2024,
69
+ limit: 5,
70
+ fields: ["id", "title", "year"],
71
+ });
72
+
73
+ const dsl = client.rawQuery.mock.calls[0][0] as string;
74
+ expect(dsl).toContain("year >= 2018");
75
+ expect(dsl).toContain("year <= 2024");
76
+ expect(dsl).toContain("limit 5");
77
+ expect(dsl).toContain("return publications[id+title+year]");
78
+ });
79
+
80
+ it("uses start_year for grants year filters", async () => {
81
+ client.rawQuery.mockResolvedValue(apiRows("grants", [{ id: "grant.1" }], 1));
82
+
83
+ await callTool(handlers, "similar_documents", {
84
+ entityType: "grants",
85
+ text: "Cancer immunotherapy CAR-T solid tumors",
86
+ yearFrom: 2020,
87
+ limit: 5,
88
+ fields: ["id", "title"],
89
+ });
90
+
91
+ const dsl = client.rawQuery.mock.calls[0][0] as string;
92
+ expect(dsl).toContain("search grants for similar_documents(");
93
+ expect(dsl).toContain("start_year >= 2020");
94
+ expect(dsl).toContain("return grants[id+title]");
95
+ });
96
+
97
+ it("formats publication results", async () => {
98
+ const pubs = [
99
+ { id: "pub.1", title: "Paper A" },
100
+ { id: "pub.2", title: "Paper B" },
101
+ ];
102
+ client.rawQuery.mockResolvedValue(apiRows("publications", pubs, 2));
103
+
104
+ const result = await callTool(handlers, "similar_documents", {
105
+ entityType: "publications",
106
+ text: SAMPLE_ABSTRACT,
107
+ limit: 5,
108
+ });
109
+ const data = parseToolResult(result);
110
+
111
+ expect(result.isError).toBeUndefined();
112
+ expect(data.entityType).toBe("publications");
113
+ expect(data.totalCount).toBe(2);
114
+ expect(data.returnedCount).toBe(2);
115
+ expect(data.publications).toEqual(pubs);
116
+ });
117
+
118
+ it("formats grant results", async () => {
119
+ const grants = [{ id: "grant.1", title: "Grant A" }];
120
+ client.rawQuery.mockResolvedValue(apiRows("grants", grants, 1));
121
+
122
+ const result = await callTool(handlers, "similar_documents", {
123
+ entityType: "grants",
124
+ text: "Climate adaptation funding",
125
+ limit: 5,
126
+ });
127
+ const data = parseToolResult(result);
128
+
129
+ expect(data.entityType).toBe("grants");
130
+ expect(data.grants).toEqual(grants);
131
+ expect(data.publications).toBeUndefined();
132
+ });
133
+
134
+ it("applies extra filters", async () => {
135
+ client.rawQuery.mockResolvedValue(apiRows("publications", [], 0));
136
+
137
+ await callTool(handlers, "similar_documents", {
138
+ entityType: "publications",
139
+ text: SAMPLE_ABSTRACT,
140
+ filters: [{ field: "type", operator: "=", value: "article" }],
141
+ limit: 5,
142
+ });
143
+
144
+ const dsl = client.rawQuery.mock.calls[0][0] as string;
145
+ expect(dsl).toContain('type = "article"');
146
+ });
147
+
148
+ it("escapes quotes in text", async () => {
149
+ client.rawQuery.mockResolvedValue(apiRows("publications", [], 0));
150
+
151
+ await callTool(handlers, "similar_documents", {
152
+ entityType: "publications",
153
+ text: 'Text with "quotes" inside',
154
+ limit: 5,
155
+ });
156
+
157
+ const dsl = client.rawQuery.mock.calls[0][0] as string;
158
+ expect(dsl).toContain('similar_documents("Text with \\"quotes\\" inside")');
159
+ });
160
+
161
+ it("handles API errors gracefully", async () => {
162
+ client.rawQuery.mockRejectedValueOnce(new Error("Similarity search failed"));
163
+
164
+ const result = await callTool(handlers, "similar_documents", {
165
+ entityType: "publications",
166
+ text: SAMPLE_ABSTRACT,
167
+ });
168
+ const data = parseToolResult(result);
169
+
170
+ expect(result.isError).toBe(true);
171
+ expect(data.error).toBe("Similarity search failed");
172
+ });
173
+ });
@@ -1 +1 @@
1
- {"root":["./src/http-main.ts","./src/index.ts","./src/main.ts","./src/client/command.ts","./src/client/config.ts","./src/client/deployment-config.ts","./src/client/errors.ts","./src/client/http-client.ts","./src/client/index.ts","./src/client/internal-dsl-client.ts","./src/client/rate-limiter.ts","./src/client/request-retry.ts","./src/client/resolve-api-key-user.ts","./src/client/types.ts","./src/client/usage-context.ts","./src/client/usage-headers.ts","./src/client/auth/config-loaders.ts","./src/client/auth/factory.ts","./src/client/auth/index.ts","./src/client/auth/jwt-utils.ts","./src/client/auth/node.ts","./src/client/auth/schemas.ts","./src/client/auth/types.ts","./src/client/auth/decorators/index.ts","./src/client/auth/decorators/with-caching.ts","./src/client/auth/providers/index.ts","./src/client/auth/providers/jwt-auth-provider.ts","./src/client/config/index.ts","./src/client/config/loader.ts","./src/client/config/node.ts","./src/client/config/schemas.ts","./src/client/config/types.ts","./src/client/config/unified-loader.ts","./src/dsl/client.ts","./src/dsl/create-client.ts","./src/dsl/fluent-query-builder.ts","./src/dsl/index.ts","./src/dsl/pagination.ts","./src/dsl/query-builder.ts","./src/dsl/response-parser.ts","./src/dsl/usage-policy.ts","./src/dsl/commands/apply-filters.ts","./src/dsl/commands/index.ts","./src/dsl/commands/validate-input.ts","./src/dsl/commands/facet/index.ts","./src/dsl/commands/facet/schemas.ts","./src/dsl/commands/functions/ClassifyCommand.ts","./src/dsl/commands/functions/ExtractAffiliationsCommand.ts","./src/dsl/commands/functions/ExtractConceptsCommand.ts","./src/dsl/commands/functions/ExtractGrantsCommand.ts","./src/dsl/commands/functions/index.ts","./src/dsl/schema/cache.ts","./src/dsl/schema/extract.ts","./src/dsl/schema/index.ts","./src/dsl/schema/load.ts","./src/dsl/schema/store.ts","./src/dsl/schema/structured-entities.ts","./src/dsl/schema/summary.ts","./src/dsl/schema/types.ts","./src/dsl/schema/validation.ts","./src/dsl/types/buckets.ts","./src/dsl/types/common.ts","./src/dsl/types/entities.ts","./src/dsl/types/index.ts","./src/dsl/types/return-clauses.ts","./src/dsl/types/special-functions.ts","./src/dsl/types/vocabulary.ts","./src/dsl/utils/escape.ts","./src/dsl/utils/sanitize.ts","./src/dsl/utils/string-utils.ts","./src/dsl/utils/validate-field-name.ts","./src/mcp/batch-fetch.ts","./src/mcp/export-format.ts","./src/mcp/funder-org-names.ts","./src/mcp/http-server.ts","./src/mcp/server.ts","./src/mcp/shared-schema.ts","./src/mcp/usage-tracking.ts","./src/mcp/utils.ts","./src/mcp/examples/dsl-examples.ts","./src/mcp/examples/usage-scenarios.ts","./src/mcp/middleware/field-aliases.ts","./src/mcp/resources/schema.ts","./src/mcp/schema/context.ts","./src/mcp/schema/index.ts","./src/mcp/tools/analytics-filters.ts","./src/mcp/tools/analytics.ts","./src/mcp/tools/fetch-search-pages.ts","./src/mcp/tools/functions.ts","./src/mcp/tools/lookup.ts","./src/mcp/tools/query.ts","./src/mcp/tools/schema.ts","./src/mcp/tools/search-entity-metadata.ts","./src/mcp/tools/search-input.ts","./src/mcp/tools/search.ts"],"version":"5.9.3"}
1
+ {"root":["./src/http-main.ts","./src/index.ts","./src/main.ts","./src/client/command.ts","./src/client/config.ts","./src/client/deployment-config.ts","./src/client/errors.ts","./src/client/http-client.ts","./src/client/index.ts","./src/client/internal-dsl-client.ts","./src/client/rate-limiter.ts","./src/client/request-retry.ts","./src/client/resolve-api-key-user.ts","./src/client/types.ts","./src/client/usage-context.ts","./src/client/usage-headers.ts","./src/client/auth/config-loaders.ts","./src/client/auth/factory.ts","./src/client/auth/index.ts","./src/client/auth/jwt-utils.ts","./src/client/auth/node.ts","./src/client/auth/schemas.ts","./src/client/auth/types.ts","./src/client/auth/decorators/index.ts","./src/client/auth/decorators/with-caching.ts","./src/client/auth/providers/index.ts","./src/client/auth/providers/jwt-auth-provider.ts","./src/client/config/index.ts","./src/client/config/loader.ts","./src/client/config/node.ts","./src/client/config/schemas.ts","./src/client/config/types.ts","./src/client/config/unified-loader.ts","./src/dsl/client.ts","./src/dsl/create-client.ts","./src/dsl/fluent-query-builder.ts","./src/dsl/index.ts","./src/dsl/pagination.ts","./src/dsl/query-builder.ts","./src/dsl/response-parser.ts","./src/dsl/usage-policy.ts","./src/dsl/commands/apply-filters.ts","./src/dsl/commands/index.ts","./src/dsl/commands/validate-input.ts","./src/dsl/commands/facet/index.ts","./src/dsl/commands/facet/schemas.ts","./src/dsl/commands/functions/ClassifyCommand.ts","./src/dsl/commands/functions/ExtractAffiliationsCommand.ts","./src/dsl/commands/functions/ExtractConceptsCommand.ts","./src/dsl/commands/functions/ExtractGrantsCommand.ts","./src/dsl/commands/functions/index.ts","./src/dsl/schema/cache.ts","./src/dsl/schema/extract.ts","./src/dsl/schema/index.ts","./src/dsl/schema/load.ts","./src/dsl/schema/store.ts","./src/dsl/schema/structured-entities.ts","./src/dsl/schema/summary.ts","./src/dsl/schema/types.ts","./src/dsl/schema/validation.ts","./src/dsl/types/buckets.ts","./src/dsl/types/common.ts","./src/dsl/types/entities.ts","./src/dsl/types/index.ts","./src/dsl/types/return-clauses.ts","./src/dsl/types/special-functions.ts","./src/dsl/types/vocabulary.ts","./src/dsl/utils/escape.ts","./src/dsl/utils/sanitize.ts","./src/dsl/utils/string-utils.ts","./src/dsl/utils/validate-field-name.ts","./src/mcp/auto-update.ts","./src/mcp/batch-fetch.ts","./src/mcp/export-format.ts","./src/mcp/funder-org-names.ts","./src/mcp/http-server.ts","./src/mcp/server.ts","./src/mcp/shared-schema.ts","./src/mcp/usage-tracking.ts","./src/mcp/utils.ts","./src/mcp/examples/dsl-examples.ts","./src/mcp/examples/usage-scenarios.ts","./src/mcp/middleware/field-aliases.ts","./src/mcp/resources/schema.ts","./src/mcp/schema/context.ts","./src/mcp/schema/index.ts","./src/mcp/tools/analytics-filters.ts","./src/mcp/tools/analytics.ts","./src/mcp/tools/fetch-search-pages.ts","./src/mcp/tools/functions.ts","./src/mcp/tools/lookup.ts","./src/mcp/tools/query.ts","./src/mcp/tools/schema.ts","./src/mcp/tools/search-entity-metadata.ts","./src/mcp/tools/search-input.ts","./src/mcp/tools/search.ts","./src/mcp/tools/similar-documents.ts"],"version":"5.9.3"}