@enokdev/springdocs-mcp 1.2.8 → 1.4.1

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 (112) hide show
  1. package/README.md +83 -40
  2. package/build/config.d.ts +9 -0
  3. package/build/config.d.ts.map +1 -0
  4. package/build/config.js +32 -0
  5. package/build/config.js.map +1 -0
  6. package/build/format.d.ts +6 -0
  7. package/build/format.d.ts.map +1 -0
  8. package/build/format.js +23 -0
  9. package/build/format.js.map +1 -0
  10. package/build/http-server.d.ts +15 -0
  11. package/build/http-server.d.ts.map +1 -0
  12. package/build/http-server.js +131 -0
  13. package/build/http-server.js.map +1 -0
  14. package/build/index.js +182 -68
  15. package/build/index.js.map +1 -1
  16. package/build/prompts.d.ts +23 -0
  17. package/build/prompts.d.ts.map +1 -0
  18. package/build/prompts.js +112 -0
  19. package/build/prompts.js.map +1 -0
  20. package/build/resources.d.ts +31 -0
  21. package/build/resources.d.ts.map +1 -0
  22. package/build/resources.js +66 -0
  23. package/build/resources.js.map +1 -0
  24. package/build/services/advanced-features.d.ts +14 -9
  25. package/build/services/advanced-features.d.ts.map +1 -1
  26. package/build/services/advanced-features.js +205 -195
  27. package/build/services/advanced-features.js.map +1 -1
  28. package/build/services/boot-wiki.d.ts +25 -0
  29. package/build/services/boot-wiki.d.ts.map +1 -0
  30. package/build/services/boot-wiki.js +103 -0
  31. package/build/services/boot-wiki.js.map +1 -0
  32. package/build/services/cache.d.ts +18 -3
  33. package/build/services/cache.d.ts.map +1 -1
  34. package/build/services/cache.js +84 -3
  35. package/build/services/cache.js.map +1 -1
  36. package/build/services/dependency-finder.d.ts +46 -0
  37. package/build/services/dependency-finder.d.ts.map +1 -0
  38. package/build/services/dependency-finder.js +181 -0
  39. package/build/services/dependency-finder.js.map +1 -0
  40. package/build/services/diagnosis.d.ts +46 -0
  41. package/build/services/diagnosis.d.ts.map +1 -0
  42. package/build/services/diagnosis.js +481 -0
  43. package/build/services/diagnosis.js.map +1 -0
  44. package/build/services/guides-list.d.ts +16 -0
  45. package/build/services/guides-list.d.ts.map +1 -0
  46. package/build/services/guides-list.js +45 -0
  47. package/build/services/guides-list.js.map +1 -0
  48. package/build/services/http.d.ts +21 -0
  49. package/build/services/http.d.ts.map +1 -0
  50. package/build/services/http.js +168 -0
  51. package/build/services/http.js.map +1 -0
  52. package/build/services/initializr.d.ts +41 -0
  53. package/build/services/initializr.d.ts.map +1 -0
  54. package/build/services/initializr.js +135 -0
  55. package/build/services/initializr.js.map +1 -0
  56. package/build/services/markdown.d.ts +56 -0
  57. package/build/services/markdown.d.ts.map +1 -0
  58. package/build/services/markdown.js +146 -0
  59. package/build/services/markdown.js.map +1 -0
  60. package/build/services/migration-sources.d.ts +13 -0
  61. package/build/services/migration-sources.d.ts.map +1 -0
  62. package/build/services/migration-sources.js +48 -0
  63. package/build/services/migration-sources.js.map +1 -0
  64. package/build/services/release-notes.d.ts +23 -0
  65. package/build/services/release-notes.d.ts.map +1 -0
  66. package/build/services/release-notes.js +94 -0
  67. package/build/services/release-notes.js.map +1 -0
  68. package/build/services/search-index.d.ts +33 -0
  69. package/build/services/search-index.d.ts.map +1 -0
  70. package/build/services/search-index.js +147 -0
  71. package/build/services/search-index.js.map +1 -0
  72. package/build/services/spring-projects-config.d.ts +26 -33
  73. package/build/services/spring-projects-config.d.ts.map +1 -1
  74. package/build/services/spring-projects-config.js +248 -79
  75. package/build/services/spring-projects-config.js.map +1 -1
  76. package/build/services/springboot-docs-optimized.d.ts +57 -16
  77. package/build/services/springboot-docs-optimized.d.ts.map +1 -1
  78. package/build/services/springboot-docs-optimized.js +315 -197
  79. package/build/services/springboot-docs-optimized.js.map +1 -1
  80. package/build/services/url.d.ts +15 -0
  81. package/build/services/url.d.ts.map +1 -0
  82. package/build/services/url.js +40 -0
  83. package/build/services/url.js.map +1 -0
  84. package/build/tools/index.d.ts +376 -2
  85. package/build/tools/index.d.ts.map +1 -1
  86. package/build/tools/index.js +178 -11
  87. package/build/tools/index.js.map +1 -1
  88. package/build/validation.d.ts +7 -0
  89. package/build/validation.d.ts.map +1 -0
  90. package/build/validation.js +50 -0
  91. package/build/validation.js.map +1 -0
  92. package/build/version.d.ts +3 -0
  93. package/build/version.d.ts.map +1 -0
  94. package/build/version.js +6 -0
  95. package/build/version.js.map +1 -0
  96. package/package.json +6 -6
  97. package/build/debug.d.ts +0 -3
  98. package/build/debug.d.ts.map +0 -1
  99. package/build/debug.js +0 -292
  100. package/build/debug.js.map +0 -1
  101. package/build/services/advanced-features-old.d.ts +0 -45
  102. package/build/services/advanced-features-old.d.ts.map +0 -1
  103. package/build/services/advanced-features-old.js +0 -792
  104. package/build/services/advanced-features-old.js.map +0 -1
  105. package/build/services/springboot-docs-optimized-old.d.ts +0 -55
  106. package/build/services/springboot-docs-optimized-old.d.ts.map +0 -1
  107. package/build/services/springboot-docs-optimized-old.js +0 -588
  108. package/build/services/springboot-docs-optimized-old.js.map +0 -1
  109. package/build/services/springboot-docs.d.ts +0 -65
  110. package/build/services/springboot-docs.d.ts.map +0 -1
  111. package/build/services/springboot-docs.js +0 -708
  112. package/build/services/springboot-docs.js.map +0 -1
@@ -1,8 +1,15 @@
1
- import fetch from 'node-fetch';
2
1
  import * as cheerio from 'cheerio';
3
- import TurndownService from 'turndown';
4
2
  import { CacheService } from './cache.js';
3
+ import { fetchWithRetry } from './http.js';
4
+ import { turndownService, extractContent, pageMarkdown } from './markdown.js';
5
+ import { absoluteSpringUrl, assertSafeSegment, normalizeVersion } from './url.js';
6
+ import { fetchSpringGuidesList } from './guides-list.js';
7
+ import { resolveWikiDocument, wikiPageName, wikiPageUrl, expectedWikiTitle, extractWikiMarkdown, selectSections } from './boot-wiki.js';
5
8
  import { springProjectsConfig } from './spring-projects-config.js';
9
+ import { SearchIndex } from './search-index.js';
10
+ import { assertRawMarkdown, isMigrationProject, projectDisplayName, rawWikiTitle, rawWikiUrl, resolveRawDocument } from './migration-sources.js';
11
+ /** Below this size an extracted guide is considered empty (a real guide is far longer; the smallest fixture is ~190). */
12
+ const MIN_GUIDE_CHARS = 100;
6
13
  /**
7
14
  * Spring Documentation Service - Supports multiple Spring projects (Boot, AI, Framework, etc.)
8
15
  * Uses ONLY real Spring documentation APIs - no mock data
@@ -14,78 +21,26 @@ export class SpringBootDocsServiceOptimized {
14
21
  springProjectsUrl = 'https://spring.io/projects';
15
22
  springGuideUrl = 'https://spring.io/guides';
16
23
  projectsConfig;
17
- turndownService;
18
24
  cache;
19
- REQUEST_TIMEOUT = 10000;
20
- MAX_RETRIES = 3;
21
- constructor(projectsConfig = springProjectsConfig) {
25
+ searchIndex;
26
+ constructor(projectsConfig = springProjectsConfig, cache = new CacheService(), searchIndex = new SearchIndex()) {
22
27
  this.projectsConfig = projectsConfig;
23
- this.turndownService = new TurndownService({
24
- headingStyle: 'atx',
25
- codeBlockStyle: 'fenced',
26
- });
27
- this.cache = new CacheService();
28
- // Cleanup cache every hour
29
- setInterval(() => this.cache.cleanup(), 60 * 60 * 1000);
28
+ this.cache = cache;
29
+ this.searchIndex = searchIndex;
30
30
  }
31
- /**
32
- * Extract content intelligently based on detail level
33
- * Preserves code blocks, key sections, and structure
34
- */
35
- extractIntelligentContent(markdown, detailLevel = 'medium') {
36
- const limits = {
37
- 'summary': 1500,
38
- 'medium': 4000,
39
- 'full': 8000
40
- };
41
- const maxLength = limits[detailLevel] || limits['medium'];
42
- // If content is smaller than limit, return as-is
43
- if (markdown.length <= maxLength) {
44
- return markdown;
45
- }
46
- // Extract important sections
47
- const lines = markdown.split('\n');
48
- let result = '';
49
- let inCodeBlock = false;
50
- let codeBlockContent = '';
51
- let currentLength = 0;
52
- for (const line of lines) {
53
- // Track code blocks
54
- if (line.trim().startsWith('```')) {
55
- inCodeBlock = !inCodeBlock;
56
- if (!inCodeBlock && codeBlockContent) {
57
- // Keep complete code blocks
58
- const blockToAdd = codeBlockContent + line + '\n';
59
- if (currentLength + blockToAdd.length <= maxLength * 0.8) { // Reserve 20% for text
60
- result += blockToAdd;
61
- currentLength += blockToAdd.length;
62
- }
63
- codeBlockContent = '';
64
- }
65
- else {
66
- codeBlockContent = line + '\n';
67
- }
68
- continue;
69
- }
70
- if (inCodeBlock) {
71
- codeBlockContent += line + '\n';
72
- continue;
73
- }
74
- // Keep headers, important lines
75
- if (line.startsWith('#') || line.startsWith('-') || line.startsWith('*') || line.trim().startsWith('>')) {
76
- if (currentLength + line.length + 1 <= maxLength) {
77
- result += line + '\n';
78
- currentLength += line.length + 1;
79
- }
80
- }
81
- else if (line.trim() && currentLength + line.length + 1 <= maxLength) {
82
- result += line + '\n';
83
- currentLength += line.length + 1;
84
- }
85
- if (currentLength >= maxLength)
86
- break;
31
+ /** Feeds the full-text index with a page already fetched; never breaks the read path. */
32
+ indexPage(docId, title, url, text) {
33
+ try {
34
+ this.searchIndex.add(docId, { title, url, text });
87
35
  }
88
- return result.trim();
36
+ catch (error) {
37
+ console.error(`Content index failure for ${docId}:`, error instanceof Error ? error.message : error);
38
+ }
39
+ }
40
+ /** Re-feeds the index from the cache when a page was evicted or read before the index existed. */
41
+ reindexIfMissing(docId, title, url, text) {
42
+ if (!this.searchIndex.has(docId))
43
+ this.indexPage(docId, title, url, text);
89
44
  }
90
45
  /**
91
46
  * Search Spring projects with caching and retry logic - REAL API ONLY
@@ -111,14 +66,14 @@ export class SpringBootDocsServiceOptimized {
111
66
  const $project = $(element);
112
67
  const title = $project.find('h2, h3, .title, .project-title').first().text().trim();
113
68
  const description = $project.find('p, .description, .summary').first().text().trim();
114
- const link = $project.find('a').first().attr('href');
115
- if (title && (title.toLowerCase().includes(query.toLowerCase()) ||
69
+ const url = absoluteSpringUrl($project.find('a').first().attr('href'));
70
+ if (url && title && (title.toLowerCase().includes(query.toLowerCase()) ||
116
71
  description.toLowerCase().includes(query.toLowerCase()))) {
117
72
  projects.push({
118
73
  type: 'spring-project',
119
74
  title: title,
120
75
  description: description,
121
- url: link?.startsWith('http') ? link : `https://spring.io${link}`,
76
+ url: url,
122
77
  });
123
78
  }
124
79
  });
@@ -128,22 +83,24 @@ export class SpringBootDocsServiceOptimized {
128
83
  }
129
84
  catch (error) {
130
85
  console.error('Error searching Spring projects:', error);
131
- return [];
86
+ throw error;
132
87
  }
133
88
  }
134
89
  /**
135
- * Get Spring project details - REAL API ONLY
90
+ * Get the full markdown of a Spring project page (cached) - REAL API ONLY
136
91
  */
137
- async getSpringProject(projectName) {
92
+ async getProjectMarkdown(projectName) {
93
+ const slug = assertSafeSegment(projectName.toLowerCase().replace(/\s+/g, '-'), 'project name');
138
94
  const cacheKey = `project:${projectName}`;
139
95
  const cached = this.cache.get(cacheKey);
140
96
  if (cached) {
141
97
  console.error(`✅ Cache hit for project: ${projectName}`);
98
+ this.reindexIfMissing(`project:${slug}`, projectName, cached.url, cached.markdown);
142
99
  return cached;
143
100
  }
144
101
  console.error(`🔍 Fetching project: ${projectName}`);
145
102
  try {
146
- const url = `${this.springProjectsUrl}/${projectName.toLowerCase().replace(/\s+/g, '-')}`;
103
+ const url = `${this.springProjectsUrl}/${slug}`;
147
104
  const response = await this.fetchWithRetry(url);
148
105
  if (!response.ok) {
149
106
  throw new Error(`Project not found: ${projectName}`);
@@ -154,17 +111,25 @@ export class SpringBootDocsServiceOptimized {
154
111
  if (content.length === 0) {
155
112
  throw new Error('No content found for project');
156
113
  }
157
- const markdown = this.turndownService.turndown(content.html() || '');
158
- const projectUrl = `${this.springProjectsUrl}/${projectName.toLowerCase().replace(/\s+/g, '-')}`;
159
- const result = `# ${projectName}\n\n${markdown.substring(0, 1500)}...\n\nFor complete project info, visit: ${projectUrl}`;
160
- this.cache.setLongTerm(cacheKey, result);
161
- return result;
114
+ const markdown = turndownService.turndown(content.html() || '');
115
+ // Cache the full markdown so later pages need no new fetch
116
+ this.indexPage(`project:${slug}`, projectName, url, markdown);
117
+ const entry = { markdown, url };
118
+ this.cache.setLongTerm(cacheKey, entry);
119
+ return entry;
162
120
  }
163
121
  catch (error) {
164
122
  console.error(`Error fetching project ${projectName}:`, error);
165
123
  throw error;
166
124
  }
167
125
  }
126
+ /**
127
+ * Get Spring project details - REAL API ONLY
128
+ */
129
+ async getSpringProject(projectName, offset = 0) {
130
+ const { markdown, url } = await this.getProjectMarkdown(projectName);
131
+ return this.formatPage(projectName, markdown, url, offset, 'For complete project info, visit');
132
+ }
168
133
  /**
169
134
  * Get all Spring guides - REAL API ONLY
170
135
  */
@@ -177,57 +142,45 @@ export class SpringBootDocsServiceOptimized {
177
142
  }
178
143
  console.error(`🔍 Fetching guides for category: ${category || 'all'}`);
179
144
  try {
180
- const response = await this.fetchWithRetry(this.springGuideUrl);
181
- if (!response.ok) {
182
- throw new Error('Unable to access Spring guides page');
183
- }
184
- const html = await response.text();
185
- const $ = cheerio.load(html);
186
- const guides = [];
187
- // Parse actual Spring guides page
188
- $('.guide-item, .card, .guide-card, .list-item, .guide').each((_, element) => {
189
- const $guide = $(element);
190
- const title = $guide.find('h2, h3, .title, .guide-title, a').first().text().trim();
191
- const description = $guide.find('p, .description, .summary').first().text().trim();
192
- const guideCategory = $guide.find('.category, .badge, .label').first().text().trim();
193
- const link = $guide.find('a').first().attr('href');
194
- if (title) {
195
- if (!category || guideCategory.toLowerCase().includes(category.toLowerCase())) {
196
- guides.push({
197
- type: 'spring-guide',
198
- title: title,
199
- description: description || 'Spring guide',
200
- category: guideCategory || 'General',
201
- url: link?.startsWith('http') ? link : `https://spring.io${link}`,
202
- });
203
- }
204
- }
205
- });
145
+ const guides = await fetchSpringGuidesList(category);
206
146
  const results = guides.slice(0, limit);
207
147
  this.cache.set(cacheKey, results);
208
148
  return results;
209
149
  }
210
150
  catch (error) {
211
151
  console.error('Error retrieving Spring guides:', error);
212
- return [];
152
+ throw error;
213
153
  }
214
154
  }
215
155
  /**
216
156
  * Get specific guide content - REAL API ONLY
217
157
  */
218
158
  async getGuide(guideId, detailLevel = 'medium') {
219
- const cacheKey = `guide:${guideId}:${detailLevel}`;
159
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(guideId) || guideId.includes('..')) {
160
+ throw new Error(`Invalid guideId "${guideId}": only letters, digits, '.', '_' and '-' are allowed`);
161
+ }
162
+ // Getting-started guides live under /guides/gs/<name>/; accept the repository-style "gs-<name>" too
163
+ const name = guideId.replace(/^gs-/, '');
164
+ if (!name) {
165
+ throw new Error(`Invalid guideId "${guideId}": the guide name is empty`);
166
+ }
167
+ const safeId = encodeURIComponent(name);
168
+ const cacheKey = `guide:${name}:${detailLevel}`;
169
+ const rawKey = `guide-md:${name}`;
220
170
  const cached = this.cache.get(cacheKey);
221
171
  if (cached) {
222
172
  console.error(`✅ Cache hit for guide: ${guideId} (${detailLevel})`);
173
+ // The formatted text depends on detailLevel: the index is fed from the full markdown kept apart
174
+ const full = this.cache.get(rawKey);
175
+ if (full)
176
+ this.reindexIfMissing(`guide:${name}`, `Guide: ${name}`, full.url, full.markdown);
223
177
  return cached;
224
178
  }
225
179
  console.error(`🔍 Fetching guide: ${guideId} with detail level: ${detailLevel}`);
226
- // Try multiple sources for guides
180
+ // Fallbacks tried in order: getting-started guide, then top-level guide path
227
181
  const sources = [
228
- { name: 'Spring.io', url: `${this.springGuideUrl}/${guideId}/` },
229
- { name: 'GitHub', url: `https://github.com/spring-guides/${guideId}` },
230
- { name: 'Spring.io alt', url: `${this.springGuideUrl}/gs/${guideId}/` }
182
+ { name: 'Spring.io getting-started', url: `${this.springGuideUrl}/gs/${safeId}/` },
183
+ { name: 'Spring.io', url: `${this.springGuideUrl}/${safeId}/` }
231
184
  ];
232
185
  let content = '';
233
186
  let sourceUrl = '';
@@ -253,7 +206,11 @@ export class SpringBootDocsServiceOptimized {
253
206
  throw new Error(`Guide not found: ${guideId}`);
254
207
  }
255
208
  try {
256
- const result = this.processHtmlGuide(content, guideId, sourceUrl, detailLevel);
209
+ const markdown = this.extractGuideMarkdown(content, sourceUrl);
210
+ const result = this.formatGuide(markdown, guideId, sourceUrl, detailLevel);
211
+ // Index the complete text, whatever the detail level of this read
212
+ this.indexPage(`guide:${name}`, `Guide: ${name}`, sourceUrl, markdown);
213
+ this.cache.setLongTerm(rawKey, { markdown, url: sourceUrl });
257
214
  this.cache.setLongTerm(cacheKey, result);
258
215
  return result;
259
216
  }
@@ -277,14 +234,22 @@ export class SpringBootDocsServiceOptimized {
277
234
  * @param projectId - Project identifier ('boot', 'ai', 'framework', etc.)
278
235
  * @param section - Documentation section (e.g., 'web', 'chatclient', 'core')
279
236
  * @param subsection - Optional subsection for deeper navigation
237
+ * @param offset - Character offset for paginated reads
238
+ * @param version - Optional documentation version ('3.4' or '3.4.2'; omitted/'current' = latest)
280
239
  * @returns Formatted markdown documentation with source URL
281
240
  */
282
- async getSpringReference(projectId, section, subsection) {
283
- const cacheKey = `reference:${projectId}:${section}:${subsection || 'main'}`;
241
+ async getSpringReference(projectId, section, subsection, offset = 0, version) {
242
+ const safeSection = assertSafeSegment(section, 'section');
243
+ const safeSubsection = subsection ? assertSafeSegment(subsection, 'subsection') : undefined;
244
+ // Validate the version before any cache or network access
245
+ const normalizedVersion = normalizeVersion(version);
246
+ const baseKey = `reference:${projectId}:${section}:${subsection || 'main'}`;
247
+ const cacheKey = normalizedVersion ? `${baseKey}:v${normalizedVersion}` : baseKey;
284
248
  const cached = this.cache.get(cacheKey);
285
249
  if (cached) {
286
250
  console.error(`✅ Cache hit for reference: ${projectId}/${section}`);
287
- return cached;
251
+ this.reindexIfMissing(`reference:${projectId}:${normalizedVersion ?? 'current'}:${section}:${subsection ?? 'main'}`, this.referenceTitle(projectId, section, subsection), cached.url, cached.markdown);
252
+ return this.formatPage(this.referenceTitle(projectId, section, subsection), cached.markdown, cached.url, offset, 'For complete reference, visit');
288
253
  }
289
254
  console.error(`🔍 Fetching reference: ${projectId}/${section}`);
290
255
  try {
@@ -296,9 +261,12 @@ export class SpringBootDocsServiceOptimized {
296
261
  throw new Error(`Invalid section "${section}" for ${project.displayName}. Available sections: ${availableSections}`);
297
262
  }
298
263
  // Build URL using configuration
299
- const url = this.projectsConfig.buildReferenceUrl(projectId, section);
264
+ const url = this.projectsConfig.buildReferenceUrl(projectId, safeSection, safeSubsection, normalizedVersion);
300
265
  const response = await this.fetchWithRetry(url);
301
266
  if (!response.ok) {
267
+ if (normalizedVersion) {
268
+ throw new Error(`Reference not found for ${project.displayName} version ${normalizedVersion} (this version may not be published at the current documentation site; omit 'version' for the latest or try a more recent one)`);
269
+ }
302
270
  throw new Error(`Reference section not found: ${project.displayName} / ${section}`);
303
271
  }
304
272
  const html = await response.text();
@@ -308,42 +276,146 @@ export class SpringBootDocsServiceOptimized {
308
276
  if (content.length === 0) {
309
277
  throw new Error(`No content found in ${project.displayName} reference documentation`);
310
278
  }
311
- const markdown = this.turndownService.turndown(content.html() || '');
312
- const result = `# ${project.displayName} Reference: ${section}\n\n${markdown.substring(0, 1500)}...\n\nFor complete reference, visit: ${url}`;
279
+ const markdown = turndownService.turndown(content.html() || '');
280
+ const entry = { markdown, url };
281
+ this.indexPage(`reference:${projectId}:${normalizedVersion ?? 'current'}:${section}:${subsection ?? 'main'}`, this.referenceTitle(projectId, section, subsection), url, markdown);
313
282
  // Use project-specific cache strategy
314
283
  const cacheTTL = this.projectsConfig.getCacheTTL(projectId);
315
284
  if (project.cacheStrategy === 'long') {
316
- this.cache.setLongTerm(cacheKey, result);
285
+ this.cache.setLongTerm(cacheKey, entry);
317
286
  }
318
287
  else {
319
- this.cache.set(cacheKey, result, cacheTTL);
288
+ this.cache.set(cacheKey, entry, cacheTTL);
320
289
  }
321
- return result;
290
+ return this.formatPage(this.referenceTitle(projectId, section, subsection), markdown, url, offset, 'For complete reference, visit');
322
291
  }
323
292
  catch (error) {
324
293
  console.error(`Error fetching reference ${projectId}/${section}:`, error);
325
294
  throw error;
326
295
  }
327
296
  }
297
+ /**
298
+ * Get the Spring Boot migration guide or upgrade release notes from the GitHub wiki
299
+ *
300
+ * @param version - Target version ('3.0', '3.4' or '3.4.2'; the patch is ignored)
301
+ * @param document - 'auto' (migration guide for x.0, release notes otherwise) or an explicit document
302
+ * @param section - Optional keyword; only matching headings (with sub-sections) are returned
303
+ * @param offset - Character offset for paginated reads
304
+ */
305
+ async getMigrationGuide(version, document = 'auto', section, offset = 0, project = 'spring-boot') {
306
+ if (!isMigrationProject(project)) {
307
+ throw new Error(`Unknown project "${project}" for migration guides`);
308
+ }
309
+ // Resolve version and document before any cache or network access
310
+ const normalizedVersion = normalizeVersion(version);
311
+ if (!normalizedVersion) {
312
+ throw new Error(`A target ${projectDisplayName(project)} version is required (e.g. "3.0", "3.4" or "4.0")`);
313
+ }
314
+ const resolved = project === 'spring-boot'
315
+ ? resolveWikiDocument(normalizedVersion, document)
316
+ : resolveRawDocument(project, document);
317
+ const keyword = section?.trim() || undefined;
318
+ const cacheKey = project === 'spring-boot'
319
+ ? `migration:${resolved}:${normalizedVersion}`
320
+ : `migration:${project}:${resolved}:${normalizedVersion}`;
321
+ let entry = this.cache.get(cacheKey);
322
+ if (entry) {
323
+ console.error(`✅ Cache hit for migration page: ${cacheKey}`);
324
+ this.reindexIfMissing(cacheKey, entry.title, entry.url, entry.markdown);
325
+ }
326
+ else {
327
+ entry = project === 'spring-boot'
328
+ ? await this.fetchBootWikiPage(normalizedVersion, resolved)
329
+ : await this.fetchRawWikiPage(project, normalizedVersion);
330
+ // Only successful, verified pages are cached and indexed (whole page, not just the requested section)
331
+ this.indexPage(cacheKey, entry.title, entry.url, entry.markdown);
332
+ this.cache.setLongTerm(cacheKey, entry);
333
+ }
334
+ const markdown = keyword ? selectSections(entry.markdown, keyword, entry.title) : entry.markdown;
335
+ const title = keyword ? `${entry.title} (section: ${keyword})` : entry.title;
336
+ return this.formatPage(title, markdown, entry.url, offset, 'For the complete page, visit', Boolean(keyword));
337
+ }
338
+ async fetchBootWikiPage(version, document) {
339
+ const url = wikiPageUrl(wikiPageName(version, document));
340
+ console.error(`🔍 Fetching migration page: ${url}`);
341
+ const response = await this.fetchWithRetry(url);
342
+ if (!response.ok) {
343
+ throw new Error(`Failed to fetch Spring Boot wiki page: ${response.status}`);
344
+ }
345
+ const html = await response.text();
346
+ const { markdown, title } = extractWikiMarkdown(html, expectedWikiTitle(version, document), url);
347
+ return { markdown, url, title };
348
+ }
349
+ /** Raw markdown wiki page (Framework, Batch): a missing page is a real 404, an HTML body is never a guide. */
350
+ async fetchRawWikiPage(project, version) {
351
+ const url = rawWikiUrl(project, version);
352
+ const title = rawWikiTitle(project, version);
353
+ console.error(`🔍 Fetching migration page: ${url}`);
354
+ const response = await this.fetchWithRetry(url);
355
+ if (response.status === 404) {
356
+ throw new Error(`Wiki page not found: ${title} (${url})`);
357
+ }
358
+ if (!response.ok) {
359
+ throw new Error(`Failed to fetch ${title}: ${response.status}`);
360
+ }
361
+ const markdown = assertRawMarkdown(await response.text(), title, url);
362
+ return { markdown, url, title };
363
+ }
364
+ referenceTitle(projectId, section, subsection) {
365
+ const project = this.projectsConfig.getProject(projectId);
366
+ return `${project.displayName} Reference: ${subsection ? `${section}/${subsection}` : section}`;
367
+ }
368
+ /**
369
+ * Format one page of a full markdown document with a pagination footer
370
+ */
371
+ formatPage(title, markdown, url, offset, linkLabel, filtered = false) {
372
+ const page = pageMarkdown(markdown, offset);
373
+ if (offset >= page.total) {
374
+ return `No content at offset ${offset} (total: ${page.total} characters).`;
375
+ }
376
+ let result = `# ${title}\n\n${page.content}`;
377
+ if (page.nextOffset !== null) {
378
+ const note = filtered ? ' The offset applies to the filtered text (the selected section), not to the full page.' : '';
379
+ result += `\n\n---\nPart ${page.start}–${page.end} of ${page.total} characters. To continue, call again with offset=${page.nextOffset}.${note}`;
380
+ }
381
+ return `${result}\n\n${linkLabel}: ${url}`;
382
+ }
328
383
  /**
329
384
  * Search Spring concepts - alias for backward compatibility
330
385
  */
331
- async searchConcepts(concept, category) {
332
- return this.searchSpringConcepts(concept, category);
386
+ async searchConcepts(concept, version) {
387
+ return this.searchSpringConcepts(concept, version);
388
+ }
389
+ /**
390
+ * Entry page of the Spring Boot reference documentation: the current docs (legacy URL, unchanged)
391
+ * or the page of a published version ("3.4" or "3.4.2"; the patch is ignored).
392
+ */
393
+ bootReferenceIndexUrl(normalizedVersion) {
394
+ return normalizedVersion
395
+ ? `${this.baseUrl}/spring-boot/${normalizedVersion}/reference/index.html`
396
+ : `${this.baseUrl}/spring-boot/docs/current/reference/html/`;
397
+ }
398
+ bootVersionNotFound(normalizedVersion) {
399
+ return new Error(`Spring Boot documentation for version ${normalizedVersion} not found (this version may not be published at the current documentation site; omit 'version' for the latest or try a more recent one)`);
333
400
  }
334
401
  /**
335
402
  * Search Spring concepts - using real documentation
403
+ *
404
+ * @param version - Optional Spring Boot documentation version ('3.4' or '3.4.2'; omitted/'current' = latest)
336
405
  */
337
- async searchSpringConcepts(concept, category) {
338
- const cacheKey = `concepts:${concept}:${category || 'all'}`;
406
+ async searchSpringConcepts(concept, version) {
407
+ const normalizedVersion = normalizeVersion(version);
408
+ const cacheKey = normalizedVersion ? `concepts:${concept}:v${normalizedVersion}` : `concepts:${concept}`;
339
409
  const cached = this.cache.get(cacheKey);
340
410
  if (cached)
341
411
  return cached;
342
412
  try {
343
413
  // Search in Spring Boot reference documentation
344
- const searchUrl = `${this.baseUrl}/spring-boot/docs/current/reference/html/`;
414
+ const searchUrl = this.bootReferenceIndexUrl(normalizedVersion);
345
415
  const response = await this.fetchWithRetry(searchUrl);
346
416
  if (!response.ok) {
417
+ if (normalizedVersion)
418
+ throw this.bootVersionNotFound(normalizedVersion);
347
419
  throw new Error('Unable to access Spring Boot documentation');
348
420
  }
349
421
  const html = await response.text();
@@ -359,7 +431,7 @@ export class SpringBootDocsServiceOptimized {
359
431
  const headingText = heading.text().toLowerCase();
360
432
  if (headingText.includes(concept.toLowerCase())) {
361
433
  const section = heading.parent();
362
- const sectionMarkdown = this.turndownService.turndown(section.html() || '');
434
+ const sectionMarkdown = turndownService.turndown(section.html() || '');
363
435
  conceptContent += sectionMarkdown.substring(0, 500) + '\n\n';
364
436
  foundSections++;
365
437
  }
@@ -375,53 +447,110 @@ export class SpringBootDocsServiceOptimized {
375
447
  }
376
448
  catch (error) {
377
449
  console.error('Error searching concepts:', error);
378
- return `# Spring Concept Search Error\n\nUnable to search for concept "${concept}": ${error instanceof Error ? error.message : 'Unknown error'}`;
450
+ throw error;
379
451
  }
380
452
  }
381
453
  /**
382
454
  * Search documentation with real API - alias for backward compatibility
383
455
  */
384
- async searchDocumentation(query, docType = 'all', limit = 10) {
385
- return this.searchSpringDocs(query, docType, limit);
456
+ async searchDocumentation(query, docType = 'all', limit = 10, version) {
457
+ return this.searchSpringDocs(query, docType, limit, version);
386
458
  }
387
459
  /**
388
460
  * Search documentation with real API
461
+ *
462
+ * @param version - Optional Spring Boot documentation version for the Boot reference part
463
+ * ('3.4' or '3.4.2'; omitted/'current' = latest). Guides and projects are not versioned and ignore it.
389
464
  */
390
- async searchSpringDocs(query, docType = 'all', limit = 10) {
391
- const cacheKey = `docs:${query}:${docType}:${limit}`;
392
- const cached = this.cache.get(cacheKey);
393
- if (cached)
394
- return cached;
395
- const results = [];
465
+ async searchSpringDocs(query, docType = 'all', limit = 10, version) {
466
+ const allowedDocTypes = ['guides', 'reference', 'projects', 'content', 'all'];
467
+ if (!allowedDocTypes.includes(docType)) {
468
+ throw new Error(`Invalid docType "${docType}". Allowed: ${allowedDocTypes.join(', ')}`);
469
+ }
470
+ const normalizedVersion = normalizeVersion(version);
471
+ const baseKey = `docs:${query}:${docType}:${limit}`;
472
+ const cacheKey = normalizedVersion ? `${baseKey}:v${normalizedVersion}` : baseKey;
473
+ // Only the title sources are cached: content hits depend on the current state of the index
474
+ const titles = this.cache.get(cacheKey) ?? await this.searchTitleSources(query, docType, limit, cacheKey, normalizedVersion);
475
+ if (docType !== 'all' && docType !== 'content') {
476
+ return titles.slice(0, limit);
477
+ }
478
+ return this.mergeContentResults(titles, query, limit, normalizedVersion);
479
+ }
480
+ mergeContentResults(titles, query, limit, normalizedVersion) {
481
+ const seen = new Set(titles.map(result => result.url));
482
+ let content = [];
396
483
  try {
397
- if (docType === 'all' || docType === 'guides') {
398
- const guides = await this.getAllSpringGuides(undefined, limit);
399
- const filteredGuides = guides.filter(guide => guide.title.toLowerCase().includes(query.toLowerCase()) ||
400
- guide.description.toLowerCase().includes(query.toLowerCase()));
401
- results.push(...filteredGuides);
402
- }
403
- if (docType === 'all' || docType === 'projects') {
404
- const projects = await this.searchSpringProjects(query, limit);
405
- results.push(...projects);
484
+ // With a version, Boot reference pages of other versions are left out (each page is indexed
485
+ // under its own versioned docId); guides, projects and other projects' pages are not versioned
486
+ const sameVersion = (docId) => !normalizedVersion || !docId.startsWith('reference:boot:') || docId.startsWith(`reference:boot:${normalizedVersion}:`);
487
+ content = this.searchIndex.search(query, limit, sameVersion)
488
+ .filter(hit => !seen.has(hit.url))
489
+ .map(hit => ({ type: 'content', title: hit.title, url: hit.url, description: hit.snippet, score: hit.score }));
490
+ }
491
+ catch (error) {
492
+ console.error('Content search failed:', error instanceof Error ? error.message : error);
493
+ }
494
+ // Content takes the slots titles leave free, and at most half of them otherwise
495
+ const contentSlots = Math.min(content.length, Math.max(Math.floor(limit / 2), limit - titles.length));
496
+ const merged = [...titles.slice(0, limit - contentSlots), ...content.slice(0, contentSlots)];
497
+ if (this.searchIndex.size === 0) {
498
+ merged.push({
499
+ type: 'note',
500
+ title: 'Content index is empty',
501
+ description: 'Full-text search covers pages already read: read a page first (get_spring_project, get_spring_reference or get_spring_guide), then search again.'
502
+ });
503
+ }
504
+ return merged;
505
+ }
506
+ async searchTitleSources(query, docType, limit, cacheKey, normalizedVersion) {
507
+ const results = [];
508
+ const sources = [];
509
+ if (docType === 'all' || docType === 'guides') {
510
+ sources.push(['guides', async () => {
511
+ const guides = await this.getAllSpringGuides(undefined, Number.MAX_SAFE_INTEGER);
512
+ return guides.filter(guide => guide.title.toLowerCase().includes(query.toLowerCase()) ||
513
+ guide.description.toLowerCase().includes(query.toLowerCase())).slice(0, limit);
514
+ }]);
515
+ }
516
+ if (docType === 'all' || docType === 'projects') {
517
+ sources.push(['projects', () => this.searchSpringProjects(query, limit)]);
518
+ }
519
+ if (docType === 'all' || docType === 'reference') {
520
+ sources.push(['reference', () => this.searchInReference(query, limit, normalizedVersion)]);
521
+ }
522
+ // A failing source must not be hidden nor cached: fail if all fail, otherwise return partial results uncached
523
+ const failures = [];
524
+ const settled = await Promise.allSettled(sources.map(([, run]) => run()));
525
+ settled.forEach((outcome, index) => {
526
+ if (outcome.status === 'fulfilled') {
527
+ results.push(...outcome.value);
406
528
  }
407
- if (docType === 'all' || docType === 'reference') {
408
- // Search in reference documentation
409
- const refResults = await this.searchInReference(query, limit);
410
- results.push(...refResults);
529
+ else {
530
+ failures.push(sources[index][0]);
531
+ console.error(`Documentation source "${sources[index][0]}" failed:`, outcome.reason instanceof Error ? outcome.reason.message : outcome.reason);
411
532
  }
412
- this.cache.set(cacheKey, results);
413
- return results.slice(0, limit);
533
+ });
534
+ if (sources.length > 0 && failures.length === sources.length) {
535
+ const reasons = settled
536
+ .map(outcome => (outcome.status === 'rejected' ? (outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason)) : ''))
537
+ .filter(Boolean);
538
+ throw new Error(`Unable to search documentation: all sources failed (${failures.join(', ')}): ${reasons.join('; ')}`);
414
539
  }
415
- catch (error) {
416
- console.error('Error searching documentation:', error);
417
- return [];
540
+ if (failures.length === 0) {
541
+ this.cache.set(cacheKey, results);
418
542
  }
543
+ return results.slice(0, limit);
419
544
  }
420
- async searchInReference(query, limit) {
545
+ async searchInReference(query, limit, normalizedVersion) {
421
546
  try {
422
- const response = await this.fetchWithRetry(`${this.baseUrl}/spring-boot/docs/current/reference/html/`);
423
- if (!response.ok)
424
- return [];
547
+ const pageUrl = this.bootReferenceIndexUrl(normalizedVersion);
548
+ const response = await this.fetchWithRetry(pageUrl);
549
+ if (!response.ok) {
550
+ if (normalizedVersion)
551
+ throw this.bootVersionNotFound(normalizedVersion);
552
+ throw new Error('Unable to access Spring Boot reference documentation');
553
+ }
425
554
  const html = await response.text();
426
555
  const $ = cheerio.load(html);
427
556
  const results = [];
@@ -433,7 +562,9 @@ export class SpringBootDocsServiceOptimized {
433
562
  results.push({
434
563
  type: 'reference',
435
564
  title: `Spring Boot: ${text}`,
436
- url: href.startsWith('http') ? href : `${this.baseUrl}/spring-boot/docs/current/reference/html/${href}`,
565
+ url: normalizedVersion
566
+ ? new URL(href, pageUrl).href // nav hrefs of the versioned site are relative to the page
567
+ : href.startsWith('http') ? href : `${this.baseUrl}/spring-boot/docs/current/reference/html/${href}`,
437
568
  description: `Reference documentation section`
438
569
  });
439
570
  }
@@ -442,55 +573,42 @@ export class SpringBootDocsServiceOptimized {
442
573
  }
443
574
  catch (error) {
444
575
  console.error('Error searching reference:', error);
445
- return [];
576
+ throw error;
446
577
  }
447
578
  }
448
- processHtmlGuide(content, guideId, sourceUrl, detailLevel = 'medium') {
449
- console.log(`Processing HTML content with detail level: ${detailLevel}...`);
579
+ extractGuideMarkdown(content, sourceUrl) {
580
+ console.error('Processing HTML guide content...');
450
581
  const $ = cheerio.load(content);
451
582
  // Remove navigation and footer elements
452
583
  $('nav, footer, .navbar, .sidebar, #js-sidebar').remove();
453
584
  // Get main content
454
- const mainContent = $('.content, .guide-content, main, .markdown-body, .guide-body, article').first();
585
+ // Selectors are tried in priority order: a single comma list would pick the first match in
586
+ // document order (on spring.io an unrelated <article> card comes before the guide body)
587
+ const selectors = ['.ascii-doc', '.content', '.guide-content', 'main', '.markdown-body', '.guide-body', 'article'];
588
+ const mainContent = selectors.map(selector => $(selector).first()).find(match => match.length > 0) ?? $();
455
589
  let markdown;
456
590
  if (mainContent.length === 0) {
457
- console.log('No main content found, using body');
591
+ console.error('No main content found, using body');
458
592
  $('script, style').remove();
459
- markdown = this.turndownService.turndown($('body').html() || '');
593
+ markdown = turndownService.turndown($('body').html() || '');
460
594
  }
461
595
  else {
462
- markdown = this.turndownService.turndown(mainContent.html() || '');
596
+ markdown = turndownService.turndown(mainContent.html() || '');
597
+ }
598
+ const trimmed = markdown.trim();
599
+ // A near-empty page or a JSON excerpt (client-rendered page data) is not a guide
600
+ if (trimmed.length < MIN_GUIDE_CHARS || /^[{[]\s*"/.test(trimmed)) {
601
+ throw new Error(`No content could be extracted from the guide page: ${sourceUrl}`);
463
602
  }
603
+ return markdown;
604
+ }
605
+ formatGuide(markdown, guideId, sourceUrl, detailLevel) {
464
606
  // Use intelligent extraction
465
- const extractedContent = this.extractIntelligentContent(markdown, detailLevel);
466
- const needsTruncation = markdown.length > extractedContent.length;
467
- return `# Spring Guide: ${guideId}\n\n**Source:** ${sourceUrl}\n**Detail Level:** ${detailLevel}\n\n${extractedContent}${needsTruncation ? '\n\n---\n*Content truncated for brevity. Use detail_level="full" for complete guide or visit the link above.*' : ''}`;
607
+ const { content: extractedContent, truncated } = extractContent(markdown, detailLevel);
608
+ return `# Spring Guide: ${guideId}\n\n**Source:** ${sourceUrl}\n**Detail Level:** ${detailLevel}\n\n${extractedContent}${truncated ? (detailLevel === 'full' ? '\n\n---\n*Content truncated at 50,000 characters even in full mode. Visit the link above for the complete guide.*' : '\n\n---\n*Content truncated for brevity. Use detail_level="full" for complete guide or visit the link above.*') : ''}`;
468
609
  }
469
- async fetchWithRetry(url, timeout = this.REQUEST_TIMEOUT, retries = this.MAX_RETRIES) {
470
- for (let attempt = 1; attempt <= retries; attempt++) {
471
- const controller = new AbortController();
472
- const timeoutId = setTimeout(() => controller.abort(), timeout);
473
- try {
474
- const response = await fetch(url, {
475
- signal: controller.signal,
476
- headers: {
477
- 'User-Agent': 'Spring-Docs-MCP/1.2.4',
478
- 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
479
- 'Accept-Language': 'en-US,en;q=0.9'
480
- }
481
- });
482
- clearTimeout(timeoutId);
483
- return response;
484
- }
485
- catch (error) {
486
- clearTimeout(timeoutId);
487
- if (attempt === retries)
488
- throw error;
489
- console.error(`Retry ${attempt}/${retries} for ${url}:`, error instanceof Error ? error.message : 'Unknown error');
490
- await new Promise(resolve => setTimeout(resolve, 1000 * attempt)); // Exponential backoff
491
- }
492
- }
493
- throw new Error('All retry attempts failed');
610
+ fetchWithRetry(url, timeout, retries) {
611
+ return fetchWithRetry(url, timeout, retries);
494
612
  }
495
613
  }
496
614
  //# sourceMappingURL=springboot-docs-optimized.js.map