@enokdev/springdocs-mcp 1.2.7 → 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 +108 -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 +184 -69
  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 +19 -9
  25. package/build/services/advanced-features.d.ts.map +1 -1
  26. package/build/services/advanced-features.js +266 -191
  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 +98 -0
  73. package/build/services/spring-projects-config.d.ts.map +1 -0
  74. package/build/services/spring-projects-config.js +397 -0
  75. package/build/services/spring-projects-config.js.map +1 -0
  76. package/build/services/springboot-docs-optimized.d.ts +72 -21
  77. package/build/services/springboot-docs-optimized.d.ts.map +1 -1
  78. package/build/services/springboot-docs-optimized.js +354 -208
  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 +393 -2
  85. package/build/tools/index.d.ts.map +1 -1
  86. package/build/tools/index.js +188 -15
  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 +9 -7
  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,87 +1,46 @@
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';
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;
5
13
  /**
6
- * Clean Spring Boot Documentation Service - uses ONLY real Spring documentation APIs
7
- * No mock data whatsoever - everything is fetched from actual Spring sources
14
+ * Spring Documentation Service - Supports multiple Spring projects (Boot, AI, Framework, etc.)
15
+ * Uses ONLY real Spring documentation APIs - no mock data
16
+ *
17
+ * Architecture: Configuration-driven multi-project support via SpringProjectsConfig
8
18
  */
9
19
  export class SpringBootDocsServiceOptimized {
10
20
  baseUrl = 'https://docs.spring.io';
11
21
  springProjectsUrl = 'https://spring.io/projects';
12
22
  springGuideUrl = 'https://spring.io/guides';
13
- springBootVersion = '3.5.6';
14
- turndownService;
23
+ projectsConfig;
15
24
  cache;
16
- REQUEST_TIMEOUT = 10000;
17
- MAX_RETRIES = 3;
18
- constructor() {
19
- this.turndownService = new TurndownService({
20
- headingStyle: 'atx',
21
- codeBlockStyle: 'fenced',
22
- });
23
- this.cache = new CacheService();
24
- // Cleanup cache every hour
25
- setInterval(() => this.cache.cleanup(), 60 * 60 * 1000);
25
+ searchIndex;
26
+ constructor(projectsConfig = springProjectsConfig, cache = new CacheService(), searchIndex = new SearchIndex()) {
27
+ this.projectsConfig = projectsConfig;
28
+ this.cache = cache;
29
+ this.searchIndex = searchIndex;
26
30
  }
27
- /**
28
- * Extract content intelligently based on detail level
29
- * Preserves code blocks, key sections, and structure
30
- */
31
- extractIntelligentContent(markdown, detailLevel = 'medium') {
32
- const limits = {
33
- 'summary': 1500,
34
- 'medium': 4000,
35
- 'full': 8000
36
- };
37
- const maxLength = limits[detailLevel] || limits['medium'];
38
- // If content is smaller than limit, return as-is
39
- if (markdown.length <= maxLength) {
40
- return markdown;
41
- }
42
- // Extract important sections
43
- const lines = markdown.split('\n');
44
- let result = '';
45
- let inCodeBlock = false;
46
- let codeBlockContent = '';
47
- let currentLength = 0;
48
- for (const line of lines) {
49
- // Track code blocks
50
- if (line.trim().startsWith('```')) {
51
- inCodeBlock = !inCodeBlock;
52
- if (!inCodeBlock && codeBlockContent) {
53
- // Keep complete code blocks
54
- const blockToAdd = codeBlockContent + line + '\n';
55
- if (currentLength + blockToAdd.length <= maxLength * 0.8) { // Reserve 20% for text
56
- result += blockToAdd;
57
- currentLength += blockToAdd.length;
58
- }
59
- codeBlockContent = '';
60
- }
61
- else {
62
- codeBlockContent = line + '\n';
63
- }
64
- continue;
65
- }
66
- if (inCodeBlock) {
67
- codeBlockContent += line + '\n';
68
- continue;
69
- }
70
- // Keep headers, important lines
71
- if (line.startsWith('#') || line.startsWith('-') || line.startsWith('*') || line.trim().startsWith('>')) {
72
- if (currentLength + line.length + 1 <= maxLength) {
73
- result += line + '\n';
74
- currentLength += line.length + 1;
75
- }
76
- }
77
- else if (line.trim() && currentLength + line.length + 1 <= maxLength) {
78
- result += line + '\n';
79
- currentLength += line.length + 1;
80
- }
81
- if (currentLength >= maxLength)
82
- 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 });
83
35
  }
84
- 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);
85
44
  }
86
45
  /**
87
46
  * Search Spring projects with caching and retry logic - REAL API ONLY
@@ -107,14 +66,14 @@ export class SpringBootDocsServiceOptimized {
107
66
  const $project = $(element);
108
67
  const title = $project.find('h2, h3, .title, .project-title').first().text().trim();
109
68
  const description = $project.find('p, .description, .summary').first().text().trim();
110
- const link = $project.find('a').first().attr('href');
111
- 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()) ||
112
71
  description.toLowerCase().includes(query.toLowerCase()))) {
113
72
  projects.push({
114
73
  type: 'spring-project',
115
74
  title: title,
116
75
  description: description,
117
- url: link?.startsWith('http') ? link : `https://spring.io${link}`,
76
+ url: url,
118
77
  });
119
78
  }
120
79
  });
@@ -124,22 +83,24 @@ export class SpringBootDocsServiceOptimized {
124
83
  }
125
84
  catch (error) {
126
85
  console.error('Error searching Spring projects:', error);
127
- return [];
86
+ throw error;
128
87
  }
129
88
  }
130
89
  /**
131
- * Get Spring project details - REAL API ONLY
90
+ * Get the full markdown of a Spring project page (cached) - REAL API ONLY
132
91
  */
133
- async getSpringProject(projectName) {
92
+ async getProjectMarkdown(projectName) {
93
+ const slug = assertSafeSegment(projectName.toLowerCase().replace(/\s+/g, '-'), 'project name');
134
94
  const cacheKey = `project:${projectName}`;
135
95
  const cached = this.cache.get(cacheKey);
136
96
  if (cached) {
137
97
  console.error(`✅ Cache hit for project: ${projectName}`);
98
+ this.reindexIfMissing(`project:${slug}`, projectName, cached.url, cached.markdown);
138
99
  return cached;
139
100
  }
140
101
  console.error(`🔍 Fetching project: ${projectName}`);
141
102
  try {
142
- const url = `${this.springProjectsUrl}/${projectName.toLowerCase().replace(/\s+/g, '-')}`;
103
+ const url = `${this.springProjectsUrl}/${slug}`;
143
104
  const response = await this.fetchWithRetry(url);
144
105
  if (!response.ok) {
145
106
  throw new Error(`Project not found: ${projectName}`);
@@ -150,17 +111,25 @@ export class SpringBootDocsServiceOptimized {
150
111
  if (content.length === 0) {
151
112
  throw new Error('No content found for project');
152
113
  }
153
- const markdown = this.turndownService.turndown(content.html() || '');
154
- const projectUrl = `${this.springProjectsUrl}/${projectName.toLowerCase().replace(/\s+/g, '-')}`;
155
- const result = `# ${projectName}\n\n${markdown.substring(0, 1500)}...\n\nFor complete project info, visit: ${projectUrl}`;
156
- this.cache.setLongTerm(cacheKey, result);
157
- 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;
158
120
  }
159
121
  catch (error) {
160
122
  console.error(`Error fetching project ${projectName}:`, error);
161
123
  throw error;
162
124
  }
163
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
+ }
164
133
  /**
165
134
  * Get all Spring guides - REAL API ONLY
166
135
  */
@@ -173,57 +142,45 @@ export class SpringBootDocsServiceOptimized {
173
142
  }
174
143
  console.error(`🔍 Fetching guides for category: ${category || 'all'}`);
175
144
  try {
176
- const response = await this.fetchWithRetry(this.springGuideUrl);
177
- if (!response.ok) {
178
- throw new Error('Unable to access Spring guides page');
179
- }
180
- const html = await response.text();
181
- const $ = cheerio.load(html);
182
- const guides = [];
183
- // Parse actual Spring guides page
184
- $('.guide-item, .card, .guide-card, .list-item, .guide').each((_, element) => {
185
- const $guide = $(element);
186
- const title = $guide.find('h2, h3, .title, .guide-title, a').first().text().trim();
187
- const description = $guide.find('p, .description, .summary').first().text().trim();
188
- const guideCategory = $guide.find('.category, .badge, .label').first().text().trim();
189
- const link = $guide.find('a').first().attr('href');
190
- if (title) {
191
- if (!category || guideCategory.toLowerCase().includes(category.toLowerCase())) {
192
- guides.push({
193
- type: 'spring-guide',
194
- title: title,
195
- description: description || 'Spring guide',
196
- category: guideCategory || 'General',
197
- url: link?.startsWith('http') ? link : `https://spring.io${link}`,
198
- });
199
- }
200
- }
201
- });
145
+ const guides = await fetchSpringGuidesList(category);
202
146
  const results = guides.slice(0, limit);
203
147
  this.cache.set(cacheKey, results);
204
148
  return results;
205
149
  }
206
150
  catch (error) {
207
151
  console.error('Error retrieving Spring guides:', error);
208
- return [];
152
+ throw error;
209
153
  }
210
154
  }
211
155
  /**
212
156
  * Get specific guide content - REAL API ONLY
213
157
  */
214
158
  async getGuide(guideId, detailLevel = 'medium') {
215
- 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}`;
216
170
  const cached = this.cache.get(cacheKey);
217
171
  if (cached) {
218
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);
219
177
  return cached;
220
178
  }
221
179
  console.error(`🔍 Fetching guide: ${guideId} with detail level: ${detailLevel}`);
222
- // Try multiple sources for guides
180
+ // Fallbacks tried in order: getting-started guide, then top-level guide path
223
181
  const sources = [
224
- { name: 'Spring.io', url: `${this.springGuideUrl}/${guideId}/` },
225
- { name: 'GitHub', url: `https://github.com/spring-guides/${guideId}` },
226
- { 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}/` }
227
184
  ];
228
185
  let content = '';
229
186
  let sourceUrl = '';
@@ -249,7 +206,11 @@ export class SpringBootDocsServiceOptimized {
249
206
  throw new Error(`Guide not found: ${guideId}`);
250
207
  }
251
208
  try {
252
- 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 });
253
214
  this.cache.setLongTerm(cacheKey, result);
254
215
  return result;
255
216
  }
@@ -259,63 +220,202 @@ export class SpringBootDocsServiceOptimized {
259
220
  }
260
221
  }
261
222
  /**
262
- * Get Spring reference documentation - alias for backward compatibility
223
+ * Get Spring reference documentation - backward compatibility (defaults to Spring Boot)
224
+ * @deprecated Use getSpringReference('boot', section) instead
263
225
  */
264
226
  async getReference(section, subsection) {
265
- return this.getSpringReference(section, subsection);
227
+ console.warn('getReference() is deprecated. Use getSpringReference("boot", section) instead');
228
+ return this.getSpringReference('boot', section, subsection);
266
229
  }
267
230
  /**
268
- * Get Spring reference documentation - REAL API ONLY
231
+ * Get Spring reference documentation for any Spring project
232
+ * Supports: Spring Boot, Spring AI, Spring Framework, and future projects
233
+ *
234
+ * @param projectId - Project identifier ('boot', 'ai', 'framework', etc.)
235
+ * @param section - Documentation section (e.g., 'web', 'chatclient', 'core')
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)
239
+ * @returns Formatted markdown documentation with source URL
269
240
  */
270
- async getSpringReference(section, subsection) {
271
- const cacheKey = `reference:${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;
272
248
  const cached = this.cache.get(cacheKey);
273
249
  if (cached) {
274
- console.error(`✅ Cache hit for reference: ${section}`);
275
- return cached;
250
+ console.error(`✅ Cache hit for reference: ${projectId}/${section}`);
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');
276
253
  }
277
- console.error(`🔍 Fetching reference: ${section}`);
254
+ console.error(`🔍 Fetching reference: ${projectId}/${section}`);
278
255
  try {
279
- const url = `${this.baseUrl}/spring-boot/docs/${this.springBootVersion}/reference/html/${section}.html`;
256
+ // Validate project exists
257
+ const project = this.projectsConfig.getProject(projectId);
258
+ // Validate section if project defines allowed sections
259
+ if (!this.projectsConfig.validateSection(projectId, section)) {
260
+ const availableSections = project.referenceSections?.join(', ') || 'any';
261
+ throw new Error(`Invalid section "${section}" for ${project.displayName}. Available sections: ${availableSections}`);
262
+ }
263
+ // Build URL using configuration
264
+ const url = this.projectsConfig.buildReferenceUrl(projectId, safeSection, safeSubsection, normalizedVersion);
280
265
  const response = await this.fetchWithRetry(url);
281
266
  if (!response.ok) {
282
- throw new Error(`Reference section not found: ${section}`);
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
+ }
270
+ throw new Error(`Reference section not found: ${project.displayName} / ${section}`);
283
271
  }
284
272
  const html = await response.text();
285
273
  const $ = cheerio.load(html);
286
- const content = $('.content, .section, main').first();
274
+ // Extract content (selector may vary by project)
275
+ const content = $('.content, .section, main, article').first();
287
276
  if (content.length === 0) {
288
- throw new Error('No content found in reference documentation');
277
+ throw new Error(`No content found in ${project.displayName} reference documentation`);
289
278
  }
290
- const markdown = this.turndownService.turndown(content.html() || '');
291
- const result = `# Spring Boot Reference: ${section}\n\n${markdown.substring(0, 1500)}...\n\nFor complete reference, visit: ${url}`;
292
- this.cache.setLongTerm(cacheKey, result);
293
- return result;
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);
282
+ // Use project-specific cache strategy
283
+ const cacheTTL = this.projectsConfig.getCacheTTL(projectId);
284
+ if (project.cacheStrategy === 'long') {
285
+ this.cache.setLongTerm(cacheKey, entry);
286
+ }
287
+ else {
288
+ this.cache.set(cacheKey, entry, cacheTTL);
289
+ }
290
+ return this.formatPage(this.referenceTitle(projectId, section, subsection), markdown, url, offset, 'For complete reference, visit');
294
291
  }
295
292
  catch (error) {
296
- console.error(`Error fetching reference ${section}:`, error);
293
+ console.error(`Error fetching reference ${projectId}/${section}:`, error);
297
294
  throw error;
298
295
  }
299
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
+ }
300
383
  /**
301
384
  * Search Spring concepts - alias for backward compatibility
302
385
  */
303
- async searchConcepts(concept, category) {
304
- 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)`);
305
400
  }
306
401
  /**
307
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)
308
405
  */
309
- async searchSpringConcepts(concept, category) {
310
- 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}`;
311
409
  const cached = this.cache.get(cacheKey);
312
410
  if (cached)
313
411
  return cached;
314
412
  try {
315
413
  // Search in Spring Boot reference documentation
316
- const searchUrl = `${this.baseUrl}/spring-boot/docs/current/reference/html/`;
414
+ const searchUrl = this.bootReferenceIndexUrl(normalizedVersion);
317
415
  const response = await this.fetchWithRetry(searchUrl);
318
416
  if (!response.ok) {
417
+ if (normalizedVersion)
418
+ throw this.bootVersionNotFound(normalizedVersion);
319
419
  throw new Error('Unable to access Spring Boot documentation');
320
420
  }
321
421
  const html = await response.text();
@@ -331,7 +431,7 @@ export class SpringBootDocsServiceOptimized {
331
431
  const headingText = heading.text().toLowerCase();
332
432
  if (headingText.includes(concept.toLowerCase())) {
333
433
  const section = heading.parent();
334
- const sectionMarkdown = this.turndownService.turndown(section.html() || '');
434
+ const sectionMarkdown = turndownService.turndown(section.html() || '');
335
435
  conceptContent += sectionMarkdown.substring(0, 500) + '\n\n';
336
436
  foundSections++;
337
437
  }
@@ -347,53 +447,110 @@ export class SpringBootDocsServiceOptimized {
347
447
  }
348
448
  catch (error) {
349
449
  console.error('Error searching concepts:', error);
350
- return `# Spring Concept Search Error\n\nUnable to search for concept "${concept}": ${error instanceof Error ? error.message : 'Unknown error'}`;
450
+ throw error;
351
451
  }
352
452
  }
353
453
  /**
354
454
  * Search documentation with real API - alias for backward compatibility
355
455
  */
356
- async searchDocumentation(query, docType = 'all', limit = 10) {
357
- return this.searchSpringDocs(query, docType, limit);
456
+ async searchDocumentation(query, docType = 'all', limit = 10, version) {
457
+ return this.searchSpringDocs(query, docType, limit, version);
358
458
  }
359
459
  /**
360
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.
361
464
  */
362
- async searchSpringDocs(query, docType = 'all', limit = 10) {
363
- const cacheKey = `docs:${query}:${docType}:${limit}`;
364
- const cached = this.cache.get(cacheKey);
365
- if (cached)
366
- return cached;
367
- 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 = [];
368
483
  try {
369
- if (docType === 'all' || docType === 'guides') {
370
- const guides = await this.getAllSpringGuides(undefined, limit);
371
- const filteredGuides = guides.filter(guide => guide.title.toLowerCase().includes(query.toLowerCase()) ||
372
- guide.description.toLowerCase().includes(query.toLowerCase()));
373
- results.push(...filteredGuides);
374
- }
375
- if (docType === 'all' || docType === 'projects') {
376
- const projects = await this.searchSpringProjects(query, limit);
377
- 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);
378
528
  }
379
- if (docType === 'all' || docType === 'reference') {
380
- // Search in reference documentation
381
- const refResults = await this.searchInReference(query, limit);
382
- 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);
383
532
  }
384
- this.cache.set(cacheKey, results);
385
- 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('; ')}`);
386
539
  }
387
- catch (error) {
388
- console.error('Error searching documentation:', error);
389
- return [];
540
+ if (failures.length === 0) {
541
+ this.cache.set(cacheKey, results);
390
542
  }
543
+ return results.slice(0, limit);
391
544
  }
392
- async searchInReference(query, limit) {
545
+ async searchInReference(query, limit, normalizedVersion) {
393
546
  try {
394
- const response = await this.fetchWithRetry(`${this.baseUrl}/spring-boot/docs/current/reference/html/`);
395
- if (!response.ok)
396
- 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
+ }
397
554
  const html = await response.text();
398
555
  const $ = cheerio.load(html);
399
556
  const results = [];
@@ -405,7 +562,9 @@ export class SpringBootDocsServiceOptimized {
405
562
  results.push({
406
563
  type: 'reference',
407
564
  title: `Spring Boot: ${text}`,
408
- 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}`,
409
568
  description: `Reference documentation section`
410
569
  });
411
570
  }
@@ -414,55 +573,42 @@ export class SpringBootDocsServiceOptimized {
414
573
  }
415
574
  catch (error) {
416
575
  console.error('Error searching reference:', error);
417
- return [];
576
+ throw error;
418
577
  }
419
578
  }
420
- processHtmlGuide(content, guideId, sourceUrl, detailLevel = 'medium') {
421
- console.log(`Processing HTML content with detail level: ${detailLevel}...`);
579
+ extractGuideMarkdown(content, sourceUrl) {
580
+ console.error('Processing HTML guide content...');
422
581
  const $ = cheerio.load(content);
423
582
  // Remove navigation and footer elements
424
583
  $('nav, footer, .navbar, .sidebar, #js-sidebar').remove();
425
584
  // Get main content
426
- 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) ?? $();
427
589
  let markdown;
428
590
  if (mainContent.length === 0) {
429
- console.log('No main content found, using body');
591
+ console.error('No main content found, using body');
430
592
  $('script, style').remove();
431
- markdown = this.turndownService.turndown($('body').html() || '');
593
+ markdown = turndownService.turndown($('body').html() || '');
432
594
  }
433
595
  else {
434
- 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}`);
435
602
  }
603
+ return markdown;
604
+ }
605
+ formatGuide(markdown, guideId, sourceUrl, detailLevel) {
436
606
  // Use intelligent extraction
437
- const extractedContent = this.extractIntelligentContent(markdown, detailLevel);
438
- const needsTruncation = markdown.length > extractedContent.length;
439
- 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.*') : ''}`;
440
609
  }
441
- async fetchWithRetry(url, timeout = this.REQUEST_TIMEOUT, retries = this.MAX_RETRIES) {
442
- for (let attempt = 1; attempt <= retries; attempt++) {
443
- const controller = new AbortController();
444
- const timeoutId = setTimeout(() => controller.abort(), timeout);
445
- try {
446
- const response = await fetch(url, {
447
- signal: controller.signal,
448
- headers: {
449
- 'User-Agent': 'Spring-Docs-MCP/1.2.4',
450
- 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
451
- 'Accept-Language': 'en-US,en;q=0.9'
452
- }
453
- });
454
- clearTimeout(timeoutId);
455
- return response;
456
- }
457
- catch (error) {
458
- clearTimeout(timeoutId);
459
- if (attempt === retries)
460
- throw error;
461
- console.error(`Retry ${attempt}/${retries} for ${url}:`, error instanceof Error ? error.message : 'Unknown error');
462
- await new Promise(resolve => setTimeout(resolve, 1000 * attempt)); // Exponential backoff
463
- }
464
- }
465
- throw new Error('All retry attempts failed');
610
+ fetchWithRetry(url, timeout, retries) {
611
+ return fetchWithRetry(url, timeout, retries);
466
612
  }
467
613
  }
468
614
  //# sourceMappingURL=springboot-docs-optimized.js.map