Coverage for repo_ctx / mcp_server.py: 0%

106 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-11-25 17:42 +0100

1"""MCP server implementation.""" 

2import json 

3from mcp.server import Server 

4from mcp.server.stdio import stdio_server 

5from mcp.types import Tool, TextContent 

6from .core import GitLabContext 

7from .config import Config 

8 

9 

10async def serve( 

11 config_path: str = None, 

12 gitlab_url: str = None, 

13 gitlab_token: str = None, 

14 github_url: str = None, 

15 github_token: str = None, 

16 storage_path: str = None 

17): 

18 """Run MCP server. 

19 

20 Args: 

21 config_path: Optional path to config file 

22 gitlab_url: Optional GitLab URL (overrides config file) 

23 gitlab_token: Optional GitLab token (overrides config file) 

24 github_url: Optional GitHub URL (overrides config file) 

25 github_token: Optional GitHub token (overrides config file) 

26 storage_path: Optional storage path (overrides config file) 

27 """ 

28 # Load config with priority: CLI args > env vars > config files 

29 try: 

30 config = Config.load( 

31 config_path=config_path, 

32 gitlab_url=gitlab_url, 

33 gitlab_token=gitlab_token, 

34 github_url=github_url, 

35 github_token=github_token, 

36 storage_path=storage_path 

37 ) 

38 except ValueError as e: 

39 print(f"Configuration error: {e}") 

40 raise 

41 

42 # Initialize core 

43 context = GitLabContext(config) 

44 await context.init() 

45 

46 # Create server 

47 server = Server("repo-ctx") 

48 

49 @server.list_tools() 

50 async def list_tools() -> list[Tool]: 

51 return [ 

52 Tool( 

53 name="gitlab-search-libraries", 

54 description="Search for GitLab libraries/projects by name. Returns matching libraries with their IDs and available versions.", 

55 inputSchema={ 

56 "type": "object", 

57 "properties": { 

58 "libraryName": { 

59 "type": "string", 

60 "description": "Library name to search for" 

61 } 

62 }, 

63 "required": ["libraryName"] 

64 } 

65 ), 

66 Tool( 

67 name="gitlab-fuzzy-search", 

68 description="Fuzzy search for GitLab repositories. Returns top matches even with typos or partial names. Use this when you don't know the exact repository path.", 

69 inputSchema={ 

70 "type": "object", 

71 "properties": { 

72 "query": { 

73 "type": "string", 

74 "description": "Search term (can be partial or fuzzy)" 

75 }, 

76 "limit": { 

77 "type": "integer", 

78 "description": "Maximum results to return (default: 10)", 

79 "default": 10 

80 } 

81 }, 

82 "required": ["query"] 

83 } 

84 ), 

85 Tool( 

86 name="gitlab-index-repository", 

87 description="Index a repository from GitLab or GitHub to make its documentation searchable. Use format: group/project for GitLab, owner/repo for GitHub", 

88 inputSchema={ 

89 "type": "object", 

90 "properties": { 

91 "repository": { 

92 "type": "string", 

93 "description": "Repository path (e.g., 'group/repo' or 'owner/repo')" 

94 }, 

95 "provider": { 

96 "type": "string", 

97 "description": "Provider to use: 'gitlab', 'github', or 'auto' for auto-detection (default: auto)", 

98 "enum": ["gitlab", "github", "auto"], 

99 "default": "auto" 

100 } 

101 }, 

102 "required": ["repository"] 

103 } 

104 ), 

105 Tool( 

106 name="gitlab-index-group", 

107 description="Index all repositories in a GitLab group or GitHub organization. Optionally include subgroups (GitLab only).", 

108 inputSchema={ 

109 "type": "object", 

110 "properties": { 

111 "group": { 

112 "type": "string", 

113 "description": "Group/organization path (e.g., 'groupname' or 'orgname')" 

114 }, 

115 "includeSubgroups": { 

116 "type": "boolean", 

117 "description": "Include subgroups - only works with GitLab (default: true)", 

118 "default": True 

119 }, 

120 "provider": { 

121 "type": "string", 

122 "description": "Provider to use: 'gitlab', 'github', or 'auto' (default: auto)", 

123 "enum": ["gitlab", "github", "auto"], 

124 "default": "auto" 

125 } 

126 }, 

127 "required": ["group"] 

128 } 

129 ), 

130 Tool( 

131 name="gitlab-get-docs", 

132 description="Retrieve documentation for a specific GitLab library. Supports topic filtering and pagination.", 

133 inputSchema={ 

134 "type": "object", 

135 "properties": { 

136 "libraryId": { 

137 "type": "string", 

138 "description": "Library ID in format /group/project or /group/project/version" 

139 }, 

140 "topic": { 

141 "type": "string", 

142 "description": "Optional topic to filter documentation" 

143 }, 

144 "page": { 

145 "type": "integer", 

146 "description": "Page number for pagination (default: 1)", 

147 "default": 1 

148 } 

149 }, 

150 "required": ["libraryId"] 

151 } 

152 ) 

153 ] 

154 

155 @server.call_tool() 

156 async def call_tool(name: str, arguments: dict) -> list[TextContent]: 

157 if name == "gitlab-search-libraries": 

158 library_name = arguments["libraryName"] 

159 results = await context.search_libraries(library_name) 

160 

161 output = [] 

162 output.append("Available Libraries (search results):\n\n") 

163 

164 for result in results: 

165 output.append(f"- Library ID: {result.library_id}\n") 

166 output.append(f" Name: {result.name}\n") 

167 output.append(f" Description: {result.description}\n") 

168 output.append(f" Versions: {', '.join(result.versions)}\n") 

169 output.append("\n") 

170 

171 if not results: 

172 output.append(f"No libraries found matching '{library_name}'.\n") 

173 output.append("Make sure the repository has been indexed first.\n") 

174 

175 return [TextContent(type="text", text="".join(output))] 

176 

177 elif name == "gitlab-fuzzy-search": 

178 query = arguments["query"] 

179 limit = arguments.get("limit", 10) 

180 results = await context.fuzzy_search_libraries(query, limit) 

181 

182 output = [] 

183 output.append(f"Fuzzy search results for '{query}':\n\n") 

184 

185 for i, result in enumerate(results, 1): 

186 output.append(f"{i}. {result.library_id}\n") 

187 output.append(f" Name: {result.name}\n") 

188 output.append(f" Group: {result.group}\n") 

189 output.append(f" Description: {result.description}\n") 

190 output.append(f" Match: {result.match_type} in {result.matched_field} (score: {result.score:.2f})\n") 

191 output.append("\n") 

192 

193 if not results: 

194 output.append(f"No repositories found matching '{query}'.\n") 

195 output.append("Try a different search term or index repositories first using gitlab-index-repository or gitlab-index-group.\n") 

196 else: 

197 output.append(f"\nTo get documentation, use gitlab-get-docs with one of the Library IDs above.\n") 

198 

199 return [TextContent(type="text", text="".join(output))] 

200 

201 elif name == "gitlab-index-repository": 

202 repository = arguments["repository"] 

203 provider = arguments.get("provider", "auto") 

204 parts = repository.split("/") 

205 if len(parts) < 2: 

206 return [TextContent(type="text", text=f"Error: Repository must be in format group/project or owner/repo")] 

207 

208 project = parts[-1] 

209 group = "/".join(parts[:-1]) 

210 

211 # Convert "auto" to None for auto-detection 

212 provider_type = None if provider == "auto" else provider 

213 

214 try: 

215 await context.index_repository(group, project, provider_type=provider_type) 

216 provider_used = provider_type or "auto-detected" 

217 return [TextContent(type="text", text=f"Successfully indexed {repository} using {provider_used} provider. You can now search for it using gitlab-fuzzy-search or gitlab-get-docs.")] 

218 except Exception as e: 

219 return [TextContent(type="text", text=f"Error indexing {repository}: {str(e)}")] 

220 

221 elif name == "gitlab-index-group": 

222 group = arguments["group"] 

223 include_subgroups = arguments.get("includeSubgroups", True) 

224 provider = arguments.get("provider", "auto") 

225 

226 # Convert "auto" to None for auto-detection 

227 provider_type = None if provider == "auto" else provider 

228 

229 try: 

230 results = await context.index_group(group, include_subgroups, provider_type=provider_type) 

231 output = [] 

232 provider_used = provider_type or "auto-detected" 

233 output.append(f"Indexed group '{group}' using {provider_used} provider:\n\n") 

234 output.append(f"Total projects: {results['total']}\n") 

235 output.append(f"Successfully indexed: {len(results['indexed'])}\n") 

236 output.append(f"Failed: {len(results['failed'])}\n\n") 

237 

238 if results['indexed']: 

239 output.append("Indexed repositories:\n") 

240 for repo in results['indexed'][:10]: # Show first 10 

241 output.append(f" - {repo}\n") 

242 if len(results['indexed']) > 10: 

243 output.append(f" ... and {len(results['indexed']) - 10} more\n") 

244 

245 if results['failed']: 

246 output.append("\nFailed repositories:\n") 

247 for fail in results['failed'][:5]: # Show first 5 failures 

248 output.append(f" - {fail['path']}: {fail['error']}\n") 

249 

250 return [TextContent(type="text", text="".join(output))] 

251 except Exception as e: 

252 return [TextContent(type="text", text=f"Error indexing group {group}: {str(e)}")] 

253 

254 elif name == "gitlab-get-docs": 

255 library_id = arguments["libraryId"] 

256 topic = arguments.get("topic") 

257 page = arguments.get("page", 1) 

258 

259 try: 

260 result = await context.get_documentation(library_id, topic, page) 

261 return [TextContent(type="text", text=result["content"][0]["text"])] 

262 except Exception as e: 

263 return [TextContent(type="text", text=f"Error: {str(e)}")] 

264 

265 else: 

266 return [TextContent(type="text", text=f"Unknown tool: {name}")] 

267 

268 # Run server with stdio transport 

269 async with stdio_server() as (read_stream, write_stream): 

270 await server.run(read_stream, write_stream, server.create_initialization_options())