@@ -53,9 +53,14 @@ export async function registerDocSearchTool(server: McpServer): Promise<void> {
5353 . describe (
5454 'A concise and specific search query for the Angular documentation (e.g., "NgModule" or "standalone components").' ,
5555 ) ,
56+ includeTopContent : z
57+ . boolean ( )
58+ . optional ( )
59+ . default ( true )
60+ . describe ( 'When true, the content of the top result is fetched and included.' ) ,
5661 } ,
5762 } ,
58- async ( { query } ) => {
63+ async ( { query, includeTopContent } ) => {
5964 if ( ! client ) {
6065 const dcip = createDecipheriv (
6166 'aes-256-gcm' ,
@@ -71,40 +76,100 @@ export async function registerDocSearchTool(server: McpServer): Promise<void> {
7176
7277 const { results } = await client . search ( createSearchArguments ( query ) ) ;
7378
74- // Convert results into text content entries instead of stringifying the entire object
75- const content = results . flatMap ( ( result ) =>
76- ( result as SearchResponse ) . hits . map ( ( hit ) => {
77- // eslint-disable-next-line @typescript-eslint/no-explicit-any
78- const hierarchy = Object . values ( hit . hierarchy as any ) . filter (
79- ( x ) => typeof x === 'string' ,
80- ) ;
81- const title = hierarchy . pop ( ) ;
82- const description = hierarchy . join ( ' > ' ) ;
83-
84- return {
85- type : 'text' as const ,
86- text : `## ${ title } \n${ description } \nURL: ${ hit . url } ` ,
87- } ;
88- } ) ,
89- ) ;
90-
91- // Return the search results if any are found
92- if ( content . length > 0 ) {
93- return { content } ;
79+ const allHits = results . flatMap ( ( result ) => ( result as SearchResponse ) . hits ) ;
80+
81+ if ( allHits . length === 0 ) {
82+ return {
83+ content : [
84+ {
85+ type : 'text' as const ,
86+ text : 'No results found.' ,
87+ } ,
88+ ] ,
89+ } ;
9490 }
9591
96- return {
97- content : [
98- {
99- type : 'text' as const ,
100- text : 'No results found.' ,
101- } ,
102- ] ,
103- } ;
92+ const content = [ ] ;
93+ // The first hit is the top search result
94+ const topHit = allHits [ 0 ] ;
95+
96+ // Process top hit first
97+ let topText = formatHitToText ( topHit ) ;
98+
99+ try {
100+ if ( includeTopContent && typeof topHit . url === 'string' ) {
101+ const url = new URL ( topHit . url ) ;
102+
103+ // Only fetch content from angular.dev
104+ if ( url . hostname === 'angular.dev' || url . hostname . endsWith ( '.angular.dev' ) ) {
105+ const response = await fetch ( url ) ;
106+ if ( response . ok ) {
107+ const html = await response . text ( ) ;
108+ const mainContent = extractBodyContent ( html ) ;
109+ if ( mainContent ) {
110+ topText += `\n\n--- DOCUMENTATION CONTENT ---\n${ mainContent } ` ;
111+ }
112+ }
113+ }
114+ }
115+ } catch {
116+ // Ignore errors fetching content. The basic info is still returned.
117+ }
118+ content . push ( {
119+ type : 'text' as const ,
120+ text : topText ,
121+ } ) ;
122+
123+ // Process remaining hits
124+ for ( const hit of allHits . slice ( 1 ) ) {
125+ content . push ( {
126+ type : 'text' as const ,
127+ text : formatHitToText ( hit ) ,
128+ } ) ;
129+ }
130+
131+ return { content } ;
104132 } ,
105133 ) ;
106134}
107135
136+ /**
137+ * Extracts the content of the `<body>` element from an HTML string.
138+ *
139+ * @param html The HTML content of a page.
140+ * @returns The content of the `<body>` element, or `undefined` if not found.
141+ */
142+ function extractBodyContent ( html : string ) : string | undefined {
143+ // TODO: Use '<main>' element instead of '<body>' when available in angular.dev HTML.
144+ const mainTagStart = html . indexOf ( '<body' ) ;
145+ if ( mainTagStart === - 1 ) {
146+ return undefined ;
147+ }
148+
149+ const mainTagEnd = html . lastIndexOf ( '</body>' ) ;
150+ if ( mainTagEnd <= mainTagStart ) {
151+ return undefined ;
152+ }
153+
154+ // Add 7 to include '</body>'
155+ return html . substring ( mainTagStart , mainTagEnd + 7 ) ;
156+ }
157+
158+ /**
159+ * Formats an Algolia search hit into a text representation.
160+ *
161+ * @param hit The Algolia search hit object, which should contain `hierarchy` and `url` properties.
162+ * @returns A formatted string with title, description, and URL.
163+ */
164+ function formatHitToText ( hit : Record < string , unknown > ) : string {
165+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
166+ const hierarchy = Object . values ( hit . hierarchy as any ) . filter ( ( x ) => typeof x === 'string' ) ;
167+ const title = hierarchy . pop ( ) ;
168+ const description = hierarchy . join ( ' > ' ) ;
169+
170+ return `## ${ title } \n${ description } \nURL: ${ hit . url } ` ;
171+ }
172+
108173/**
109174 * Creates the search arguments for an Algolia search.
110175 *
0 commit comments