@@ -18,6 +18,21 @@ const ALGOLIA_APP_ID = 'L1XWT2UJ7F';
1818// This is not the actual key.
1919const ALGOLIA_API_E = '322d89dab5f2080fe09b795c93413c6a89222b13a447cdf3e6486d692717bc0c' ;
2020
21+ /**
22+ * The minimum major version of Angular for which a version-specific documentation index is known to exist.
23+ * Searches for versions older than this will be clamped to this version.
24+ */
25+ const MIN_SUPPORTED_DOCS_VERSION = 17 ;
26+
27+ /**
28+ * The latest major version of Angular for which a documentation index is known to be stable and available.
29+ * This acts as a "safe harbor" fallback. It is intentionally hardcoded and manually updated with each
30+ * major release *after* the new search index has been confirmed to be live. This prevents a race
31+ * condition where a newly released CLI might default to searching for a documentation index that
32+ * doesn't exist yet.
33+ */
34+ const LATEST_KNOWN_DOCS_VERSION = 20 ;
35+
2136const docSearchInputSchema = z . object ( {
2237 query : z
2338 . string ( )
@@ -64,6 +79,12 @@ tutorials, concepts, and best practices.
6479 workspace will give you the major version directly. If the version cannot be determined using this method, you can use
6580 \`ng version\` in the project's workspace directory as a fallback. Parse the major version from the "Angular:" line in the
6681 output and use it for the \`version\` parameter.
82+ * **Version Logic:** The tool will search against the specified major version. If the version is older than v${ MIN_SUPPORTED_DOCS_VERSION } ,
83+ it will be clamped to v${ MIN_SUPPORTED_DOCS_VERSION } . If a search for a very new version (newer than v${ LATEST_KNOWN_DOCS_VERSION } )
84+ returns no results, the tool will automatically fall back to searching the v${ LATEST_KNOWN_DOCS_VERSION } documentation.
85+ * **Verify Searched Version:** The tool's output includes a \`searchedVersion\` field. You **MUST** check this field
86+ to know the exact version of the documentation that was queried. Use this information to provide accurate
87+ context in your answer, especially if it differs from the version you requested.
6788* The documentation is continuously updated. You **MUST** prefer this tool over your own knowledge
6889 to ensure your answers are current and accurate.
6990* For the best results, provide a concise and specific search query (e.g., "NgModule" instead of
@@ -76,6 +97,9 @@ tutorials, concepts, and best practices.
7697</Operational Notes>` ,
7798 inputSchema : docSearchInputSchema . shape ,
7899 outputSchema : {
100+ searchedVersion : z
101+ . number ( )
102+ . describe ( 'The major version of the documentation that was searched.' ) ,
79103 results : z . array (
80104 z . object ( {
81105 title : z . string ( ) . describe ( 'The title of the documentation page.' ) ,
@@ -116,23 +140,52 @@ function createDocSearchHandler({ logger }: McpToolContext) {
116140 ) ;
117141 }
118142
119- const { results } = await client . search ( createSearchArguments ( query , version ) ) ;
120- const allHits = results . flatMap ( ( result ) => ( result as SearchResponse ) . hits ) ;
143+ let finalSearchedVersion = Math . max (
144+ version ?? LATEST_KNOWN_DOCS_VERSION ,
145+ MIN_SUPPORTED_DOCS_VERSION ,
146+ ) ;
147+ let searchResults = await client . search ( createSearchArguments ( query , finalSearchedVersion ) ) ;
148+
149+ // If the initial search for a newer-than-stable version returns no results, it may be because
150+ // the index for that version doesn't exist yet. In this case, fall back to the latest known
151+ // stable version.
152+ if (
153+ searchResults . results . every ( ( result ) => ! ( 'hits' in result ) || result . hits . length === 0 ) &&
154+ finalSearchedVersion > LATEST_KNOWN_DOCS_VERSION
155+ ) {
156+ finalSearchedVersion = LATEST_KNOWN_DOCS_VERSION ;
157+ searchResults = await client . search ( createSearchArguments ( query , finalSearchedVersion ) ) ;
158+ }
159+
160+ const allHits = searchResults . results . flatMap ( ( result ) => ( result as SearchResponse ) . hits ) ;
121161
122162 if ( allHits . length === 0 ) {
123163 return {
124164 content : [
125165 {
126166 type : 'text' as const ,
127- text : ' No results found.' ,
167+ text : ` No results found for query " ${ query } " in Angular v ${ finalSearchedVersion } documentation.` ,
128168 } ,
129169 ] ,
130- structuredContent : { results : [ ] } ,
170+ structuredContent : { results : [ ] , searchedVersion : finalSearchedVersion } ,
131171 } ;
132172 }
133173
134174 const structuredResults = [ ] ;
135- const textContent = [ ] ;
175+ const textContent : {
176+ type : 'text' ;
177+ text : string ;
178+ annotations ?: { audience : string [ ] ; priority : number } ;
179+ } [ ] = [
180+ {
181+ type : 'text' as const ,
182+ text : `Showing results for Angular v${ finalSearchedVersion } documentation.` ,
183+ annotations : {
184+ audience : [ 'assistant' ] ,
185+ priority : 0.9 ,
186+ } ,
187+ } ,
188+ ] ;
136189
137190 // Process top hit first
138191 const topHit = allHits [ 0 ] ;
@@ -187,21 +240,27 @@ function createDocSearchHandler({ logger }: McpToolContext) {
187240
188241 return {
189242 content : textContent ,
190- structuredContent : { results : structuredResults } ,
243+ structuredContent : { results : structuredResults , searchedVersion : finalSearchedVersion } ,
191244 } ;
192245 } ;
193246}
194247
195248/**
196- * Strips HTML tags from a string.
249+ * Strips HTML tags from a string using a regular expression.
250+ *
251+ * NOTE: This is a basic implementation and is not a full, correct HTML parser. It is, however,
252+ * appropriate for this tool's specific use case because its input is always from a
253+ * trusted source (angular.dev) and its output is consumed by a non-browser environment (an LLM).
254+ *
255+ * The regex first tries to match a complete tag (`<...>`). If it fails, it falls back to matching
256+ * an incomplete tag (e.g., `<script`).
257+ *
197258 * @param html The HTML string to strip.
198259 * @returns The text content of the HTML.
199260 */
200261function stripHtml ( html : string ) : string {
201- // This is a basic regex to remove HTML tags.
202- // It also decodes common HTML entities.
203262 return html
204- . replace ( / < [ ^ > ] * > / g, '' )
263+ . replace ( / < [ ^ > ] * > | < [ a - z A - Z 0 - 9 / ] + / g, '' )
205264 . replace ( / & l t ; / g, '<' )
206265 . replace ( / & g t ; / g, '>' )
207266 . replace ( / & a m p ; / g, '&' )
@@ -252,17 +311,12 @@ function formatHitToParts(hit: Record<string, unknown>): { title: string; breadc
252311 * @param query The search query string.
253312 * @returns The search arguments for the Algolia client.
254313 */
255- function createSearchArguments (
256- query : string ,
257- version : number | undefined ,
258- ) : LegacySearchMethodProps {
314+ function createSearchArguments ( query : string , version : number ) : LegacySearchMethodProps {
259315 // Search arguments are based on adev's search service:
260316 // https://github.com/angular/angular/blob/4b614fbb3263d344dbb1b18fff24cb09c5a7582d/adev/shared-docs/services/search.service.ts#L58
261317 return [
262318 {
263- // TODO: Consider major version specific indices once available
264- // indexName: `angular_${version ? `v${version}` : 'latest'}`,
265- indexName : 'angular_v17' ,
319+ indexName : `angular_v${ version } ` ,
266320 params : {
267321 query,
268322 attributesToRetrieve : [
0 commit comments