simple-dynamsoft-mcp 7.7.4 → 7.7.6

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 CHANGED
@@ -295,15 +295,15 @@ mcpServers:
295
295
 
296
296
  The server exposes this minimal tool surface:
297
297
 
298
- - `get_index` -- compact product/version/sample index with selection guidance
299
- - `search` -- semantic search across docs and samples (also accepts exact sample IDs); returns `doc://` and `sample://` links
298
+ - `get_index` -- compact product/version/sample index with selection guidance and known capability facts (OCR, VIN, ID documents)
299
+ - `search` -- semantic search across docs and samples (also accepts exact sample IDs); returns `doc://` and `sample://` links plus the matched passage (up to ~1500 chars) of the top 3 results (`content: "none"` returns links and short snippets only). Without `edition`/`platform`, MRZ and MDS search their web edition, and a DBR query that names exactly one edition (for example "web app" or "Android") searches that edition and says so
300
300
  - `read_document` -- read the full text (title, public docs URL, body) of a `doc://` URI returned by `search`
301
301
  - `list_samples` -- browse available samples for a product/edition/platform
302
302
  - `resolve_version` -- resolve current version for a product/edition/platform
303
303
  - `get_quickstart` -- opinionated quickstart: picks a sample by scenario, returns code + install instructions
304
304
  - `get_sample_files` -- get full project files for a known sample (discovered via list_samples or search)
305
305
 
306
- Typical doc Q&A flow: `search` -> `read_document` with the exact `doc://` URI -> answer from the returned text and cite its public URL. Clients that support MCP resources can also fetch the same `doc://` / `sample://` URIs with `resources/read`.
306
+ Typical doc Q&A flow: `search` -> answer from a matched passage when it covers the question, otherwise `read_document` with the exact `doc://` URI -> answer from the returned text and cite its public URL. Clients that support MCP resources can also fetch the same `doc://` / `sample://` URIs with `resources/read`.
307
307
 
308
308
  DBR searches also cover the shared Capture Vision API pages that the DBR docs link to (for example `CaptureVisionRouter.capture()` and `getSimplifiedSettings()`/`updateSettings()`); these hits keep their official `capture-vision/docs` URL and are labelled `shared Capture Vision API used by DBR` in search results. Doc URIs are path-based (`doc://dbr/web/web/<version>/user-guide/use-in-framework`) and change only when the docs move or the SDK version changes.
309
309
 
@@ -8,8 +8,8 @@
8
8
  "branch": "main",
9
9
  "owner": "dynamsoft-docs",
10
10
  "repo": "barcode-reader-docs-js",
11
- "commit": "8c5cd13b851d632d3ffe9bf4dca30289ac6b8ce5",
12
- "archiveUrl": "https://codeload.github.com/dynamsoft-docs/barcode-reader-docs-js/zip/8c5cd13b851d632d3ffe9bf4dca30289ac6b8ce5"
11
+ "commit": "d34116f3c7bdad9d3ab0ab8a19c2365076da4929",
12
+ "archiveUrl": "https://codeload.github.com/dynamsoft-docs/barcode-reader-docs-js/zip/d34116f3c7bdad9d3ab0ab8a19c2365076da4929"
13
13
  },
14
14
  {
15
15
  "name": "data/documentation/barcode-reader-docs-mobile",
@@ -38,8 +38,8 @@
38
38
  "branch": "main",
39
39
  "owner": "dynamsoft-docs",
40
40
  "repo": "capture-vision-docs",
41
- "commit": "428c0080975aee70f15e09deda09ca548a4f9a93",
42
- "archiveUrl": "https://codeload.github.com/dynamsoft-docs/capture-vision-docs/zip/428c0080975aee70f15e09deda09ca548a4f9a93"
41
+ "commit": "dbd15028a8a76cb0bc39fcb1f7c8e8b366b42496",
42
+ "archiveUrl": "https://codeload.github.com/dynamsoft-docs/capture-vision-docs/zip/dbd15028a8a76cb0bc39fcb1f7c8e8b366b42496"
43
43
  },
44
44
  {
45
45
  "name": "data/documentation/capture-vision-docs-js",
@@ -48,8 +48,8 @@
48
48
  "branch": "main",
49
49
  "owner": "dynamsoft-docs",
50
50
  "repo": "capture-vision-docs-js",
51
- "commit": "acbcef22e86ee252b6cf8cd97fd75c5f4c0e3f40",
52
- "archiveUrl": "https://codeload.github.com/dynamsoft-docs/capture-vision-docs-js/zip/acbcef22e86ee252b6cf8cd97fd75c5f4c0e3f40"
51
+ "commit": "f5d39c818fa6bdc28d7d9e1a6fdf567a8d52b892",
52
+ "archiveUrl": "https://codeload.github.com/dynamsoft-docs/capture-vision-docs-js/zip/f5d39c818fa6bdc28d7d9e1a6fdf567a8d52b892"
53
53
  },
54
54
  {
55
55
  "name": "data/documentation/capture-vision-docs-mobile",
@@ -138,8 +138,8 @@
138
138
  "branch": "main",
139
139
  "owner": "Dynamsoft",
140
140
  "repo": "barcode-reader-javascript-samples",
141
- "commit": "c1011477f745d7259331f4d9a1963623c2f0e64f",
142
- "archiveUrl": "https://codeload.github.com/Dynamsoft/barcode-reader-javascript-samples/zip/c1011477f745d7259331f4d9a1963623c2f0e64f"
141
+ "commit": "36244065391a6a32506546c9fe7b422823f8c58e",
142
+ "archiveUrl": "https://codeload.github.com/Dynamsoft/barcode-reader-javascript-samples/zip/36244065391a6a32506546c9fe7b422823f8c58e"
143
143
  },
144
144
  {
145
145
  "name": "data/samples/dynamsoft-barcode-reader-c-cpp",
@@ -228,7 +228,7 @@
228
228
  "dbr-web": {
229
229
  "name": "Dynamsoft Barcode Reader JavaScript SDK",
230
230
  "description": "Barcode scanning for web applications using JavaScript/TypeScript.",
231
- "version": "11.6.3200",
231
+ "version": "11.6.5000",
232
232
  "default_platform": "web",
233
233
  "snippet_path": "dynamsoft-barcode-reader/web",
234
234
  "platforms": {
@@ -347,7 +347,7 @@
347
347
  "dcv-web": {
348
348
  "name": "Dynamsoft Capture Vision JavaScript SDK",
349
349
  "description": "Capture Vision workflows for JavaScript web apps, including MRZ processing scenarios.",
350
- "version": "3.6.3200",
350
+ "version": "3.6.5000",
351
351
  "default_platform": "web",
352
352
  "snippet_path": "dynamsoft-capture-vision/web",
353
353
  "platforms": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "simple-dynamsoft-mcp",
3
- "version": "7.7.4",
3
+ "version": "7.7.6",
4
4
  "description": "MCP server for Dynamsoft SDKs - Capture Vision, Barcode Reader (Mobile/Python/Web), Dynamic Web TWAIN, and Document Viewer. Provides documentation, code snippets, and API guidance.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -24,7 +24,7 @@
24
24
  "scripts": {
25
25
  "start": "node src/index.js",
26
26
  "test": "npm run test:lite",
27
- "test:unit": "node --test test/unit/gemini-retry.test.js test/unit/profile-config.test.js test/unit/lexical-provider.test.js test/unit/hydration-mode.test.js test/unit/hydration-policy.test.js test/unit/repo-map.test.js test/unit/download-utils.test.js test/unit/logging.test.js test/unit/request-logging.test.js test/unit/request-log-file.test.js test/unit/rag-signature-manifest.test.js test/unit/startup-timing.test.js test/unit/http-startup-readiness.test.js test/unit/resource-index-startup.test.js test/unit/create-server.test.js test/unit/server-helpers.test.js test/unit/update-sdk-versions.test.js test/unit/version-policy.test.js test/unit/query-expansion.test.js test/unit/docs-loader-enrich.test.js test/unit/shared-docs.test.js test/unit/vector-cache.test.js test/unit/doc-slugs.test.js test/unit/incremental-embedding.test.js",
27
+ "test:unit": "node --test test/unit/gemini-retry.test.js test/unit/profile-config.test.js test/unit/lexical-provider.test.js test/unit/hydration-mode.test.js test/unit/hydration-policy.test.js test/unit/repo-map.test.js test/unit/download-utils.test.js test/unit/logging.test.js test/unit/request-logging.test.js test/unit/request-log-file.test.js test/unit/rag-signature-manifest.test.js test/unit/startup-timing.test.js test/unit/http-startup-readiness.test.js test/unit/resource-index-startup.test.js test/unit/create-server.test.js test/unit/server-helpers.test.js test/unit/update-sdk-versions.test.js test/unit/version-policy.test.js test/unit/query-expansion.test.js test/unit/docs-loader-enrich.test.js test/unit/shared-docs.test.js test/unit/vector-cache.test.js test/unit/doc-slugs.test.js test/unit/incremental-embedding.test.js test/unit/search-scope.test.js",
28
28
  "test:lite": "npm run test:stdio && npm run test:http && npm run test:package",
29
29
  "test:lexical": "node --test test/integration/stdio.test.js test/integration/http.test.js",
30
30
  "test:gemini": "node scripts/run-gemini-tests.mjs",
@@ -30,19 +30,43 @@ if (cases.length === 0) {
30
30
  process.exit(1);
31
31
  }
32
32
 
33
- const { ensureResourceIndexReady } = await import("../src/server/resource-index.js");
33
+ const { ensureResourceIndexReady, formatScopeLabel, getSampleIdFromUri } = await import("../src/server/resource-index.js");
34
34
  const { searchResources } = await import("../src/rag/index.js");
35
35
  const { runRetrievalEval } = await import("./lib/retrieval-eval.mjs");
36
36
  const { isResultInScope } = await import("./lib/scope-matcher.mjs");
37
+ const { buildSearchResultsResponse, createHitDescriber, runScopedSearch, searchResponseText } = await import("../src/server/helpers/search-response.js");
38
+
39
+ const describeHit = createHitDescriber({ formatScopeLabel, getSampleIdFromUri });
40
+ // The text of the search tool's own response for these results (eval cases
41
+ // pass already-normalized product filters).
42
+ const renderResponse = (testCase, results, contentMode) => searchResponseText(buildSearchResultsResponse({
43
+ query: testCase.query,
44
+ product: testCase.filters?.product || "",
45
+ results,
46
+ contentMode,
47
+ describeHit,
48
+ getSampleIdFromUri
49
+ }));
37
50
 
38
51
  ensureResourceIndexReady();
39
- const report = await runRetrievalEval({ searchResources, cases, k: golden.k || 5, isInScope: isResultInScope });
52
+ // Search in the scope the tool would use (edition defaults read from the query).
53
+ const scopedSearch = async (params) => (await runScopedSearch(searchResources, params)).results;
54
+ const report = await runRetrievalEval({
55
+ searchResources: scopedSearch,
56
+ cases,
57
+ k: golden.k || 5,
58
+ isInScope: isResultInScope,
59
+ renderResponse
60
+ });
40
61
  report.provider = provider;
41
62
 
42
63
  const fmt = (value) => (value === null || value === undefined ? "n/a" : value.toFixed(3));
43
64
  for (const item of report.cases) {
44
65
  const flag = item.gap ? "GAP " : item.requiredHit === false ? "MISS" : "ok ";
45
- console.log(`${flag} ${item.id.padEnd(34)} recall=${fmt(item.recall)} mrr=${fmt(item.mrr)} ndcg=${fmt(item.ndcg)} dup=${item.duplicates} off=${item.offScope} rn=${item.releaseNotes} ms=${item.latencyMs.toFixed(0)}`);
66
+ const answer = item.answerInPassages === null
67
+ ? ""
68
+ : ` answer: snippets=${item.answerInSnippets ? "y" : "n"} passages=${item.answerInPassages ? "y" : "n"}`;
69
+ console.log(`${flag} ${item.id.padEnd(34)} recall=${fmt(item.recall)} mrr=${fmt(item.mrr)} ndcg=${fmt(item.ndcg)} dup=${item.duplicates} off=${item.offScope} rn=${item.releaseNotes} ms=${item.latencyMs.toFixed(0)}${answer}`);
46
70
  if (args.show || item.requiredHit === false) {
47
71
  for (const hit of item.top) {
48
72
  console.log(` ${hit.rank}. [g${hit.grade}] [${hit.product}] ${hit.title} :: ${hit.url || hit.uri}`);
@@ -55,6 +79,7 @@ const s = report.summary;
55
79
  console.log(`\nprovider=${provider} cases=${s.cases} (gap=${s.gapCases}) k=${report.k}`);
56
80
  console.log(`recall@${report.k}=${fmt(s.recallAtK)} mrr=${fmt(s.mrr)} ndcg@${report.k}=${fmt(s.ndcgAtK)} required_hit=${fmt(s.requiredHitRate)}`);
57
81
  console.log(`duplicate_rate=${fmt(s.duplicateRate)} off_scope_rate=${fmt(s.offScopeRate)} release_notes_rate=${fmt(s.releaseNotesRate)}`);
82
+ console.log(`answer_in_text cases=${s.answerCases} snippets=${fmt(s.answerInSnippetsRate)} passages=${fmt(s.answerInPassagesRate)} | mean_chars snippets=${fmt(s.meanSnippetChars)} passages=${fmt(s.meanPassageChars)}`);
58
83
  const lat = s.latencyMs;
59
84
  console.log(`latency_ms first_query=${lat.firstQuery.toFixed(0)} first_pass p50=${lat.p50FirstPass.toFixed(0)} p95=${lat.p95FirstPass.toFixed(0)} | warm p50=${lat.p50Warm.toFixed(0)} p95=${lat.p95Warm.toFixed(0)}`);
60
85
 
@@ -25,9 +25,12 @@ function percentile(values, p) {
25
25
 
26
26
  // `isInScope(result, filters)` (when given) applies the server's own
27
27
  // product/edition/platform matching so cross-platform leakage is counted too.
28
+ // `expectedEdition` checks the edition the server should pick on its own
29
+ // (cases that pass no edition filter), independently of the filters.
28
30
  function isOffScope(result, testCase, isInScope) {
29
31
  const product = testCase.filters?.product;
30
32
  if (product && result.product !== product) return true;
33
+ if (testCase.expectedEdition && result.edition !== testCase.expectedEdition) return true;
31
34
  if (isInScope && !isInScope(result, testCase.filters || {})) return true;
32
35
  const prefixes = testCase.allowedUrlPrefixes;
33
36
  if (Array.isArray(prefixes) && prefixes.length > 0 && result.url) {
@@ -40,7 +43,30 @@ function isReleaseNotes(result) {
40
43
  return /\/release-notes?\//i.test(String(result.url || ""));
41
44
  }
42
45
 
43
- function scoreCase(testCase, results, k, isInScope) {
46
+ function normalizeForMatch(text) {
47
+ return String(text || "").toLowerCase().replace(/\s+/g, " ");
48
+ }
49
+
50
+ // What an agent reads from the search tool's actual response in each content
51
+ // mode. `renderResponse(testCase, results, contentMode)` returns that text
52
+ // (scripts/eval-retrieval.mjs builds it with the tool's own response builder).
53
+ function emittedText(testCase, results, k, renderResponse) {
54
+ if (!renderResponse) return { snippet: "", passage: "" };
55
+ const top = results.slice(0, k);
56
+ return {
57
+ snippet: renderResponse(testCase, top, "none"),
58
+ passage: renderResponse(testCase, top, "passage")
59
+ };
60
+ }
61
+
62
+ // answerTerms: groups of alternatives; the answer counts as present when
63
+ // every group has at least one alternative in the text (case-insensitive).
64
+ function answerPresent(text, answerTerms) {
65
+ const haystack = normalizeForMatch(text);
66
+ return answerTerms.every((group) => group.some((term) => haystack.includes(normalizeForMatch(term))));
67
+ }
68
+
69
+ function scoreCase(testCase, results, k, isInScope, renderResponse) {
44
70
  const relevant = new Map(
45
71
  Object.entries(testCase.relevant || {}).map(([url, grade]) => [normalizeUrl(url), Number(grade) || 0])
46
72
  );
@@ -57,6 +83,8 @@ function scoreCase(testCase, results, k, isInScope) {
57
83
 
58
84
  const urls = top.map((result) => normalizeUrl(result.url || result.uri));
59
85
  const duplicates = urls.length - new Set(urls).size;
86
+ const hasAnswer = Array.isArray(testCase.answerTerms) && testCase.answerTerms.length > 0;
87
+ const emitted = emittedText(testCase, results, k, renderResponse);
60
88
 
61
89
  return {
62
90
  id: testCase.id,
@@ -70,6 +98,10 @@ function scoreCase(testCase, results, k, isInScope) {
70
98
  offScope: top.filter((result) => isOffScope(result, testCase, isInScope)).length,
71
99
  releaseNotes: top.filter(isReleaseNotes).length,
72
100
  returned: top.length,
101
+ answerInSnippets: hasAnswer ? answerPresent(emitted.snippet, testCase.answerTerms) : null,
102
+ answerInPassages: hasAnswer ? answerPresent(emitted.passage, testCase.answerTerms) : null,
103
+ snippetChars: emitted.snippet.length,
104
+ passageChars: emitted.passage.length,
73
105
  top: top.map((result, index) => ({
74
106
  rank: index + 1,
75
107
  grade: gains[index],
@@ -90,6 +122,7 @@ function mean(values) {
90
122
  function summarize(caseScores, firstPass, warmPass) {
91
123
  const totalReturned = caseScores.reduce((sum, item) => sum + item.returned, 0) || 1;
92
124
  const required = caseScores.filter((item) => item.requiredHit !== null);
125
+ const answered = caseScores.filter((item) => item.answerInPassages !== null);
93
126
  return {
94
127
  cases: caseScores.length,
95
128
  gapCases: caseScores.filter((item) => item.gap).length,
@@ -100,6 +133,11 @@ function summarize(caseScores, firstPass, warmPass) {
100
133
  duplicateRate: caseScores.reduce((sum, item) => sum + item.duplicates, 0) / totalReturned,
101
134
  offScopeRate: caseScores.reduce((sum, item) => sum + item.offScope, 0) / totalReturned,
102
135
  releaseNotesRate: caseScores.reduce((sum, item) => sum + item.releaseNotes, 0) / totalReturned,
136
+ answerCases: answered.length,
137
+ answerInSnippetsRate: answered.length ? answered.filter((item) => item.answerInSnippets).length / answered.length : null,
138
+ answerInPassagesRate: answered.length ? answered.filter((item) => item.answerInPassages).length / answered.length : null,
139
+ meanSnippetChars: mean(caseScores.map((item) => item.snippetChars)),
140
+ meanPassageChars: mean(caseScores.map((item) => item.passageChars)),
103
141
  // First pass pays index loading and per-scope index builds; the warm pass
104
142
  // repeats the same queries, so it reflects steady-state search cost.
105
143
  latencyMs: {
@@ -112,7 +150,7 @@ function summarize(caseScores, firstPass, warmPass) {
112
150
  };
113
151
  }
114
152
 
115
- async function runRetrievalEval({ searchResources, cases, k = 5, isInScope }) {
153
+ async function runRetrievalEval({ searchResources, cases, k = 5, isInScope, renderResponse }) {
116
154
  const caseScores = [];
117
155
  const firstPass = [];
118
156
  const warmPass = [];
@@ -121,7 +159,7 @@ async function runRetrievalEval({ searchResources, cases, k = 5, isInScope }) {
121
159
  const results = await searchResources({ query: testCase.query, ...(testCase.filters || {}), limit: k, throwOnError: true });
122
160
  const latencyMs = performance.now() - startedAt;
123
161
  firstPass.push(latencyMs);
124
- caseScores.push({ ...scoreCase(testCase, results, k, isInScope), latencyMs });
162
+ caseScores.push({ ...scoreCase(testCase, results, k, isInScope, renderResponse), latencyMs });
125
163
  }
126
164
  for (const testCase of cases) {
127
165
  const startedAt = performance.now();
package/src/rag/index.js CHANGED
@@ -30,7 +30,7 @@ import {
30
30
  dotProduct,
31
31
  isRateLimitError,
32
32
  expandQueryTokens,
33
- extractSnippet
33
+ buildMatchFields
34
34
  } from "./search-utils.js";
35
35
  import { createProviderOrchestrator } from "./providers.js";
36
36
  import { createVectorCacheHelpers } from "./vector-cache.js";
@@ -46,7 +46,7 @@ const searchUtils = {
46
46
  dotProduct,
47
47
  isRateLimitError,
48
48
  expandQueryTokens,
49
- extractSnippet,
49
+ buildMatchFields,
50
50
  entryMatchesScope: (entry, filters) => entryMatchesScope(entry, filters, {
51
51
  editionMatches,
52
52
  platformMatches
@@ -1,5 +1,5 @@
1
1
  import Fuse from "fuse.js";
2
- import { chunkMarkdown, expandQueryTokens, extractSnippet } from "./search-utils.js";
2
+ import { buildMatchFields, chunkMarkdown, expandQueryTokens } from "./search-utils.js";
3
3
 
4
4
  const DEFAULT_FUSE_OPTIONS = {
5
5
  keys: [
@@ -150,7 +150,8 @@ function scoreDocument(index, doc, terms) {
150
150
  }
151
151
  return {
152
152
  score: computeBm25Score(index.headerStats, doc, terms) + bestScore,
153
- snippetText: best ? best.text : doc.fallbackSnippet
153
+ passageText: best ? best.text : "",
154
+ fallbackSnippet: doc.fallbackSnippet
154
155
  };
155
156
  }
156
157
 
@@ -204,11 +205,11 @@ function createLexicalProvider({
204
205
  // out-of-scope fuzzy match deflate every in-scope fuse contribution (issue #147).
205
206
  let maxFuse = 0;
206
207
 
207
- const snippetTextByUri = new Map();
208
+ const matchByUri = new Map();
208
209
  for (const doc of bm25Index.documents) {
209
210
  if (!entryMatchesScope(doc.entry, filters)) continue;
210
- const { score, snippetText } = scoreDocument(bm25Index, doc, terms);
211
- snippetTextByUri.set(doc.entry.uri, snippetText);
211
+ const { score, passageText, fallbackSnippet } = scoreDocument(bm25Index, doc, terms);
212
+ matchByUri.set(doc.entry.uri, { passageText, fallbackSnippet });
212
213
  if (score > 0) {
213
214
  bm25ScoreByUri.set(doc.entry.uri, score);
214
215
  if (score > maxBm25) maxBm25 = score;
@@ -237,9 +238,11 @@ function createLexicalProvider({
237
238
 
238
239
  merged.sort(compareLexicalResults);
239
240
  const ranked = merged.map((item) => {
240
- const scored = attachScore(item.entry, item.score);
241
- const snippet = extractSnippet(snippetTextByUri.get(item.entry.uri) || "", terms);
242
- return snippet ? { ...scored, matchedSnippet: snippet } : scored;
241
+ const match = matchByUri.get(item.entry.uri) || {};
242
+ return {
243
+ ...attachScore(item.entry, item.score),
244
+ ...buildMatchFields(match.passageText, terms, match.fallbackSnippet)
245
+ };
243
246
  });
244
247
  if (limit) return ranked.slice(0, limit);
245
248
  return ranked;
@@ -300,6 +300,9 @@ function createProviderOrchestrator({
300
300
  uri: item.uri,
301
301
  hash: createHash("sha256").update(`${model}\n${item.text}`).digest("hex").slice(0, 32)
302
302
  }));
303
+ // Passages stay in memory only (not in the cache file): they come from
304
+ // the current docs, parallel to indexedItems.
305
+ const passages = items.map((item) => item.passage || "");
303
306
  const checkpointHashes = new Set();
304
307
  const collect = () => (ragConfig.rebuild
305
308
  ? new Map()
@@ -338,7 +341,7 @@ function createProviderOrchestrator({
338
341
  vectorCache.clearVectorIndexCheckpoint(checkpointFile);
339
342
  vectorCache.pruneOtherCaches({ provider: name, model, keepFile: cacheFile });
340
343
  logRag(`cache hit provider=${name} file=${cacheFile} items=${indexedItems.length}`);
341
- return { items: indexedItems, vectors };
344
+ return { items: indexedItems, vectors, passages };
342
345
  }
343
346
 
344
347
  if (name === "gemini" && embedder.resetMetrics) {
@@ -431,7 +434,8 @@ function createProviderOrchestrator({
431
434
  );
432
435
  return {
433
436
  items: payload.items,
434
- vectors: payload.vectors
437
+ vectors: payload.vectors,
438
+ passages
435
439
  };
436
440
  })();
437
441
  return indexPromise;
@@ -462,7 +466,7 @@ function createProviderOrchestrator({
462
466
  if (!entry || !utils.entryMatchesScope(entry, filters)) continue;
463
467
  const existing = bestByUri.get(item.uri);
464
468
  if (!existing || score > existing.score) {
465
- bestByUri.set(item.uri, { entry, score, chunkText: item.text });
469
+ bestByUri.set(item.uri, { entry, score, passage: index.passages[i] });
466
470
  }
467
471
  }
468
472
 
@@ -477,11 +481,10 @@ function createProviderOrchestrator({
477
481
  const kept = ordered.filter((item) => item.score >= relativeFloor);
478
482
 
479
483
  const snippetTerms = baseQuery.toLowerCase().split(/\s+/).filter(Boolean);
480
- const results = kept.map((item) => {
481
- const scored = utils.attachScore(item.entry, item.score);
482
- const snippet = utils.extractSnippet ? utils.extractSnippet(item.chunkText || "", snippetTerms, 240) : "";
483
- return snippet ? { ...scored, matchedSnippet: snippet } : scored;
484
- });
484
+ const results = kept.map((item) => ({
485
+ ...utils.attachScore(item.entry, item.score),
486
+ ...utils.buildMatchFields(item.passage, snippetTerms)
487
+ }));
485
488
 
486
489
  if (limit) return results.slice(0, limit);
487
490
  return results;
@@ -184,11 +184,17 @@ function expandQueryTokens(tokens) {
184
184
  return [...out];
185
185
  }
186
186
 
187
+ // Cap for matched passages returned by search: one chunk (RAG_CHUNK_SIZE 1200)
188
+ // plus its heading path. The same cap applies to semantic and lexical results.
189
+ const PASSAGE_MAX_CHARS = 1500;
190
+
187
191
  // A ~maxLen window around the first query term found in text — used to give the
188
192
  // agent a matched snippet in search results (issues #146/#149).
189
193
  function extractSnippet(text, terms, maxLen = 240) {
190
194
  const cleaned = normalizeText(text);
191
195
  if (!cleaned) return "";
196
+ // Text that fits is returned whole, so its heading and leading context stay.
197
+ if (cleaned.length <= maxLen) return cleaned;
192
198
  const lower = cleaned.toLowerCase();
193
199
  let hitAt = -1;
194
200
  for (const term of terms) {
@@ -202,6 +208,50 @@ function extractSnippet(text, terms, maxLen = 240) {
202
208
  return (start > 0 ? "…" : "") + snippet + (start + maxLen < cleaned.length ? "…" : "");
203
209
  }
204
210
 
211
+ // Function words that match almost any passage; a passage must share a word
212
+ // other than these with the query to count as matched.
213
+ const PASSAGE_STOPWORDS = new Set(
214
+ "a an and are as at be by can do does for from how i if in into is it its me my of on or so the this that to use using via what when where which with without you your".split(" ")
215
+ );
216
+
217
+ // Each word is kept whole and, when camelCase, also split into its parts
218
+ // ("SelectSource" -> "selectsource", "select", "source"), so API names match
219
+ // plain query words and lowercased identifiers alike.
220
+ function wordTokens(text) {
221
+ const tokens = [];
222
+ for (const word of String(text || "").split(/[^A-Za-z0-9]+/)) {
223
+ if (!word) continue;
224
+ tokens.push(word.toLowerCase());
225
+ const parts = word.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase().split(" ");
226
+ if (parts.length > 1) tokens.push(...parts);
227
+ }
228
+ return tokens;
229
+ }
230
+
231
+ // True when the passage contains a query content word (compared by stem, with
232
+ // the query's synonym expansion). Semantic scores include the page title,
233
+ // summary, and tags, so the best chunk of a header-driven hit can be an
234
+ // unrelated or near-empty section; such chunks are not shown as passages.
235
+ function passageMatchesQuery(passage, terms) {
236
+ const queryWords = expandQueryTokens(wordTokens(terms.join(" ")).filter((word) => !PASSAGE_STOPWORDS.has(word)));
237
+ if (queryWords.length === 0) return false;
238
+ const wanted = new Set(queryWords.map(stemToken));
239
+ return wordTokens(passage).some((word) => wanted.has(stemToken(word)));
240
+ }
241
+
242
+ // Short snippet plus the full matched passage (capped) for one search hit.
243
+ // Both come from the passage only when it contains a query content word;
244
+ // otherwise the snippet comes from `fallbackSnippet` (e.g. the page summary)
245
+ // or is omitted, so an unrelated section is never shown as evidence.
246
+ function buildMatchFields(passage, terms, fallbackSnippet = "") {
247
+ const fields = {};
248
+ const matched = Boolean(passage) && passageMatchesQuery(passage, terms);
249
+ const snippet = extractSnippet(matched ? passage : fallbackSnippet, terms);
250
+ if (snippet) fields.matchedSnippet = snippet;
251
+ if (matched) fields.matchedPassage = extractSnippet(passage, terms, PASSAGE_MAX_CHARS);
252
+ return fields;
253
+ }
254
+
205
255
  function buildEntryBaseText(entry) {
206
256
  const parts = [entry.title, entry.summary];
207
257
  if (Array.isArray(entry.tags) && entry.tags.length > 0) {
@@ -230,7 +280,10 @@ function buildEmbeddingItems(resourceIndex, ragConfig) {
230
280
  items.push({
231
281
  id: `${entry.id}#${index}`,
232
282
  uri: entry.uri,
233
- text: truncateText(combined, ragConfig.maxTextChars)
283
+ text: truncateText(combined, ragConfig.maxTextChars),
284
+ // The chunk alone (heading path + body), without the page header that
285
+ // is embedded with it; search returns this as the matched passage.
286
+ passage: chunk
234
287
  });
235
288
  });
236
289
  continue;
@@ -308,6 +361,8 @@ export {
308
361
  expandQueryTokens,
309
362
  stemToken,
310
363
  extractSnippet,
364
+ buildMatchFields,
365
+ PASSAGE_MAX_CHARS,
311
366
  buildEmbeddingItems,
312
367
  buildIndexSignature,
313
368
  normalizeVector,
@@ -72,8 +72,8 @@ export function createMcpServerInstance({ pkgVersion, resourceIndexApi, ragApi }
72
72
  "This server answers questions about five public Dynamsoft offerings: DBR, DWT, DDV, MRZ, MDS.",
73
73
  "",
74
74
  "How to use it:",
75
- "- Call get_index first to see valid product/edition/platform combinations before asking the user anything.",
76
- "- To answer from documentation: call search, then call read_document with the exact doc:// uri it returned, and base the answer on that text, citing the public documentation URL it contains. Do not answer from search summaries alone or hand doc:// links to the user.",
75
+ "- Call get_index first to see valid product/edition/platform combinations before asking the user anything. Its productSelection.capabilities lists known facts (OCR, VIN, ID documents) that take precedence over inference; for other capabilities, search before saying something is not offered.",
76
+ "- To answer from documentation: call search. When a matched passage in the search response covers the question, answer from it and cite its Source URL. Otherwise call read_document with the exact doc:// uri it returned, base the answer on that text, and cite the public documentation URL it contains. Do not answer from search summaries alone or hand doc:// links to the user.",
77
77
  "- For sample:// results, call get_sample_files with resource_uri to get the complete sample project.",
78
78
  "- Only ask the user to disambiguate dimensions that actually vary for the chosen product. Do NOT ask about dimensions that don't apply.",
79
79
  "",
@@ -0,0 +1,136 @@
1
+ import { formatScoreLabel, formatScoreNote } from "./server-helpers.js";
2
+ import { resolveSearchScope } from "../normalizers.js";
3
+
4
+ // Runs a search in the scope resolveSearchScope picks (see normalizers.js).
5
+ // An edition read from the query never hides every result: with no results
6
+ // it searches again across all editions. `filters` are already normalized.
7
+ async function runScopedSearch(searchResources, { query, product, edition, platform, ...rest }) {
8
+ const scope = resolveSearchScope({ query, product, edition, platform });
9
+ const results = await searchResources({ query, product, edition: scope.edition, platform, ...rest });
10
+ if (!scope.inferredFrom) return { results, scopeNote: "" };
11
+ if (results.length === 0) {
12
+ return { results: await searchResources({ query, product, edition, platform, ...rest }), scopeNote: "" };
13
+ }
14
+ return {
15
+ results,
16
+ scopeNote: `Searched the ${product.toUpperCase()} ${scope.edition} edition because the query names it; pass edition (or platform) explicitly to search another edition.`
17
+ };
18
+ }
19
+
20
+ // Matched passages are inlined for the top doc results, so an agent can answer
21
+ // from the relevant section without a read_document round trip. Each passage
22
+ // is capped by PASSAGE_MAX_CHARS (src/rag/search-utils.js); full pages stay
23
+ // behind read_document. Samples are excluded: their indexed text is generated
24
+ // metadata (name, category, scenario tags), not content.
25
+ const SEARCH_PASSAGE_COUNT = 3;
26
+
27
+ function formatSearchPassages(results, count = SEARCH_PASSAGE_COUNT) {
28
+ const blocks = results
29
+ .slice(0, count)
30
+ .map((entry, index) => ({ entry, rank: index + 1 }))
31
+ .filter(({ entry }) => entry.type === "doc" && entry.matchedPassage)
32
+ .map(({ entry, rank }) => {
33
+ const source = entry.url ? `\nSource: ${entry.url}` : "";
34
+ return `[${rank}] ${entry.title} (${entry.uri})${source}\n${entry.matchedPassage}`;
35
+ });
36
+ if (blocks.length === 0) return "";
37
+ return [
38
+ `Matched passages from the top ${count} results (call read_document for the full page):`,
39
+ ...blocks
40
+ ].join("\n\n");
41
+ }
42
+
43
+ // A resource_link description that lets an agent choose WITHOUT a follow-up
44
+ // read: type | scope | version | score - summary, then the matched snippet and
45
+ // canonical URL when available (issue #146).
46
+ function createHitDescriber({ formatScopeLabel, getSampleIdFromUri }) {
47
+ return function describeHit(entry) {
48
+ const versionLabel = entry.version ? `v${entry.version}` : "n/a";
49
+ const scopeLabel = formatScopeLabel(entry);
50
+ const sampleId = entry.type === "sample" ? getSampleIdFromUri(entry.uri) : "";
51
+ const sampleHint = sampleId ? ` | sample_id: ${sampleId}` : "";
52
+ const scoreLabel = formatScoreLabel(entry);
53
+ const sharedLabel = entry.type === "doc" && Array.isArray(entry.tags) && entry.tags.includes("shared")
54
+ ? ` | shared Capture Vision API used by DBR (DCV v${entry.sourceVersion || "n/a"})`
55
+ : "";
56
+ let desc = `${entry.type.toUpperCase()} | ${scopeLabel} | ${versionLabel}${sharedLabel}${scoreLabel} - ${entry.summary}${sampleHint}`;
57
+ if (entry.matchedSnippet) {
58
+ const snippet = String(entry.matchedSnippet).replace(/\s+/g, " ").slice(0, 220);
59
+ desc += ` — "${snippet}"`;
60
+ }
61
+ if (entry.type === "doc" && entry.url) {
62
+ desc += ` | ${entry.url}`;
63
+ }
64
+ return desc;
65
+ };
66
+ }
67
+
68
+ // The search tool's response for ranked results: lead text, one resource_link
69
+ // per hit, matched passages (unless contentMode is "none"), plain URI list.
70
+ // `product` is the normalized product filter (empty when none was given);
71
+ // `scopeNote` explains a search scope the server chose (empty when none).
72
+ function buildSearchResultsResponse({ query, product, scopeNote = "", results, contentMode, describeHit, getSampleIdFromUri }) {
73
+ const passages = contentMode === "none" ? "" : formatSearchPassages(results);
74
+ const linkGuidance = "call read_document with the exact uri of a doc:// result for the full page, or get_sample_files with resource_uri for a sample:// result.";
75
+ const readGuidance = passages
76
+ ? `Matched passages of the top results follow the links; when they do not cover the question, ${linkGuidance}`
77
+ : `To read a result, ${linkGuidance}`;
78
+ const found = `Found ${results.length} result(s) for "${query}".${scopeNote ? ` ${scopeNote}` : ""} ${readGuidance}`;
79
+ // Capture Vision / DCV is the umbrella framework, not a selectable public
80
+ // product; steer the agent to the right offering (issue #150).
81
+ const dcvUmbrella = /\b(capture[\s-]?vision|dcv)\b/i.test(query) && !product;
82
+ const leadText = dcvUmbrella
83
+ ? `"Capture Vision" (DCV) is Dynamsoft's umbrella framework — pick the public product for your task: DBR (barcodes), MRZ (passports/IDs), MDS (document capture), DWT (scanner hardware), DDV (document viewer). Results below span the closest matches.\n${found}`
84
+ : found;
85
+
86
+ const content = [{ type: "text", text: leadText }];
87
+ for (const entry of results) {
88
+ content.push({
89
+ type: "resource_link",
90
+ uri: entry.uri,
91
+ name: entry.title,
92
+ description: describeHit(entry),
93
+ mimeType: entry.mimeType,
94
+ annotations: {
95
+ audience: ["assistant"],
96
+ priority: 0.8
97
+ }
98
+ });
99
+ }
100
+ if (passages) {
101
+ content.push({ type: "text", text: passages });
102
+ }
103
+
104
+ const plainLines = results.map((entry, index) => {
105
+ const sampleId = entry.type === "sample" ? getSampleIdFromUri(entry.uri) : "";
106
+ const action = entry.type === "sample" ? "get_sample_files resource_uri" : "read_document uri";
107
+ const sampleNote = sampleId ? ` sample_id=${sampleId}` : "";
108
+ const scoreNote = formatScoreNote(entry);
109
+ return `- ${index + 1}. ${entry.uri}${sampleNote}${scoreNote} (${action})`;
110
+ });
111
+ content.push({
112
+ type: "text",
113
+ text: ["Plain URIs (copy/paste):", ...plainLines].join("\n")
114
+ });
115
+
116
+ return { content };
117
+ }
118
+
119
+ // Everything an agent reads from a search response: text items plus each
120
+ // link's uri, name, and description.
121
+ function searchResponseText(response) {
122
+ return response.content
123
+ .map((item) => (item.type === "resource_link"
124
+ ? [item.uri, item.name, item.description].filter(Boolean).join("\n")
125
+ : item.text || ""))
126
+ .join("\n\n");
127
+ }
128
+
129
+ export {
130
+ SEARCH_PASSAGE_COUNT,
131
+ formatSearchPassages,
132
+ createHitDescriber,
133
+ buildSearchResultsResponse,
134
+ searchResponseText,
135
+ runScopedSearch
136
+ };
@@ -402,6 +402,36 @@ function inferProductFromQuery(query) {
402
402
  return "";
403
403
  }
404
404
 
405
+ // Edition hints for DBR searches, checked against the current query only.
406
+ // React Native and Node.js (also written "node js", "node-js") are removed
407
+ // before the web check so their "js"/"react" do not also count as web.
408
+ const NON_WEB_JS = /react[\s-]?native|node[\s.-]?js/g;
409
+ const QUERY_EDITION_PATTERNS = {
410
+ mobile: /\b(?:android|ios|iphone|ipad|swift|kotlin|flutter|maui|xamarin|react[\s-]?native|mobile)\b/,
411
+ server: /\b(?:python|java|cpp|csharp|dotnet|node[\s.-]?js|server|desktop)\b|(?<![a-z0-9])(?:c\+\+|c#|\.net)(?![a-z0-9])/,
412
+ web: /\b(?:web|browser|javascript|typescript|js|react|angular|vue|svelte|nuxt|next\.?js|html)\b/
413
+ };
414
+
415
+ // The edition a search runs in when the caller gave neither edition nor
416
+ // platform. MRZ and MDS are served for web only, so they default to web.
417
+ // For DBR, an edition is taken from the query only when it names exactly one
418
+ // edition unambiguously ("web app", "Android"); otherwise all editions are
419
+ // searched. `inferredFrom` is set when the query decided the edition, so the
420
+ // response can say so.
421
+ function resolveSearchScope({ query, product, edition, platform }) {
422
+ if (edition || platform) return { edition: edition || "", inferredFrom: "" };
423
+ if (product === "mrz" || product === "mds") return { edition: "web", inferredFrom: "" };
424
+ if (product !== "dbr") return { edition: "", inferredFrom: "" };
425
+ const text = String(query || "").toLowerCase();
426
+ const mentioned = [];
427
+ if (QUERY_EDITION_PATTERNS.mobile.test(text)) mentioned.push("mobile");
428
+ if (QUERY_EDITION_PATTERNS.server.test(text)) mentioned.push("server");
429
+ const withoutOtherJs = text.replace(NON_WEB_JS, " ");
430
+ if (QUERY_EDITION_PATTERNS.web.test(withoutOtherJs)) mentioned.push("web");
431
+ if (mentioned.length !== 1) return { edition: "", inferredFrom: "" };
432
+ return { edition: mentioned[0], inferredFrom: "query" };
433
+ }
434
+
405
435
  export {
406
436
  sdkAliases,
407
437
  platformAliases,
@@ -421,6 +451,7 @@ export {
421
451
  isWebFrameworkPlatform,
422
452
  isWebPlatform,
423
453
  inferProductFromQuery,
454
+ resolveSearchScope,
424
455
  KNOWN_PLATFORMS,
425
456
  KNOWN_EDITIONS,
426
457
  validatePlatform,
@@ -43,6 +43,16 @@ const DBR_ONLY_EDITIONS_NOTE = `Only DBR has multiple editions; ${WEB_ONLY_OMIT_
43
43
  const API_LEVEL_NOTE = "high-level or low-level. DBR mobile ONLY; ignored for web/server/other products.";
44
44
  const PRODUCT_SELECTION_GUIDANCE = "DBR is the only product with multiple editions (mobile/web/server). DWT, DDV, MRZ, and MDS are web/JavaScript-only here, so do not ask the user which platform or language for them. api_level (high-level/low-level) applies only to DBR mobile.";
45
45
 
46
+ // Capability facts confirmed by Dynamsoft that the docs do not state outright
47
+ // (especially what is NOT offered). Served by get_index and the pinned
48
+ // product-selection guidance so agents answer "do you support X?" correctly.
49
+ const CAPABILITY_FACTS = {
50
+ note: "Known capability facts. They take precedence over inference; for anything not listed here, search before concluding that a capability is unsupported.",
51
+ ocr: "There is no standalone OCR SDK. The OCR Connector is an add-on of Dynamic Web TWAIN (DWT) and is used only together with DWT, typically on a PC or workstation. Do not recommend it for mobile or non-DWT workflows.",
52
+ vin: "VINs are read from their barcodes with DBR (web sample: read-vin). Reading VIN text (OCR) is not offered.",
53
+ idDocuments: "Passports and ID cards with an MRZ zone: MRZ. AAMVA driver licenses: DBR, which decodes the PDF417 barcode and parses the driver-license fields (web sample: read-a-drivers-license)."
54
+ };
55
+
46
56
  function normalizePublicOffering(product) {
47
57
  if (!product) return "";
48
58
  const normalized = product.trim().toLowerCase();
@@ -76,6 +86,7 @@ export {
76
86
  DBR_ONLY_EDITIONS_NOTE,
77
87
  API_LEVEL_NOTE,
78
88
  PRODUCT_SELECTION_GUIDANCE,
89
+ CAPABILITY_FACTS,
79
90
  normalizePublicOffering,
80
91
  getHydrationProduct,
81
92
  isKnownPublicOffering,
@@ -2,7 +2,7 @@ import { existsSync, readdirSync, statSync } from "node:fs";
2
2
  import { extname, join } from "node:path";
3
3
  import { DDV_PREFERRED_ENTRY_FILES } from "./config.js";
4
4
  import { shortDocPath } from "./docs-loader.js";
5
- import { PRODUCT_SELECTION_GUIDANCE, PUBLIC_OFFERING_PRODUCTS } from "../public-offerings.js";
5
+ import { CAPABILITY_FACTS, PRODUCT_SELECTION_GUIDANCE, PUBLIC_OFFERING_PRODUCTS } from "../public-offerings.js";
6
6
 
7
7
  const MRZ_MATCHER = /(?:\bmrz\b|machine[-\s]?readable[-\s]?zone|passport)/i;
8
8
  const MDS_MATCHER = /(?:document[-\s]scan|document scanner|document scanning|document normalizer|document normalization|normaliz|auto[-\s]?capture|crop|cropping|deskew)/i;
@@ -93,7 +93,15 @@ function buildProductSelectionGuidanceText() {
93
93
  "- MRZ Scanner (MRZ): use for passport and machine-readable-zone workflows. Public guidance is web/mobile solution/RTU only. Do not default to a foundational API path here; handle server/desktop separately through contact-driven guidance.",
94
94
  "- Mobile Document Scanner (MDS): use for document scan and normalization workflows. Public guidance is web-only solution/RTU. Do not default to a foundational API path here; handle mobile and server/desktop separately through contact-driven guidance.",
95
95
  "",
96
- "Choose DBR when you want direct barcode capabilities, MRZ when you want passport/MRZ flows, MDS when you want document scanning flows, DWT when you need browser acquisition, and DDV when you need viewing, annotation, multi-page handling, or PDF-oriented extension paths."
96
+ "Choose DBR when you want direct barcode capabilities, MRZ when you want passport/MRZ flows, MDS when you want document scanning flows, DWT when you need browser acquisition, and DDV when you need viewing, annotation, multi-page handling, or PDF-oriented extension paths.",
97
+ "",
98
+ "## Known Capability Facts",
99
+ "",
100
+ CAPABILITY_FACTS.note,
101
+ "",
102
+ `- OCR: ${CAPABILITY_FACTS.ocr}`,
103
+ `- VIN: ${CAPABILITY_FACTS.vin}`,
104
+ `- ID documents: ${CAPABILITY_FACTS.idDocuments}`
97
105
  ].join("\n");
98
106
  }
99
107
 
@@ -428,6 +436,7 @@ function buildIndexData({
428
436
  productSelection: {
429
437
  guidance: PRODUCT_SELECTION_GUIDANCE,
430
438
  publicOfferings: [...PUBLIC_OFFERING_PRODUCTS],
439
+ capabilities: { ...CAPABILITY_FACTS },
431
440
  offerings: {
432
441
  dwt: {
433
442
  name: "Dynamic Web TWAIN",
@@ -506,6 +515,7 @@ function buildIndexData({
506
515
  productSelection: {
507
516
  guidance: PRODUCT_SELECTION_GUIDANCE,
508
517
  publicOfferings: [...PUBLIC_OFFERING_PRODUCTS],
518
+ capabilities: { ...CAPABILITY_FACTS },
509
519
  dcvSupersetSummary: "Dynamsoft Capture Vision (DCV) aggregates Dynamsoft Barcode Reader (DBR), Dynamsoft Label Recognizer (DLR), Dynamsoft Document Normalizer (DDN), Dynamsoft Code Parser (DCP), and Dynamsoft Camera Enhancer (DCE) into one pipeline. DCV is internal only and is not a selectable public product.",
510
520
  useDbrWhen: [
511
521
  "Barcode-only workflows where DCV-specific workflows are not required."
@@ -13,10 +13,11 @@ export function registerDocumentTools({ server, readIndexedResource }) {
13
13
  "Read the full text of a Dynamsoft documentation page by its doc:// URI.",
14
14
  "",
15
15
  "WHEN TO USE:",
16
- "- After search returns doc:// results: call read_document with the exact uri from a resource_link or the plain URI list before answering.",
17
- "- When you need the actual article body (API details, parameters, code snippets) instead of the search summary.",
16
+ "- After search returns doc:// results whose matched passages do not cover the question: call read_document with the exact uri from a resource_link or the plain URI list before answering.",
17
+ "- When you need the full article (complete API details, parameter lists, code with its original formatting) rather than a matched passage or summary.",
18
18
  "",
19
19
  "WHEN NOT TO USE:",
20
+ "- When a matched passage returned by search already answers the question; cite that result's public URL instead.",
20
21
  "- For sample:// URIs, use get_sample_files with resource_uri instead (complete sample projects).",
21
22
  "- Do not construct or edit doc:// URIs yourself; they are generated by search and may change across data refreshes.",
22
23
  "",
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { SEARCH_PASSAGE_COUNT, buildSearchResultsResponse, createHitDescriber, runScopedSearch } from "../helpers/search-response.js";
2
3
  import { formatScoreLabel, formatScoreNote } from "../helpers/server-helpers.js";
3
4
  import {
4
5
  buildUnknownPublicProductResponse,
@@ -8,7 +9,7 @@ import {
8
9
  WEB_ONLY_OMIT_NOTE
9
10
  } from "../public-offerings.js";
10
11
  import { buildUnsupportedPublicScopeResponse } from "./public-routing.js";
11
- import { validatePlatform, validateEdition } from "../normalizers.js";
12
+ import { resolveSearchScope, validatePlatform, validateEdition } from "../normalizers.js";
12
13
 
13
14
  export function registerIndexTools({
14
15
  server,
@@ -25,28 +26,7 @@ export function registerIndexTools({
25
26
  getSampleEntries,
26
27
  getSampleSuggestions
27
28
  }) {
28
- // Build a resource_link description that lets an agent choose WITHOUT a
29
- // follow-up read: type | scope | version | score - summary, then the matched
30
- // snippet and canonical URL when available (issue #146).
31
- function buildHitDescription(entry) {
32
- const versionLabel = entry.version ? `v${entry.version}` : "n/a";
33
- const scopeLabel = formatScopeLabel(entry);
34
- const sampleId = entry.type === "sample" ? getSampleIdFromUri(entry.uri) : "";
35
- const sampleHint = sampleId ? ` | sample_id: ${sampleId}` : "";
36
- const scoreLabel = formatScoreLabel(entry);
37
- const sharedLabel = entry.type === "doc" && Array.isArray(entry.tags) && entry.tags.includes("shared")
38
- ? ` | shared Capture Vision API used by DBR (DCV v${entry.sourceVersion || "n/a"})`
39
- : "";
40
- let desc = `${entry.type.toUpperCase()} | ${scopeLabel} | ${versionLabel}${sharedLabel}${scoreLabel} - ${entry.summary}${sampleHint}`;
41
- if (entry.matchedSnippet) {
42
- const snippet = String(entry.matchedSnippet).replace(/\s+/g, " ").slice(0, 220);
43
- desc += ` — "${snippet}"`;
44
- }
45
- if (entry.type === "doc" && entry.url) {
46
- desc += ` | ${entry.url}`;
47
- }
48
- return desc;
49
- }
29
+ const buildHitDescription = createHitDescriber({ formatScopeLabel, getSampleIdFromUri });
50
30
 
51
31
  function looksLikeSampleIdQuery(value) {
52
32
  const normalized = String(value || "").trim();
@@ -119,8 +99,9 @@ export function registerIndexTools({
119
99
  "- version: Version constraint (e.g. '10', '11.x'). Only latest major is served by default.",
120
100
  "- type: 'doc', 'sample', 'index', 'policy', or 'any' (default). Use 'sample' to restrict to sample results.",
121
101
  "- limit: 1-10 (default 5). Max number of results.",
102
+ `- content: 'passage' (default) includes the matched passage (up to ~1500 chars) of the top ${SEARCH_PASSAGE_COUNT} results; 'none' returns links and short snippets only.`,
122
103
  "",
123
- "RETURNS: An MCP response whose content array includes a leading text summary item followed by zero or more resource_link items with URIs. For doc:// URIs, call read_document with the exact returned uri to get the full document text before answering. For sample:// URIs, call get_sample_files with resource_uri to get the full project files.",
104
+ "RETURNS: An MCP response whose content array includes a leading text summary item, zero or more resource_link items with URIs, and (unless content='none') a text item with the matched passages of the top results. Answer from a passage when it covers the question; otherwise, for doc:// URIs call read_document with the exact returned uri to get the full document text. For sample:// URIs, call get_sample_files with resource_uri to get the full project files.",
124
105
  "",
125
106
  "RELATED TOOLS: get_index (discover products first), list_samples (browse all samples), read_document (read a doc:// result), get_sample_files (retrieve full sample project files)."
126
107
  ].join("\n"),
@@ -131,7 +112,8 @@ export function registerIndexTools({
131
112
  platform: z.string().optional().describe(`Platform (DBR only spans multiple): android, ios, maui, react-native, flutter, js, python, cpp, java, dotnet, nodejs, angular, blazor, capacitor, electron, es6, native-ts, next, nuxt, pwa, react, requirejs, svelte, vue, webview, spm, core. ${WEB_ONLY_OMIT_NOTE}`),
132
113
  version: z.string().optional().describe("Version constraint (major or full version)"),
133
114
  type: z.enum(["doc", "sample", "index", "policy", "any"]).optional(),
134
- limit: z.number().int().min(1).max(10).optional().describe("Max results (default 5)")
115
+ limit: z.number().int().min(1).max(10).optional().describe("Max results (default 5)"),
116
+ content: z.enum(["passage", "none"]).optional().describe(`'passage' (default): include the matched passage of the top ${SEARCH_PASSAGE_COUNT} results. 'none': links and short snippets only.`)
135
117
  },
136
118
  annotations: {
137
119
  readOnlyHint: true,
@@ -140,7 +122,7 @@ export function registerIndexTools({
140
122
  openWorldHint: false
141
123
  }
142
124
  },
143
- async ({ query, product, edition, platform, version, type, limit }) => {
125
+ async ({ query, product, edition, platform, version, type, limit, content: contentMode }) => {
144
126
  const normalizedProduct = normalizeProduct(product);
145
127
  if (product && !isKnownPublicOffering(normalizedProduct)) {
146
128
  return buildUnknownPublicProductResponse(product);
@@ -162,9 +144,21 @@ export function registerIndexTools({
162
144
  const unsupportedScopeResponse = buildUnsupportedPublicScopeResponse(normalizedProduct, normalizedEdition, normalizedPlatform);
163
145
  if (unsupportedScopeResponse) return unsupportedScopeResponse;
164
146
 
165
- await ensureScopeHydrated({
147
+ // MRZ/MDS are served for web only: hydration, exact sample matches,
148
+ // sample suggestions, and ranked search all use the web default (an
149
+ // unfiltered MRZ scope would otherwise download the mobile repos in lazy
150
+ // mode). DBR editions are read from the query only for ranked search.
151
+ // Version policy keeps the scope exactly as the caller gave it.
152
+ const defaultEdition = resolveSearchScope({
153
+ query: "",
166
154
  product: normalizedProduct,
167
155
  edition: normalizedEdition,
156
+ platform: normalizedPlatform
157
+ }).edition;
158
+
159
+ await ensureScopeHydrated({
160
+ product: normalizedProduct,
161
+ edition: defaultEdition,
168
162
  platform: normalizedPlatform,
169
163
  type: type || "any"
170
164
  });
@@ -182,14 +176,13 @@ export function registerIndexTools({
182
176
  }
183
177
 
184
178
  const maxResults = Math.min(limit || 5, 10);
185
-
186
179
  // Exact sample-ID fast path (when searching for samples)
187
180
  const effectiveType = type || "any";
188
181
  if (effectiveType === "sample" || effectiveType === "any") {
189
182
  const normalizedQuery = normalizeSampleName(query);
190
183
  const scopedSamples = getSampleEntries({
191
184
  product: normalizedProduct,
192
- edition: normalizedEdition,
185
+ edition: defaultEdition,
193
186
  platform: normalizedPlatform
194
187
  });
195
188
  const exactMatches = scopedSamples.filter((entry) => {
@@ -243,9 +236,9 @@ export function registerIndexTools({
243
236
  const preferSampleSuggestionFallback =
244
237
  effectiveType === "sample" && looksLikeSampleIdQuery(query);
245
238
 
246
- const topResults = preferSampleSuggestionFallback
247
- ? []
248
- : await searchResources({
239
+ const { results: topResults, scopeNote } = preferSampleSuggestionFallback
240
+ ? { results: [], scopeNote: "" }
241
+ : await runScopedSearch(searchResources, {
249
242
  query,
250
243
  product: normalizedProduct,
251
244
  edition: normalizedEdition,
@@ -260,7 +253,7 @@ export function registerIndexTools({
260
253
  const suggestions = await getSampleSuggestions({
261
254
  query,
262
255
  product: normalizedProduct,
263
- edition: normalizedEdition,
256
+ edition: defaultEdition,
264
257
  platform: normalizedPlatform,
265
258
  limit: maxResults
266
259
  });
@@ -315,52 +308,15 @@ export function registerIndexTools({
315
308
  };
316
309
  }
317
310
 
318
- // Capture Vision / DCV is the umbrella framework, not a selectable public
319
- // product — steer the agent to the right offering (issue #150).
320
- const dcvUmbrella = /\b(capture[\s-]?vision|dcv)\b/i.test(query) && !normalizedProduct;
321
- const readGuidance = "For doc:// results, call read_document with the exact uri to get the document text; for sample:// results, call get_sample_files with resource_uri.";
322
- const leadText = dcvUmbrella
323
- ? `"Capture Vision" (DCV) is Dynamsoft's umbrella framework — pick the public product for your task: DBR (barcodes), MRZ (passports/IDs), MDS (document capture), DWT (scanner hardware), DDV (document viewer). Results below span the closest matches.\nFound ${topResults.length} result(s) for "${query}". ${readGuidance}`
324
- : `Found ${topResults.length} result(s) for "${query}". ${readGuidance}`;
325
- const content = [
326
- {
327
- type: "text",
328
- text: leadText
329
- }
330
- ];
331
-
332
- for (const entry of topResults) {
333
- const versionLabel = entry.version ? `v${entry.version}` : "n/a";
334
- const scopeLabel = formatScopeLabel(entry);
335
- const sampleId = entry.type === "sample" ? getSampleIdFromUri(entry.uri) : "";
336
- const sampleHint = sampleId ? ` | sample_id: ${sampleId}` : "";
337
- const scoreLabel = formatScoreLabel(entry);
338
- content.push({
339
- type: "resource_link",
340
- uri: entry.uri,
341
- name: entry.title,
342
- description: buildHitDescription(entry),
343
- mimeType: entry.mimeType,
344
- annotations: {
345
- audience: ["assistant"],
346
- priority: 0.8
347
- }
348
- });
349
- }
350
-
351
- const plainLines = topResults.map((entry, index) => {
352
- const sampleId = entry.type === "sample" ? getSampleIdFromUri(entry.uri) : "";
353
- const action = entry.type === "sample" ? "get_sample_files resource_uri" : "read_document uri";
354
- const sampleNote = sampleId ? ` sample_id=${sampleId}` : "";
355
- const scoreNote = formatScoreNote(entry);
356
- return `- ${index + 1}. ${entry.uri}${sampleNote}${scoreNote} (${action})`;
357
- });
358
- content.push({
359
- type: "text",
360
- text: ["Plain URIs (copy/paste):", ...plainLines].join("\n")
311
+ return buildSearchResultsResponse({
312
+ query,
313
+ results: topResults,
314
+ contentMode,
315
+ product: normalizedProduct,
316
+ scopeNote,
317
+ describeHit: buildHitDescription,
318
+ getSampleIdFromUri
361
319
  });
362
-
363
- return { content };
364
320
  }
365
321
  );
366
322
  }