Coverage for src/quickmcp/registry.py: 93%

120 statements  

« prev     ^ index     » next       coverage.py v7.10.4, created at 2025-08-20 20:56 +0200

1""" 

2QuickMCP Server Registry - Register and discover QuickMCP servers 

3""" 

4 

5import json 

6import os 

7from pathlib import Path 

8from typing import Dict, List, Optional, Any 

9from dataclasses import dataclass, asdict 

10import subprocess 

11import sys 

12 

13 

14@dataclass 

15class ServerRegistration: 

16 """Registration info for a QuickMCP server.""" 

17 name: str 

18 description: str 

19 command: List[str] # Command to run the server 

20 working_dir: Optional[str] = None 

21 tool_prefix: Optional[str] = None 

22 capabilities: Optional[Dict[str, Any]] = None 

23 metadata: Optional[Dict[str, Any]] = None 

24 

25 def to_dict(self) -> Dict[str, Any]: 

26 """Convert to dictionary.""" 

27 return {k: v for k, v in asdict(self).items() if v is not None} 

28 

29 @classmethod 

30 def from_dict(cls, data: Dict[str, Any]) -> "ServerRegistration": 

31 """Create from dictionary.""" 

32 return cls(**data) 

33 

34 

35class ServerRegistry: 

36 """Registry for QuickMCP servers that can be launched via stdio.""" 

37 

38 def __init__(self, registry_path: Optional[Path] = None): 

39 """ 

40 Initialize the server registry. 

41  

42 Args: 

43 registry_path: Path to registry file (defaults to ~/.quickmcp/registry.json) 

44 """ 

45 if registry_path is None: 

46 home = Path.home() 

47 registry_dir = home / ".quickmcp" 

48 registry_dir.mkdir(parents=True, exist_ok=True) 

49 registry_path = registry_dir / "registry.json" 

50 

51 self.registry_path = Path(registry_path) 

52 self.servers: Dict[str, ServerRegistration] = {} 

53 self.load() 

54 

55 def load(self) -> None: 

56 """Load the registry from disk.""" 

57 if self.registry_path.exists(): 

58 try: 

59 with open(self.registry_path, 'r') as f: 

60 data = json.load(f) 

61 self.servers = { 

62 name: ServerRegistration.from_dict(info) 

63 for name, info in data.items() 

64 } 

65 except Exception as e: 

66 print(f"Warning: Could not load registry: {e}", file=sys.stderr) 

67 self.servers = {} 

68 else: 

69 self.servers = {} 

70 

71 def save(self) -> None: 

72 """Save the registry to disk.""" 

73 try: 

74 with open(self.registry_path, 'w') as f: 

75 data = { 

76 name: server.to_dict() 

77 for name, server in self.servers.items() 

78 } 

79 json.dump(data, f, indent=2) 

80 except Exception as e: 

81 print(f"Warning: Could not save registry: {e}", file=sys.stderr) 

82 

83 def register(self, server: ServerRegistration) -> None: 

84 """ 

85 Register a server. 

86  

87 Args: 

88 server: Server registration info 

89 """ 

90 self.servers[server.name] = server 

91 self.save() 

92 

93 def unregister(self, name: str) -> bool: 

94 """ 

95 Unregister a server. 

96  

97 Args: 

98 name: Server name 

99  

100 Returns: 

101 True if server was unregistered, False if not found 

102 """ 

103 if name in self.servers: 

104 del self.servers[name] 

105 self.save() 

106 return True 

107 return False 

108 

109 def get(self, name: str) -> Optional[ServerRegistration]: 

110 """ 

111 Get a server registration. 

112  

113 Args: 

114 name: Server name 

115  

116 Returns: 

117 Server registration or None if not found 

118 """ 

119 return self.servers.get(name) 

120 

121 def list(self) -> List[ServerRegistration]: 

122 """ 

123 List all registered servers. 

124  

125 Returns: 

126 List of server registrations 

127 """ 

128 return list(self.servers.values()) 

129 

130 def to_gleitzeit_config(self) -> Dict[str, Any]: 

131 """ 

132 Convert registry to Gleitzeit MCP configuration format. 

133  

134 Returns: 

135 Configuration dictionary for Gleitzeit 

136 """ 

137 servers = [] 

138 for server in self.servers.values(): 

139 config = { 

140 "name": server.name, 

141 "connection_type": "stdio", 

142 "command": server.command, 

143 "auto_start": True, 

144 } 

145 

146 if server.working_dir: 

147 config["working_dir"] = server.working_dir 

148 

149 if server.tool_prefix: 

150 config["tool_prefix"] = server.tool_prefix 

151 

152 if server.metadata: 

153 config.update(server.metadata) 

154 

155 servers.append(config) 

156 

157 return { 

158 "mcp": { 

159 "auto_discover": True, 

160 "servers": servers 

161 } 

162 } 

163 

164 def auto_discover(self, search_paths: Optional[List[Path]] = None) -> List[ServerRegistration]: 

165 """ 

166 Auto-discover QuickMCP servers in the filesystem. 

167  

168 Args: 

169 search_paths: Paths to search (defaults to current directory and common locations) 

170  

171 Returns: 

172 List of discovered servers 

173 """ 

174 if search_paths is None: 

175 search_paths = [ 

176 Path.cwd(), 

177 Path.home() / "quickmcp", 

178 Path.home() / "mcp-servers", 

179 Path("/usr/local/share/quickmcp"), 

180 ] 

181 

182 discovered = [] 

183 

184 for base_path in search_paths: 

185 if not base_path.exists(): 

186 continue 

187 

188 # Look for Python files that might be QuickMCP servers 

189 for py_file in base_path.rglob("*.py"): 

190 if self._is_quickmcp_server(py_file): 

191 # Extract server info from the file 

192 info = self._extract_server_info(py_file) 

193 if info: 

194 discovered.append(info) 

195 

196 return discovered 

197 

198 def _is_quickmcp_server(self, file_path: Path) -> bool: 

199 """Check if a Python file is a QuickMCP server.""" 

200 try: 

201 with open(file_path, 'r') as f: 

202 content = f.read(1000) # Read first 1000 chars 

203 return "QuickMCPServer" in content or "from quickmcp import" in content 

204 except: 

205 return False 

206 

207 def _extract_server_info(self, file_path: Path) -> Optional[ServerRegistration]: 

208 """Extract server information from a Python file.""" 

209 try: 

210 # Try to run the file with --info flag to get metadata 

211 result = subprocess.run( 

212 [sys.executable, str(file_path), "--info"], 

213 capture_output=True, 

214 text=True, 

215 timeout=2 

216 ) 

217 

218 if result.returncode == 0: 

219 # Parse JSON output 

220 info = json.loads(result.stdout) 

221 return ServerRegistration( 

222 name=info.get("name", file_path.stem), 

223 description=info.get("description", ""), 

224 command=[sys.executable, str(file_path)], 

225 working_dir=str(file_path.parent), 

226 capabilities=info.get("capabilities"), 

227 metadata=info.get("metadata") 

228 ) 

229 except: 

230 pass 

231 

232 # Fallback: create basic registration 

233 return ServerRegistration( 

234 name=file_path.stem, 

235 description=f"QuickMCP server: {file_path.name}", 

236 command=[sys.executable, str(file_path)], 

237 working_dir=str(file_path.parent) 

238 ) 

239 

240 

241def register_server( 

242 name: str, 

243 command: List[str], 

244 description: str = "", 

245 working_dir: Optional[str] = None, 

246 tool_prefix: Optional[str] = None, 

247 **metadata 

248) -> None: 

249 """ 

250 Convenience function to register a server. 

251  

252 Args: 

253 name: Server name 

254 command: Command to run the server 

255 description: Server description 

256 working_dir: Working directory 

257 tool_prefix: Tool prefix for Gleitzeit 

258 **metadata: Additional metadata 

259 """ 

260 registry = ServerRegistry() 

261 server = ServerRegistration( 

262 name=name, 

263 description=description, 

264 command=command, 

265 working_dir=working_dir, 

266 tool_prefix=tool_prefix, 

267 metadata=metadata if metadata else None 

268 ) 

269 registry.register(server) 

270 print(f"Registered server: {name}") 

271 

272 

273def list_servers() -> List[ServerRegistration]: 

274 """ 

275 List all registered servers. 

276  

277 Returns: 

278 List of server registrations 

279 """ 

280 registry = ServerRegistry() 

281 return registry.list() 

282 

283 

284def export_gleitzeit_config(output_path: Optional[Path] = None) -> None: 

285 """ 

286 Export registry as Gleitzeit configuration. 

287  

288 Args: 

289 output_path: Output path (defaults to stdout) 

290 """ 

291 registry = ServerRegistry() 

292 config = registry.to_gleitzeit_config() 

293 

294 import yaml 

295 yaml_str = yaml.dump(config, default_flow_style=False) 

296 

297 if output_path: 

298 with open(output_path, 'w') as f: 

299 f.write(yaml_str) 

300 print(f"Exported configuration to {output_path}") 

301 else: 

302 print(yaml_str)