@adeu/mcp-server 1.15.1 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +156 -57
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +293 -180
- package/src/mcp.bugs.test.ts +23 -0
- package/src/mcp.schema-gaps.test.ts +384 -0
- package/src/response-builders.ts +118 -50
- package/src/response_builders.test.ts +266 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// FILE: packages/mcp-server/src/response_builders.test.ts
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import { build_search_response } from "./response-builders.js";
|
|
4
|
+
|
|
5
|
+
describe("build_search_response — page-as-document-filter semantics", () => {
|
|
6
|
+
// Small body: 3 matches all in one paragraph → all on document page 1.
|
|
7
|
+
function makeSmallBody(count: number): string {
|
|
8
|
+
const lines: string[] = ["# Section One"];
|
|
9
|
+
for (let i = 0; i < count; i++) {
|
|
10
|
+
lines.push(`Paragraph ${i} mentions the TARGET_PHRASE here.`);
|
|
11
|
+
}
|
|
12
|
+
return lines.join("\n\n");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Large body designed to span multiple document pages (PAGE_TARGET_CHARS=19000).
|
|
16
|
+
// Each filler paragraph is ~500 chars, so ~40 paragraphs per document page.
|
|
17
|
+
// We seed TARGET_PHRASE at known intervals so we can predict page distribution.
|
|
18
|
+
function makeMultiPageBody(): string {
|
|
19
|
+
const filler =
|
|
20
|
+
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.";
|
|
21
|
+
const blocks: string[] = [];
|
|
22
|
+
// ~50 paragraphs of filler before each marker, with TARGET_PHRASE injected at known places.
|
|
23
|
+
for (let i = 0; i < 200; i++) {
|
|
24
|
+
if (i === 10 || i === 11 || i === 12) {
|
|
25
|
+
blocks.push(`Paragraph ${i}: TARGET_PHRASE appears here.`);
|
|
26
|
+
} else if (i === 80) {
|
|
27
|
+
blocks.push(`Paragraph ${i}: TARGET_PHRASE appears here.`);
|
|
28
|
+
} else if (i === 150) {
|
|
29
|
+
blocks.push(`Paragraph ${i}: TARGET_PHRASE appears here.`);
|
|
30
|
+
} else {
|
|
31
|
+
blocks.push(`Paragraph ${i}: ${filler}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return blocks.join("\n\n");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
it("page omitted with single-page doc: searches all, shows all matches", () => {
|
|
38
|
+
const body = makeSmallBody(3);
|
|
39
|
+
const res = build_search_response(
|
|
40
|
+
body,
|
|
41
|
+
"TARGET_PHRASE",
|
|
42
|
+
false,
|
|
43
|
+
true,
|
|
44
|
+
undefined,
|
|
45
|
+
"dummy.docx",
|
|
46
|
+
);
|
|
47
|
+
const text = res.content[0].text;
|
|
48
|
+
|
|
49
|
+
expect(text).toContain("Found 3 matches");
|
|
50
|
+
expect(text).toContain("### Match 1 (p1)");
|
|
51
|
+
expect(text).toContain("### Match 2 (p1)");
|
|
52
|
+
expect(text).toContain("### Match 3 (p1)");
|
|
53
|
+
// No clamp warning, no result-pagination hint.
|
|
54
|
+
expect(text).not.toContain("exceeds available result pages");
|
|
55
|
+
expect(text).not.toContain("Showing page");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("page='all' explicit: same behavior as omitting", () => {
|
|
59
|
+
const body = makeSmallBody(3);
|
|
60
|
+
const res = build_search_response(
|
|
61
|
+
body,
|
|
62
|
+
"TARGET_PHRASE",
|
|
63
|
+
false,
|
|
64
|
+
true,
|
|
65
|
+
"all",
|
|
66
|
+
"dummy.docx",
|
|
67
|
+
);
|
|
68
|
+
expect(res.content[0].text).toContain("Found 3 matches");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("page omitted with multi-page doc: shows distribution summary", () => {
|
|
72
|
+
const body = makeMultiPageBody();
|
|
73
|
+
const res = build_search_response(
|
|
74
|
+
body,
|
|
75
|
+
"TARGET_PHRASE",
|
|
76
|
+
false,
|
|
77
|
+
true,
|
|
78
|
+
undefined,
|
|
79
|
+
"dummy.docx",
|
|
80
|
+
);
|
|
81
|
+
const text = res.content[0].text;
|
|
82
|
+
|
|
83
|
+
expect(text).toContain("Found 5 matches");
|
|
84
|
+
expect(text).toContain("Distribution across");
|
|
85
|
+
expect(text).toMatch(/p\d+: \d+/);
|
|
86
|
+
expect(text).toContain("Pass `page=N` to filter");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("page=N as document-page filter: only returns matches on that page", () => {
|
|
90
|
+
const body = makeMultiPageBody();
|
|
91
|
+
|
|
92
|
+
// First find out which doc pages actually have hits by doing an "all" search.
|
|
93
|
+
const allRes = build_search_response(
|
|
94
|
+
body,
|
|
95
|
+
"TARGET_PHRASE",
|
|
96
|
+
false,
|
|
97
|
+
true,
|
|
98
|
+
"all",
|
|
99
|
+
"dummy.docx",
|
|
100
|
+
);
|
|
101
|
+
const allText = allRes.content[0].text;
|
|
102
|
+
// Extract the first hit page from the Match annotations.
|
|
103
|
+
const firstMatchPageMatch = allText.match(/### Match 1 \(p(\d+)\)/);
|
|
104
|
+
expect(firstMatchPageMatch).not.toBeNull();
|
|
105
|
+
const firstHitPage = parseInt(firstMatchPageMatch![1], 10);
|
|
106
|
+
|
|
107
|
+
// Now filter to that page.
|
|
108
|
+
const res = build_search_response(
|
|
109
|
+
body,
|
|
110
|
+
"TARGET_PHRASE",
|
|
111
|
+
false,
|
|
112
|
+
true,
|
|
113
|
+
firstHitPage,
|
|
114
|
+
"dummy.docx",
|
|
115
|
+
);
|
|
116
|
+
const text = res.content[0].text;
|
|
117
|
+
|
|
118
|
+
expect(text).toContain(`on document page ${firstHitPage}`);
|
|
119
|
+
// All shown matches must be on the filtered page.
|
|
120
|
+
const matchPageRegex = /### Match \d+ \(p(\d+)\)/g;
|
|
121
|
+
const matches = Array.from(text.matchAll(matchPageRegex));
|
|
122
|
+
expect(matches.length).toBeGreaterThan(0);
|
|
123
|
+
for (const m of matches) {
|
|
124
|
+
expect(parseInt(m[1], 10)).toBe(firstHitPage);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("page=N where N has no hits but query exists elsewhere: helpful message", () => {
|
|
129
|
+
const body = makeMultiPageBody();
|
|
130
|
+
// Find a page that has no hits. Multi-page body has hits on a few pages
|
|
131
|
+
// but several pages in between have none. Page 2 should be safe if hits
|
|
132
|
+
// are on early pages or much later pages.
|
|
133
|
+
// First, identify a hit-less page by checking the "all" output.
|
|
134
|
+
const allRes = build_search_response(
|
|
135
|
+
body,
|
|
136
|
+
"TARGET_PHRASE",
|
|
137
|
+
false,
|
|
138
|
+
true,
|
|
139
|
+
"all",
|
|
140
|
+
"dummy.docx",
|
|
141
|
+
);
|
|
142
|
+
const allText = allRes.content[0].text;
|
|
143
|
+
const hitPages = new Set<number>();
|
|
144
|
+
const matchPageRegex = /### Match \d+ \(p(\d+)\)/g;
|
|
145
|
+
for (const m of allText.matchAll(matchPageRegex)) {
|
|
146
|
+
hitPages.add(parseInt(m[1], 10));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Find the total page count from the distribution line.
|
|
150
|
+
const distMatch = allText.match(/Distribution across (\d+) document pages/);
|
|
151
|
+
expect(distMatch).not.toBeNull();
|
|
152
|
+
const totalPages = parseInt(distMatch![1], 10);
|
|
153
|
+
|
|
154
|
+
// Find a page with no hits.
|
|
155
|
+
let emptyPage: number | null = null;
|
|
156
|
+
for (let p = 1; p <= totalPages; p++) {
|
|
157
|
+
if (!hitPages.has(p)) {
|
|
158
|
+
emptyPage = p;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
expect(emptyPage).not.toBeNull();
|
|
163
|
+
|
|
164
|
+
const res = build_search_response(
|
|
165
|
+
body,
|
|
166
|
+
"TARGET_PHRASE",
|
|
167
|
+
false,
|
|
168
|
+
true,
|
|
169
|
+
emptyPage!,
|
|
170
|
+
"dummy.docx",
|
|
171
|
+
);
|
|
172
|
+
const text = res.content[0].text;
|
|
173
|
+
|
|
174
|
+
expect(text).toContain(
|
|
175
|
+
`No matches for \`TARGET_PHRASE\` on document page ${emptyPage}`,
|
|
176
|
+
);
|
|
177
|
+
expect(text).toContain("The query DOES appear elsewhere");
|
|
178
|
+
expect(text).toContain("Omit `page` or pass `page='all'`");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("page=N where N exceeds total document pages: hard error", () => {
|
|
182
|
+
const body = makeSmallBody(3); // Single-page document.
|
|
183
|
+
expect(() =>
|
|
184
|
+
build_search_response(
|
|
185
|
+
body,
|
|
186
|
+
"TARGET_PHRASE",
|
|
187
|
+
false,
|
|
188
|
+
true,
|
|
189
|
+
99,
|
|
190
|
+
"dummy.docx",
|
|
191
|
+
),
|
|
192
|
+
).toThrow(/Document page 99 is out of range/);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("invalid page values throw with actionable message", () => {
|
|
196
|
+
const body = makeSmallBody(3);
|
|
197
|
+
expect(() =>
|
|
198
|
+
build_search_response(
|
|
199
|
+
body,
|
|
200
|
+
"TARGET_PHRASE",
|
|
201
|
+
false,
|
|
202
|
+
true,
|
|
203
|
+
0,
|
|
204
|
+
"dummy.docx",
|
|
205
|
+
),
|
|
206
|
+
).toThrow(/Invalid page value/);
|
|
207
|
+
expect(() =>
|
|
208
|
+
build_search_response(
|
|
209
|
+
body,
|
|
210
|
+
"TARGET_PHRASE",
|
|
211
|
+
false,
|
|
212
|
+
true,
|
|
213
|
+
"garbage",
|
|
214
|
+
"dummy.docx",
|
|
215
|
+
),
|
|
216
|
+
).toThrow(/Invalid page value/);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("no matches anywhere: standard empty message", () => {
|
|
220
|
+
const body = makeSmallBody(3);
|
|
221
|
+
const res = build_search_response(
|
|
222
|
+
body,
|
|
223
|
+
"NONEXISTENT_TOKEN",
|
|
224
|
+
false,
|
|
225
|
+
true,
|
|
226
|
+
undefined,
|
|
227
|
+
"dummy.docx",
|
|
228
|
+
);
|
|
229
|
+
expect(res.content[0].text).toContain("No matches found");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("occurrence counts are global, not filtered", () => {
|
|
233
|
+
const body = makeMultiPageBody();
|
|
234
|
+
// Filter to one specific page and verify the occurrence count reflects the full document.
|
|
235
|
+
const allRes = build_search_response(
|
|
236
|
+
body,
|
|
237
|
+
"TARGET_PHRASE",
|
|
238
|
+
false,
|
|
239
|
+
true,
|
|
240
|
+
"all",
|
|
241
|
+
"dummy.docx",
|
|
242
|
+
);
|
|
243
|
+
const totalMatches = (allRes.content[0].text.match(/### Match /g) || [])
|
|
244
|
+
.length;
|
|
245
|
+
expect(totalMatches).toBe(5);
|
|
246
|
+
|
|
247
|
+
// Now filter to the first hit page.
|
|
248
|
+
const firstPageMatch = allRes.content[0].text.match(
|
|
249
|
+
/### Match 1 \(p(\d+)\)/,
|
|
250
|
+
);
|
|
251
|
+
const firstHitPage = parseInt(firstPageMatch![1], 10);
|
|
252
|
+
|
|
253
|
+
const filteredRes = build_search_response(
|
|
254
|
+
body,
|
|
255
|
+
"TARGET_PHRASE",
|
|
256
|
+
false,
|
|
257
|
+
true,
|
|
258
|
+
firstHitPage,
|
|
259
|
+
"dummy.docx",
|
|
260
|
+
);
|
|
261
|
+
// The "Occurrences" line should still report the global count of 5.
|
|
262
|
+
expect(filteredRes.content[0].text).toContain(
|
|
263
|
+
"This exact phrasing appears 5 times in the document",
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
});
|