Coverage for sources/librovore/server.py: 49%

90 statements  

« prev     ^ index     » next       coverage.py v7.10.4, created at 2025-08-17 23:43 +0000

1# vim: set filetype=python fileencoding=utf-8: 

2# -*- coding: utf-8 -*- 

3 

4#============================================================================# 

5# # 

6# Licensed under the Apache License, Version 2.0 (the "License"); # 

7# you may not use this file except in compliance with the License. # 

8# You may obtain a copy of the License at # 

9# # 

10# http://www.apache.org/licenses/LICENSE-2.0 # 

11# # 

12# Unless required by applicable law or agreed to in writing, software # 

13# distributed under the License is distributed on an "AS IS" BASIS, # 

14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

15# See the License for the specific language governing permissions and # 

16# limitations under the License. # 

17# # 

18#============================================================================# 

19 

20 

21''' MCP server implementation. ''' 

22 

23 

24from mcp.server.fastmcp import FastMCP as _FastMCP 

25 

26# FastMCP uses Pydantic to generate JSON schemas from function signatures. 

27from pydantic import Field as _Field 

28 

29from . import __ 

30from . import exceptions as _exceptions 

31from . import functions as _functions 

32from . import interfaces as _interfaces 

33from . import state as _state 

34 

35 

36@__.dcls.dataclass( kw_only = True, slots = True ) 

37class SearchBehaviorsMutable: 

38 ''' Mutable version of SearchBehaviors for FastMCP/Pydantic compatibility. 

39 

40 Note: Fields are manually duplicated from SearchBehaviors to avoid 

41 immutable dataclass internals leaking into JSON schema generation. 

42 ''' 

43 

44 match_mode: _interfaces.MatchMode = _interfaces.MatchMode.Fuzzy 

45 fuzzy_threshold: int = 50 

46 

47 

48FiltersMutable: __.typx.TypeAlias = dict[ str, __.typx.Any ] 

49GroupByArgument: __.typx.TypeAlias = __.typx.Annotated[ 

50 __.typx.Optional[ str ], 

51 _Field( description = __.access_doctab( 'group by argument' ) ), 

52] 

53IncludeSnippets: __.typx.TypeAlias = __.typx.Annotated[ 

54 bool, 

55 _Field( description = __.access_doctab( 'include snippets argument' ) ), 

56] 

57TermArgument: __.typx.TypeAlias = __.typx.Annotated[ 

58 str, _Field( description = __.access_doctab( 'term argument' ) ) ] 

59ResultsMax: __.typx.TypeAlias = __.typx.Annotated[ 

60 int, _Field( description = __.access_doctab( 'results max argument' ) ) ] 

61LocationArgument: __.typx.TypeAlias = __.typx.Annotated[ 

62 str, _Field( description = __.access_doctab( 'location argument' ) ) ] 

63 

64 

65_filters_default = FiltersMutable( ) 

66_search_behaviors_default = SearchBehaviorsMutable( ) 

67 

68_scribe = __.acquire_scribe( __name__ ) 

69 

70 

71 

72async def serve( 

73 auxdata: _state.Globals, /, *, 

74 port: int = 0, 

75 transport: str = 'stdio', 

76 extra_functions: bool = False, 

77) -> None: 

78 ''' Runs MCP server. ''' 

79 _scribe.debug( "Initializing FastMCP server." ) 

80 mcp = _FastMCP( 'Sphinx MCP Server', port = port ) 

81 _register_server_functions( 

82 auxdata, mcp, extra_functions = extra_functions ) 

83 match transport: 

84 case 'sse': await mcp.run_sse_async( mount_path = None ) 

85 case 'stdio': await mcp.run_stdio_async( ) 

86 case _: raise ValueError 

87 

88 

89def _exception_to_error_response( exc: Exception ) -> dict[ str, str ]: # noqa: PLR0911 

90 ''' Maps exceptions to structured error responses for MCP tools. ''' 

91 match exc: 

92 case _exceptions.ProcessorInavailability( ): 

93 return { 

94 'error_type': 'ProcessorInavailability', 

95 'message': 

96 'No processor found to handle this documentation source', 

97 'details': f"Source: {exc.source}", 

98 'suggestion': 'Verify the URL is a Sphinx documentation site' 

99 } 

100 case _exceptions.InventoryInaccessibility( ): 

101 cause = exc.__cause__ or 'Unknown' 

102 return { 

103 'error_type': 'InventoryInaccessibility', 

104 'message': 'Cannot access documentation inventory', 

105 'details': f"Source: {exc.source}, Cause: {cause}", 

106 'suggestion': 'Check URL accessibility and network connection' 

107 } 

108 case _exceptions.DocumentationContentAbsence( ): 

109 return { 

110 'error_type': 'DocumentationContentAbsence', 

111 'message': 'Documentation page structure not recognized', 

112 'details': f"URL: {exc.url}", 

113 'suggestion': 'This may be an unsupported Sphinx theme' 

114 } 

115 case _exceptions.DocumentationObjectAbsence( ): 

116 return { 

117 'error_type': 'ObjectNotFoundError', 

118 'message': 'Requested object not found in documentation page', 

119 'details': f"Object: {exc.object_id}, URL: {exc.url}", 

120 'suggestion': 'Verify the object name and try a broader search' 

121 } 

122 case _exceptions.InventoryInvalidity( ): 

123 cause = exc.__cause__ or 'Unknown' 

124 return { 

125 'error_type': 'InventoryInvalidity', 

126 'message': 'Documentation inventory has invalid format', 

127 'details': f"Source: {exc.source}, Cause: {cause}", 

128 'suggestion': 'The documentation site may be corrupted' 

129 } 

130 case _exceptions.DocumentationInaccessibility( ): 

131 cause = exc.__cause__ or 'Unknown' 

132 return { 

133 'error_type': 'DocumentationInaccessibility', 

134 'message': 'Documentation file or resource is inaccessible', 

135 'details': f"URL: {exc.url}, Cause: {cause}", 

136 'suggestion': 'Check URL accessibility and network connection' 

137 } 

138 case _: 

139 return { 

140 'error_type': 'UnknownError', 

141 'message': 'An unexpected error occurred', 

142 'details': f"Exception: {type( exc ).__name__}: {exc}", 

143 'suggestion': 'Please report this issue if it persists' 

144 } 

145 

146 

147def _produce_detect_function( auxdata: _state.Globals ): 

148 async def detect( 

149 location: LocationArgument, 

150 genus: __.typx.Annotated[ 

151 _interfaces.ProcessorGenera, 

152 _Field( description = "Processor genus (inventory or structure)" ), 

153 ], 

154 processor_name: __.typx.Annotated[ 

155 __.typx.Optional[ str ], 

156 _Field( description = "Optional processor name." ), 

157 ] = None, 

158 ) -> dict[ str, __.typx.Any ]: 

159 nomargs: __.NominativeArguments = { } 

160 if processor_name is not None: 

161 nomargs[ 'processor_name' ] = processor_name 

162 return await _functions.detect( auxdata, location, genus, **nomargs ) 

163 

164 return detect 

165 

166 

167def _produce_query_content_function( auxdata: _state.Globals ): 

168 async def query_content( # noqa: PLR0913 

169 location: LocationArgument, 

170 term: TermArgument, 

171 search_behaviors: __.typx.Annotated[ 

172 SearchBehaviorsMutable, 

173 _Field( description = "Search behavior configuration" ), 

174 ] = _search_behaviors_default, 

175 filters: __.typx.Annotated[ 

176 FiltersMutable, 

177 _Field( description = "Processor-specific filters" ), 

178 ] = _filters_default, 

179 include_snippets: IncludeSnippets = True, 

180 results_max: ResultsMax = 10, 

181 ) -> dict[ str, __.typx.Any ]: 

182 immutable_search_behaviors = ( 

183 _to_immutable_search_behaviors( search_behaviors ) ) 

184 immutable_filters = _to_immutable_filters( filters ) 

185 return await _functions.query_content( 

186 auxdata, location, term, 

187 search_behaviors = immutable_search_behaviors, 

188 filters = immutable_filters, 

189 include_snippets = include_snippets, 

190 results_max = results_max ) 

191 

192 return query_content 

193 

194 

195def _produce_query_inventory_function( auxdata: _state.Globals ): 

196 async def query_inventory( # noqa: PLR0913 

197 location: LocationArgument, 

198 term: TermArgument, 

199 search_behaviors: __.typx.Annotated[ 

200 SearchBehaviorsMutable, 

201 _Field( description = "Search behavior configuration" ), 

202 ] = _search_behaviors_default, 

203 filters: __.typx.Annotated[ 

204 FiltersMutable, 

205 _Field( description = "Processor-specific filters" ), 

206 ] = _filters_default, 

207 details: __.typx.Annotated[ 

208 _interfaces.InventoryQueryDetails, 

209 _Field( description = "Detail level for inventory results" ), 

210 ] = _interfaces.InventoryQueryDetails.Documentation, 

211 results_max: ResultsMax = 5, 

212 ) -> dict[ str, __.typx.Any ]: 

213 immutable_search_behaviors = ( 

214 _to_immutable_search_behaviors( search_behaviors ) ) 

215 immutable_filters = _to_immutable_filters( filters ) 

216 return await _functions.query_inventory( 

217 auxdata, location, term, 

218 search_behaviors = immutable_search_behaviors, 

219 filters = immutable_filters, 

220 details = details, 

221 results_max = results_max ) 

222 

223 return query_inventory 

224 

225 

226def _produce_summarize_inventory_function( auxdata: _state.Globals ): 

227 async def summarize_inventory( 

228 location: LocationArgument, 

229 search_behaviors: __.typx.Annotated[ 

230 SearchBehaviorsMutable, 

231 _Field( description = "Search behavior configuration." ), 

232 ] = _search_behaviors_default, 

233 filters: __.typx.Annotated[ 

234 FiltersMutable, 

235 _Field( description = "Processor-specific filters." ), 

236 ] = _filters_default, 

237 group_by: GroupByArgument = None, 

238 term: TermArgument = '', 

239 ) -> dict[ str, __.typx.Any ]: 

240 immutable_search_behaviors = ( 

241 _to_immutable_search_behaviors( search_behaviors ) ) 

242 immutable_filters = _to_immutable_filters( filters ) 

243 return await _functions.summarize_inventory( 

244 auxdata, location, term, 

245 search_behaviors = immutable_search_behaviors, 

246 filters = immutable_filters, 

247 group_by = group_by ) 

248 

249 return summarize_inventory 

250 

251 

252def _produce_survey_processors_function( auxdata: _state.Globals ): 

253 async def survey_processors( 

254 genus: __.typx.Annotated[ 

255 _interfaces.ProcessorGenera, 

256 _Field( description = "Processor genus (inventory or structure)" ), 

257 ], 

258 name: __.typx.Annotated[ 

259 __.typx.Optional[ str ], 

260 _Field( description = "Optional processor name to filter." ) 

261 ] = None, 

262 ) -> dict[ str, __.typx.Any ]: 

263 return await _functions.survey_processors( auxdata, genus, name ) 

264 

265 return survey_processors 

266 

267 

268def _register_server_functions( 

269 auxdata: _state.Globals, mcp: _FastMCP, /, *, extra_functions: bool = False 

270) -> None: 

271 ''' Registers MCP server tools with closures for auxdata access. ''' 

272 _scribe.debug( "Registering tools." ) 

273 mcp.tool( )( _produce_query_inventory_function( auxdata ) ) 

274 mcp.tool( )( _produce_query_content_function( auxdata ) ) 

275 mcp.tool( )( _produce_summarize_inventory_function( auxdata ) ) 

276 if extra_functions: 276 ↛ 277line 276 didn't jump to line 277 because the condition on line 276 was never true

277 mcp.tool( )( _produce_detect_function( auxdata ) ) 

278 mcp.tool( )( _produce_survey_processors_function( auxdata ) ) 

279 _scribe.debug( "All tools registered successfully." ) 

280 

281 

282def _to_immutable_filters( 

283 mutable_filters: FiltersMutable 

284) -> __.immut.Dictionary[ str, __.typx.Any ]: 

285 ''' Converts mutable filters dict to immutable dictionary. ''' 

286 return __.immut.Dictionary[ str, __.typx.Any ]( mutable_filters ) 

287 

288 

289def _to_immutable_search_behaviors( 

290 mutable_behaviors: SearchBehaviorsMutable 

291) -> _interfaces.SearchBehaviors: 

292 ''' Converts mutable search behaviors to immutable. ''' 

293 field_values = { 

294 field.name: getattr( mutable_behaviors, field.name ) 

295 for field in __.dcls.fields( mutable_behaviors ) 

296 if not field.name.startswith( '_' ) } 

297 return _interfaces.SearchBehaviors( **field_values )