@@ -53,9 +53,14 @@ export async function registerDocSearchTool(server: McpServer): Promise<void> {
53
53
. describe (
54
54
'A concise and specific search query for the Angular documentation (e.g., "NgModule" or "standalone components").' ,
55
55
) ,
56
+ includeTopContent : z
57
+ . boolean ( )
58
+ . optional ( )
59
+ . default ( true )
60
+ . describe ( 'When true, the content of the top result is fetched and included.' ) ,
56
61
} ,
57
62
} ,
58
- async ( { query } ) => {
63
+ async ( { query, includeTopContent } ) => {
59
64
if ( ! client ) {
60
65
const dcip = createDecipheriv (
61
66
'aes-256-gcm' ,
@@ -71,40 +76,100 @@ export async function registerDocSearchTool(server: McpServer): Promise<void> {
71
76
72
77
const { results } = await client . search ( createSearchArguments ( query ) ) ;
73
78
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
+ } ;
94
90
}
95
91
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 } ;
104
132
} ,
105
133
) ;
106
134
}
107
135
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
+
108
173
/**
109
174
* Creates the search arguments for an Algolia search.
110
175
*
0 commit comments