Coverage for repo_ctx/mcp_server.py: 0%

1"""MCP server implementation."""

2 import json

3 from mcp.server import Server

4 from mcp.server.stdio import stdio_server

5 from mcp.types import Tool, TextContent

6 from .core import GitLabContext

7 from .config import Config

8

9

10 async 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())