Coverage for oracle / oci_fsdr_mcp_server / server.py: 96.18%

137 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-08-03 16:45 +0000

1""" 

2Copyright (c) 2025, Oracle and/or its affiliates. 

3Licensed under the Universal Permissive License v1.0 as shown at 

4https://oss.oracle.com/licenses/upl. 

5 

6OCI Full Stack Disaster Recovery MCP Server 

7 

8Exposes OCI FSDR operations as MCP tools for MCP-aware clients. 

9 

10The server is intentionally schema-agnostic: read operations are thin wrappers 

11over paginated list/get APIs, and all write operations go through a single 

12generic passthrough (`fsdr_raw_call`). Polymorphic SDK models (members, 

13execution options, log locations) are resolved at call time via a `_type` 

14discriminator in the payload, so new member types or plan execution types 

15never require server changes. 

16 

17Environment variables: 

18 OCI_AUTH_TYPE : "api_key" (default) or "security_token" 

19 OCI_CONFIG_FILE : path to OCI config file (default: ~/.oci/config) 

20 FSDR_PROFILE_1 : profile for region 1 (default: FSDR_REGION1) 

21 FSDR_PROFILE_2 : profile for region 2 (default: FSDR_REGION2) 

22""" 

23 

24from __future__ import annotations 

25 

26import logging 

27import sys 

28import uuid 

29from functools import wraps 

30from pathlib import Path 

31from typing import Any, Callable, Dict, Optional 

32 

33import oci 

34from oci.util import to_dict 

35from fastmcp import FastMCP 

36from pydantic import Field 

37 

38from .auth import get_dr_client 

39from .consts import ALLOWED_FSDR_OPERATIONS, DEFAULT_PROFILE_1, DEFAULT_PROFILE_2 

40from .models import ( 

41 DrPlanExecutionLifecycleState, 

42 DrPlanType, 

43 DrProtectionGroupLifecycleState, 

44 FsdrOperation, 

45 ListResult, 

46 OciResponseResult, 

47 WriteResult, 

48) 

49 

50# --------------------------------------------------------------------------- 

51# Logging (stderr only -- never stdout, which would corrupt the MCP stream) 

52# --------------------------------------------------------------------------- 

53 

54logging.basicConfig( 

55 stream=sys.stderr, 

56 level=logging.INFO, 

57 format="%(asctime)s [%(levelname)s] %(name)s %(message)s", 

58) 

59log = logging.getLogger("oci_fsdr_mcp") 

60 

61# --------------------------------------------------------------------------- 

62# MCP server 

63# --------------------------------------------------------------------------- 

64 

65_PROMPTS_DIR = Path(__file__).parent / "data" / "prompts" 

66_PROFILE_DESCRIPTION = ( 

67 "OCI config profile for the target FSDR region. " 

68 f"Defaults to '{DEFAULT_PROFILE_1}'. Common profiles are " 

69 f"'{DEFAULT_PROFILE_1}' for region 1 and '{DEFAULT_PROFILE_2}' for region 2." 

70) 

71 

72mcp = FastMCP( 

73 name="oci-fsdr-mcp-server", 

74 instructions=( 

75 "Tools for OCI Full Stack Disaster Recovery (FSDR). " 

76 f"Two region profiles are available: '{DEFAULT_PROFILE_1}' (region 1) and " 

77 f"'{DEFAULT_PROFILE_2}' (region 2). " 

78 "All tools accept a 'profile' parameter to target the desired region -- " 

79 f"defaults to '{DEFAULT_PROFILE_1}' when omitted. " 

80 "Read tools (list_*/get_*) return data directly. " 

81 "All mutations go through `fsdr_raw_call`, which invokes any " 

82 "DisasterRecoveryClient method by name. For polymorphic payloads " 

83 "(DRPG members, execution options, log locations), include a `_type` " 

84 "key naming the exact SDK model class -- e.g. " 

85 "`_type`: 'UpdateDrProtectionGroupMemberDatabaseDetails'. " 

86 "Use the built-in prompts for guided workflows: " 

87 "check_dr_status, setup_drpg_pair, run_switchover, run_drill, " 

88 "run_failover, plan_refresh_workflow, add_members." 

89 ), 

90) 

91 

92# --------------------------------------------------------------------------- 

93# Request tracking decorator 

94# --------------------------------------------------------------------------- 

95 

96 

97def _tool_logger(func: Callable) -> Callable: 

98 """Log each tool call with a short request ID for correlation.""" 

99 @wraps(func) 

100 def wrapper(*args: Any, **kwargs: Any) -> Any: 

101 req_id = uuid.uuid4().hex[:8] 

102 log_context: Dict[str, Any] = { 

103 "operation": kwargs.get("operation"), 

104 "profile": kwargs.get("profile"), 

105 "kwargs": sorted(kwargs), 

106 } 

107 if isinstance(kwargs.get("parameters"), dict): 

108 log_context["parameter_keys"] = sorted(kwargs["parameters"]) 

109 log.info("[%s] %s called context=%s", req_id, func.__name__, log_context) 

110 try: 

111 result = func(*args, **kwargs) 

112 log.info("[%s] %s succeeded", req_id, func.__name__) 

113 return result 

114 except Exception as exc: 

115 log.error("[%s] %s failed: %s", req_id, func.__name__, exc) 

116 raise 

117 return wrapper 

118 

119# --------------------------------------------------------------------------- 

120# Helpers 

121# --------------------------------------------------------------------------- 

122 

123 

124def _response_to_result(response: oci.response.Response) -> OciResponseResult: 

125 data = response.data 

126 return OciResponseResult( 

127 data=to_dict(data) if data is not None else None, 

128 status=response.status, 

129 headers=dict(response.headers or {}), 

130 ) 

131 

132 

133def _resolve_model(value: Any) -> Any: 

134 """Recursively turn dicts carrying `_type` into OCI SDK model instances. 

135 

136 A dict with a `_type: "SomeClassName"` entry is instantiated as 

137 `oci.disaster_recovery.models.SomeClassName(**rest)`. Dicts without 

138 `_type` and all other values are returned unchanged (but still recursed 

139 into). This lets callers describe polymorphic payloads -- e.g. DRPG 

140 members, execution options -- without the server needing to know any 

141 specific type. 

142 """ 

143 if isinstance(value, dict): 

144 if "_type" in value: 

145 rest = {k: v for k, v in value.items() if k != "_type"} 

146 type_name = value["_type"] 

147 model_cls = getattr(oci.disaster_recovery.models, type_name, None) 

148 if model_cls is None: 148 ↛ 149line 148 didn't jump to line 149 because the condition on line 148 was never true

149 raise ValueError( 

150 f"Unknown oci.disaster_recovery.models class: '{type_name}'" 

151 ) 

152 return model_cls(**{k: _resolve_model(v) for k, v in rest.items()}) 

153 return {k: _resolve_model(v) for k, v in value.items()} 

154 if isinstance(value, list): 

155 return [_resolve_model(v) for v in value] 

156 return value 

157 

158# --------------------------------------------------------------------------- 

159# Guided workflow prompts 

160# --------------------------------------------------------------------------- 

161 

162 

163def _load_prompt(filename: str) -> str: 

164 return (_PROMPTS_DIR / filename).read_text(encoding="utf-8") 

165 

166 

167@mcp.prompt() 

168def setup_drpg_pair() -> str: 

169 """Step-by-step guide to create and associate a PRIMARY/STANDBY DRPG pair across two regions.""" 

170 return _load_prompt("setup_drpg_pair.md") 

171 

172 

173@mcp.prompt() 

174def check_dr_status() -> str: 

175 """Read-only guide to inspect DRPG state, plan health, execution history, and work requests.""" 

176 return _load_prompt("check_dr_status.md") 

177 

178 

179@mcp.prompt() 

180def run_switchover() -> str: 

181 """Guide for a planned Switchover — reverses PRIMARY/STANDBY roles; both regions must be up.""" 

182 return _load_prompt("run_switchover.md") 

183 

184 

185@mcp.prompt() 

186def run_drill() -> str: 

187 """Guide to run a DR Drill (START_DRILL then STOP_DRILL) — roles do NOT change, test only.""" 

188 return _load_prompt("run_drill.md") 

189 

190 

191@mcp.prompt() 

192def run_failover() -> str: 

193 """Guide for an emergency Failover when primary is down, including mandatory post-failover reset.""" 

194 return _load_prompt("run_failover.md") 

195 

196 

197@mcp.prompt() 

198def plan_refresh_workflow() -> str: 

199 """Guide to refresh DR plans after adding or removing DRPG members.""" 

200 return _load_prompt("plan_refresh_workflow.md") 

201 

202 

203@mcp.prompt() 

204def add_members() -> str: 

205 """Guide to add compute, database, and other resources to a DRPG.""" 

206 return _load_prompt("add_members.md") 

207 

208# --------------------------------------------------------------------------- 

209# Read tools 

210# --------------------------------------------------------------------------- 

211 

212 

213@mcp.tool() 

214@_tool_logger 

215def list_dr_protection_groups( 

216 compartment_id: str = Field( 

217 ..., description="The OCID of the compartment containing DR Protection Groups." 

218 ), 

219 lifecycle_state: Optional[DrProtectionGroupLifecycleState] = Field( 

220 None, description="Optional DR Protection Group lifecycle state filter." 

221 ), 

222 limit: int = Field( 

223 50, description="Maximum number of DR Protection Groups to return.", ge=1, le=1000 

224 ), 

225 page: Optional[str] = Field( 

226 None, description="Opaque opc-next-page token returned by a previous list call." 

227 ), 

228 profile: str = Field(DEFAULT_PROFILE_1, description=_PROFILE_DESCRIPTION), 

229) -> ListResult: 

230 """ 

231 List DR Protection Groups (DRPGs) in a compartment. 

232 

233 Args: 

234 compartment_id: OCID of the compartment. 

235 lifecycle_state: Optional filter e.g. "ACTIVE". If None, all states returned. 

236 limit: Max items per page (default 50). 

237 page: opc-next-page token for pagination. 

238 profile: OCI config profile for the target region. Defaults to FSDR_REGION1. 

239 

240 Returns: 

241 { "items": [...], "opc_next_page": "...", "total_items": N } 

242 """ 

243 resp = get_dr_client(profile).list_dr_protection_groups( 

244 compartment_id=compartment_id, 

245 lifecycle_state=lifecycle_state, 

246 limit=limit, 

247 page=page, 

248 ) 

249 items = [to_dict(pg) for pg in resp.data.items] 

250 return ListResult.from_items(items, resp.headers.get("opc-next-page")) 

251 

252 

253@mcp.tool() 

254@_tool_logger 

255def list_dr_plans_for_protection_group( 

256 dr_protection_group_id: str = Field( 

257 ..., description="The OCID of the DR Protection Group that owns the plans." 

258 ), 

259 display_name: Optional[str] = Field( 

260 None, description="Optional display name filter for DR Plans." 

261 ), 

262 plan_type: Optional[DrPlanType] = Field( 

263 None, 

264 description="Optional DR Plan type filter, such as SWITCHOVER or FAILOVER.", 

265 ), 

266 limit: int = Field( 

267 50, description="Maximum number of DR Plans to return.", ge=1, le=1000 

268 ), 

269 page: Optional[str] = Field( 

270 None, description="Opaque opc-next-page token returned by a previous list call." 

271 ), 

272 profile: str = Field(DEFAULT_PROFILE_1, description=_PROFILE_DESCRIPTION), 

273) -> ListResult: 

274 """ 

275 List DR Plans for a DR Protection Group. 

276 

277 Args: 

278 dr_protection_group_id: OCID of the DR Protection Group. 

279 display_name: Optional filter by display name. 

280 plan_type: Optional filter -- "SWITCHOVER", "FAILOVER", "START_DRILL", "STOP_DRILL". 

281 limit: Max items per page (default 50). 

282 page: opc-next-page token for pagination. 

283 profile: OCI config profile for the target region. 

284 

285 Returns: 

286 { "items": [...], "opc_next_page": "...", "total_items": N } 

287 """ 

288 resp = get_dr_client(profile).list_dr_plans( 

289 dr_protection_group_id=dr_protection_group_id, 

290 display_name=display_name, 

291 dr_plan_type=plan_type, 

292 limit=limit, 

293 page=page, 

294 ) 

295 items = [to_dict(p) for p in resp.data.items] 

296 return ListResult.from_items(items, resp.headers.get("opc-next-page")) 

297 

298 

299@mcp.tool() 

300@_tool_logger 

301def list_dr_plan_executions_for_protection_group( 

302 dr_protection_group_id: str = Field( 

303 ..., 

304 description="The OCID of the DR Protection Group that owns the plan executions.", 

305 ), 

306 lifecycle_state: Optional[DrPlanExecutionLifecycleState] = Field( 

307 None, description="Optional DR Plan Execution lifecycle state filter." 

308 ), 

309 limit: int = Field( 

310 50, description="Maximum number of DR Plan Executions to return.", ge=1, le=1000 

311 ), 

312 page: Optional[str] = Field( 

313 None, description="Opaque opc-next-page token returned by a previous list call." 

314 ), 

315 profile: str = Field(DEFAULT_PROFILE_1, description=_PROFILE_DESCRIPTION), 

316) -> ListResult: 

317 """ 

318 List DR Plan Executions for a DR Protection Group. 

319 

320 Args: 

321 dr_protection_group_id: OCID of the DR Protection Group. 

322 lifecycle_state: Optional lifecycle state filter. 

323 limit: Max items per page (default 50). 

324 page: opc-next-page token for pagination. 

325 profile: OCI config profile for the target region. 

326 

327 Returns: 

328 { "items": [...], "opc_next_page": "...", "total_items": N } 

329 """ 

330 resp = get_dr_client(profile).list_dr_plan_executions( 

331 dr_protection_group_id=dr_protection_group_id, 

332 lifecycle_state=lifecycle_state, 

333 limit=limit, 

334 page=page, 

335 ) 

336 items = [to_dict(e) for e in resp.data.items] 

337 return ListResult.from_items(items, resp.headers.get("opc-next-page")) 

338 

339 

340@mcp.tool() 

341@_tool_logger 

342def get_dr_protection_group( 

343 dr_protection_group_id: str = Field( 

344 ..., description="The OCID of the DR Protection Group to retrieve." 

345 ), 

346 profile: str = Field(DEFAULT_PROFILE_1, description=_PROFILE_DESCRIPTION), 

347) -> OciResponseResult: 

348 """ 

349 Get details of a DR Protection Group. 

350 

351 Args: 

352 dr_protection_group_id: OCID of the DR Protection Group. 

353 profile: OCI config profile for the target region. 

354 """ 

355 resp = get_dr_client(profile).get_dr_protection_group(dr_protection_group_id) 

356 return _response_to_result(resp) 

357 

358 

359@mcp.tool() 

360@_tool_logger 

361def get_dr_plan( 

362 dr_plan_id: str = Field(..., description="The OCID of the DR Plan to retrieve."), 

363 profile: str = Field(DEFAULT_PROFILE_1, description=_PROFILE_DESCRIPTION), 

364) -> OciResponseResult: 

365 """ 

366 Get details of a DR Plan. 

367 

368 Args: 

369 dr_plan_id: OCID of the DR Plan. 

370 profile: OCI config profile for the target region. 

371 """ 

372 resp = get_dr_client(profile).get_dr_plan(dr_plan_id) 

373 return _response_to_result(resp) 

374 

375 

376@mcp.tool() 

377@_tool_logger 

378def get_dr_plan_execution( 

379 dr_plan_execution_id: str = Field( 

380 ..., description="The OCID of the DR Plan Execution to retrieve." 

381 ), 

382 profile: str = Field(DEFAULT_PROFILE_1, description=_PROFILE_DESCRIPTION), 

383) -> OciResponseResult: 

384 """ 

385 Get details of a DR Plan Execution. 

386 

387 Args: 

388 dr_plan_execution_id: OCID of the DR Plan Execution. 

389 profile: OCI config profile for the target region. 

390 """ 

391 resp = get_dr_client(profile).get_dr_plan_execution(dr_plan_execution_id) 

392 return _response_to_result(resp) 

393 

394 

395@mcp.tool() 

396@_tool_logger 

397def get_work_request( 

398 work_request_id: str = Field( 

399 ..., description="The OCID of the OCI work request to retrieve." 

400 ), 

401 profile: str = Field(DEFAULT_PROFILE_1, description=_PROFILE_DESCRIPTION), 

402) -> OciResponseResult: 

403 """ 

404 Get details of a Work Request (tracks async operations). 

405 

406 Args: 

407 work_request_id: OCID of the Work Request. 

408 profile: OCI config profile for the target region. 

409 """ 

410 resp = get_dr_client(profile).get_work_request(work_request_id) 

411 return _response_to_result(resp) 

412 

413@mcp.tool() 

414@_tool_logger 

415def fsdr_raw_call( 

416 operation: FsdrOperation = Field( 

417 ..., 

418 description=( 

419 "Allowed DisasterRecoveryClient method name to invoke. Use read tools " 

420 "first, then call this for approved FSDR changes." 

421 ), 

422 ), 

423 parameters: Dict[str, Any] = Field( 

424 ..., 

425 description=( 

426 "Keyword arguments for the OCI SDK operation. Use *_details dicts for " 

427 "request bodies and `_type` for polymorphic nested OCI SDK models." 

428 ), 

429 ), 

430 profile: str = Field(DEFAULT_PROFILE_1, description=_PROFILE_DESCRIPTION), 

431) -> WriteResult: 

432 """ 

433 Invoke any OCI Full Stack Disaster Recovery API by DisasterRecoveryClient 

434 method name. This is the single entry point for all mutations -- CREATE, 

435 UPDATE, DELETE, associate, execute, refresh, cancel, etc. 

436 

437 WARNING: Live, potentially irreversible operations. The caller is 

438 responsible for previewing parameters with the user before invoking 

439 write operations -- this tool does not implement a dry-run mode. 

440 

441 Parameter construction rules: 

442 

443 1. Any `*_details` parameter whose value is a dict is converted to the 

444 matching SDK model class by naming convention -- e.g. 

445 `create_dr_protection_group_details` -> CreateDrProtectionGroupDetails. 

446 

447 2. For polymorphic nested payloads (DRPG members, execution options, 

448 log locations, etc.) include a `_type` key naming the exact SDK 

449 model class. The tool recurses into dicts and lists and instantiates 

450 any dict with a `_type` key as that class. Examples: 

451 

452 "members": [ 

453 {"_type": "UpdateDrProtectionGroupMemberDatabaseDetails", 

454 "member_id": "ocid1.database..."}, 

455 {"_type": "UpdateDrProtectionGroupMemberComputeInstanceMovableDetails", 

456 "member_id": "ocid1.instance..."} 

457 ] 

458 

459 "execution_options": { 

460 "_type": "SwitchoverExecutionOptionDetails", 

461 "are_prechecks_enabled": true, 

462 "are_warnings_ignored": false 

463 } 

464 

465 "log_location": { 

466 "_type": "CreateObjectStorageLogLocationDetails", 

467 "bucket": "dr-logs", 

468 "namespace": "mytenancy" 

469 } 

470 

471 Refer to the `oci.disaster_recovery.models` module for the full set 

472 of available class names. 

473 

474 Args: 

475 operation: DisasterRecoveryClient method name e.g. 

476 "create_dr_protection_group", "update_dr_protection_group", 

477 "associate_dr_protection_group", "create_dr_plan_execution". 

478 parameters: Keyword arguments for the SDK method. 

479 profile: OCI config profile for the target region. Defaults to FSDR_REGION1. 

480 

481 Returns: 

482 { 

483 "data": ..., 

484 "status": 200, 

485 "headers": {...}, 

486 "work_request_id": "..." # present when the response carries one 

487 } 

488 """ 

489 if operation not in ALLOWED_FSDR_OPERATIONS: 

490 raise ValueError( 

491 f"Unsupported operation '{operation}'. " 

492 f"Allowed: {', '.join(sorted(ALLOWED_FSDR_OPERATIONS))}" 

493 ) 

494 

495 client = get_dr_client(profile) 

496 func = getattr(client, operation) 

497 

498 converted: Dict[str, Any] = {} 

499 for key, value in parameters.items(): 

500 resolved = _resolve_model(value) 

501 if key.endswith("_details") and isinstance(resolved, dict): 

502 parts = key.split("_")[:-1] 

503 class_name = "".join(p.capitalize() for p in parts) + "Details" 

504 model_cls = getattr(oci.disaster_recovery.models, class_name, None) 

505 if model_cls is not None: 505 ↛ 507line 505 didn't jump to line 507 because the condition on line 505 was always true

506 resolved = model_cls(**resolved) 

507 converted[key] = resolved 

508 

509 resp = func(**converted) 

510 result = _response_to_result(resp) 

511 work_request_id = resp.headers.get("opc-work-request-id") if resp.headers else None 

512 return WriteResult( 

513 data=result.data, 

514 status=result.status, 

515 headers=result.headers, 

516 work_request_id=work_request_id, 

517 ) 

518 

519# --------------------------------------------------------------------------- 

520# Entry point 

521# --------------------------------------------------------------------------- 

522 

523 

524def main() -> None: 

525 """Entry point for uvx / installed scripts. 

526 

527 MCP stdio uses stdout for JSON-RPC messages. Application logs are 

528 configured above to use stderr so they do not corrupt the protocol stream. 

529 """ 

530 mcp.run(show_banner=False) 

531 

532 

533if __name__ == "__main__": 533 ↛ 534line 533 didn't jump to line 534 because the condition on line 533 was never true

534 main()