Coverage for mcpgateway/services/resource_service.py: 30%

302 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-22 12:53 +0100

1# -*- coding: utf-8 -*- 

2"""Resource Service Implementation. 

3 

4Copyright 2025 

5SPDX-License-Identifier: Apache-2.0 

6Authors: Mihai Criveti 

7 

8This module implements resource management according to the MCP specification. 

9It handles: 

10- Resource registration and retrieval 

11- Resource templates and URI handling 

12- Resource subscriptions and updates 

13- Content type management 

14- Active/inactive resource management 

15""" 

16 

17import asyncio 

18import logging 

19import mimetypes 

20import re 

21from datetime import datetime 

22from typing import Any, AsyncGenerator, Dict, List, Optional, Union 

23from urllib.parse import urlparse 

24 

25import parse 

26from sqlalchemy import delete, func, not_, select 

27from sqlalchemy.exc import IntegrityError 

28from sqlalchemy.orm import Session 

29 

30from mcpgateway.db import Resource as DbResource 

31from mcpgateway.db import ResourceMetric 

32from mcpgateway.db import ResourceSubscription as DbSubscription 

33from mcpgateway.db import server_resource_association 

34from mcpgateway.schemas import ( 

35 ResourceCreate, 

36 ResourceMetrics, 

37 ResourceRead, 

38 ResourceSubscription, 

39 ResourceUpdate, 

40) 

41from mcpgateway.types import ResourceContent, ResourceTemplate, TextContent 

42 

43logger = logging.getLogger(__name__) 

44 

45 

46class ResourceError(Exception): 

47 """Base class for resource-related errors.""" 

48 

49 

50class ResourceNotFoundError(ResourceError): 

51 """Raised when a requested resource is not found.""" 

52 

53 

54class ResourceURIConflictError(ResourceError): 

55 """Raised when a resource URI conflicts with existing (active or inactive) resource.""" 

56 

57 def __init__(self, uri: str, is_active: bool = True, resource_id: Optional[int] = None): 

58 """Initialize the error with resource information. 

59 

60 Args: 

61 uri: The conflicting resource URI 

62 is_active: Whether the existing resource is active 

63 resource_id: ID of the existing resource if available 

64 """ 

65 self.uri = uri 

66 self.is_active = is_active 

67 self.resource_id = resource_id 

68 message = f"Resource already exists with URI: {uri}" 

69 if not is_active: 69 ↛ 70line 69 didn't jump to line 70 because the condition on line 69 was never true

70 message += f" (currently inactive, ID: {resource_id})" 

71 super().__init__(message) 

72 

73 

74class ResourceValidationError(ResourceError): 

75 """Raised when resource validation fails.""" 

76 

77 

78class ResourceService: 

79 """Service for managing resources. 

80 

81 Handles: 

82 - Resource registration and retrieval 

83 - Resource templates and URIs 

84 - Resource subscriptions 

85 - Content type detection 

86 - Active/inactive status management 

87 """ 

88 

89 def __init__(self): 

90 """Initialize the resource service.""" 

91 self._event_subscribers: Dict[str, List[asyncio.Queue]] = {} 

92 self._template_cache: Dict[str, ResourceTemplate] = {} 

93 

94 # Initialize mime types 

95 mimetypes.init() 

96 

97 async def initialize(self) -> None: 

98 """Initialize the service.""" 

99 logger.info("Initializing resource service") 

100 

101 async def shutdown(self) -> None: 

102 """Shutdown the service.""" 

103 # Clear subscriptions 

104 self._event_subscribers.clear() 

105 logger.info("Resource service shutdown complete") 

106 

107 def _convert_resource_to_read(self, resource: DbResource) -> ResourceRead: 

108 """ 

109 Converts a DbResource instance into a ResourceRead model, including aggregated metrics. 

110 

111 Args: 

112 resource (DbResource): The ORM instance of the resource. 

113 

114 Returns: 

115 ResourceRead: The Pydantic model representing the resource, including aggregated metrics. 

116 """ 

117 resource_dict = resource.__dict__.copy() 

118 # Remove SQLAlchemy state and any pre-existing 'metrics' attribute 

119 resource_dict.pop("_sa_instance_state", None) 

120 resource_dict.pop("metrics", None) 

121 

122 # Compute aggregated metrics from the resource's metrics list. 

123 total = len(resource.metrics) if hasattr(resource, "metrics") and resource.metrics is not None else 0 

124 successful = sum(1 for m in resource.metrics if m.is_success) if total > 0 else 0 

125 failed = sum(1 for m in resource.metrics if not m.is_success) if total > 0 else 0 

126 failure_rate = (failed / total) if total > 0 else 0.0 

127 min_rt = min((m.response_time for m in resource.metrics), default=None) if total > 0 else None 

128 max_rt = max((m.response_time for m in resource.metrics), default=None) if total > 0 else None 

129 avg_rt = (sum(m.response_time for m in resource.metrics) / total) if total > 0 else None 

130 last_time = max((m.timestamp for m in resource.metrics), default=None) if total > 0 else None 

131 

132 resource_dict["metrics"] = { 

133 "total_executions": total, 

134 "successful_executions": successful, 

135 "failed_executions": failed, 

136 "failure_rate": failure_rate, 

137 "min_response_time": min_rt, 

138 "max_response_time": max_rt, 

139 "avg_response_time": avg_rt, 

140 "last_execution_time": last_time, 

141 } 

142 return ResourceRead.model_validate(resource_dict) 

143 

144 async def register_resource(self, db: Session, resource: ResourceCreate) -> ResourceRead: 

145 """Register a new resource. 

146 

147 Args: 

148 db: Database session 

149 resource: Resource creation schema 

150 

151 Returns: 

152 Created resource information 

153 

154 Raises: 

155 ResourceURIConflictError: If resource URI already exists 

156 ResourceValidationError: If resource validation fails 

157 ResourceError: For other resource registration errors 

158 """ 

159 try: 

160 # Check for URI conflicts (both active and inactive) 

161 existing_resource = db.execute(select(DbResource).where(DbResource.uri == resource.uri)).scalar_one_or_none() 

162 

163 if existing_resource: 

164 raise ResourceURIConflictError( 

165 resource.uri, 

166 is_active=existing_resource.is_active, 

167 resource_id=existing_resource.id, 

168 ) 

169 

170 # Validate URI 

171 if not self._is_valid_uri(resource.uri): 171 ↛ 172line 171 didn't jump to line 172 because the condition on line 171 was never true

172 raise ResourceValidationError(f"Invalid URI: {resource.uri}") 

173 

174 # Detect mime type if not provided 

175 mime_type = resource.mime_type 

176 if not mime_type: 176 ↛ 177line 176 didn't jump to line 177 because the condition on line 176 was never true

177 mime_type = self._detect_mime_type(resource.uri, resource.content) 

178 

179 # Determine content storage 

180 is_text = mime_type and mime_type.startswith("text/") or isinstance(resource.content, str) 

181 

182 # Create DB model 

183 db_resource = DbResource( 

184 uri=resource.uri, 

185 name=resource.name, 

186 description=resource.description, 

187 mime_type=mime_type, 

188 template=resource.template, 

189 text_content=resource.content if is_text else None, 

190 binary_content=(resource.content.encode() if is_text and isinstance(resource.content, str) else resource.content if isinstance(resource.content, bytes) else None), 

191 size=len(resource.content) if resource.content else 0, 

192 ) 

193 

194 # Add to DB 

195 db.add(db_resource) 

196 db.commit() 

197 db.refresh(db_resource) 

198 

199 # Notify subscribers 

200 await self._notify_resource_added(db_resource) 

201 

202 logger.info(f"Registered resource: {resource.uri}") 

203 return self._convert_resource_to_read(db_resource) 

204 

205 except IntegrityError: 

206 db.rollback() 

207 raise ResourceError(f"Resource already exists: {resource.uri}") 

208 except Exception as e: 

209 db.rollback() 

210 raise ResourceError(f"Failed to register resource: {str(e)}") 

211 

212 async def list_resources(self, db: Session, include_inactive: bool = False) -> List[ResourceRead]: 

213 """ 

214 Retrieve a list of registered resources from the database. 

215 

216 This method retrieves resources from the database and converts them into a list 

217 of ResourceRead objects. It supports filtering out inactive resources based on the 

218 include_inactive parameter. The cursor parameter is reserved for future pagination support 

219 but is currently not implemented. 

220 

221 Args: 

222 db (Session): The SQLAlchemy database session. 

223 include_inactive (bool): If True, include inactive resources in the result. 

224 Defaults to False. 

225 

226 Returns: 

227 List[ResourceRead]: A list of resources represented as ResourceRead objects. 

228 """ 

229 query = select(DbResource) 

230 if not include_inactive: 230 ↛ 233line 230 didn't jump to line 233 because the condition on line 230 was always true

231 query = query.where(DbResource.is_active) 

232 # Cursor-based pagination logic can be implemented here in the future. 

233 resources = db.execute(query).scalars().all() 

234 return [self._convert_resource_to_read(r) for r in resources] 

235 

236 async def list_server_resources(self, db: Session, server_id: int, include_inactive: bool = False) -> List[ResourceRead]: 

237 """ 

238 Retrieve a list of registered resources from the database. 

239 

240 This method retrieves resources from the database and converts them into a list 

241 of ResourceRead objects. It supports filtering out inactive resources based on the 

242 include_inactive parameter. The cursor parameter is reserved for future pagination support 

243 but is currently not implemented. 

244 

245 Args: 

246 db (Session): The SQLAlchemy database session. 

247 server_id (int): Server ID 

248 include_inactive (bool): If True, include inactive resources in the result. 

249 Defaults to False. 

250 

251 Returns: 

252 List[ResourceRead]: A list of resources represented as ResourceRead objects. 

253 """ 

254 query = select(DbResource).join(server_resource_association, DbResource.id == server_resource_association.c.resource_id).where(server_resource_association.c.server_id == server_id) 

255 if not include_inactive: 

256 query = query.where(DbResource.is_active) 

257 # Cursor-based pagination logic can be implemented here in the future. 

258 resources = db.execute(query).scalars().all() 

259 return [self._convert_resource_to_read(r) for r in resources] 

260 

261 async def read_resource(self, db: Session, uri: str) -> ResourceContent: 

262 """Read a resource's content. 

263 

264 Args: 

265 db: Database session 

266 uri: Resource URI to read 

267 

268 Returns: 

269 Resource content object 

270 

271 Raises: 

272 ResourceNotFoundError: If resource not found 

273 """ 

274 # Check for template 

275 if "{" in uri and "}" in uri: 275 ↛ 276line 275 didn't jump to line 276 because the condition on line 275 was never true

276 return await self._read_template_resource(uri) 

277 

278 # Find resource 

279 resource = db.execute(select(DbResource).where(DbResource.uri == uri).where(DbResource.is_active)).scalar_one_or_none() 

280 

281 if not resource: 

282 # Check if inactive resource exists 

283 inactive_resource = db.execute(select(DbResource).where(DbResource.uri == uri).where(not_(DbResource.is_active))).scalar_one_or_none() 

284 

285 if inactive_resource: 285 ↛ 286line 285 didn't jump to line 286 because the condition on line 285 was never true

286 raise ResourceNotFoundError(f"Resource '{uri}' exists but is inactive") 

287 

288 raise ResourceNotFoundError(f"Resource not found: {uri}") 

289 

290 # Return content 

291 return resource.content 

292 

293 async def toggle_resource_status(self, db: Session, resource_id: int, activate: bool) -> ResourceRead: 

294 """Toggle resource active status. 

295 

296 Args: 

297 db: Database session 

298 resource_id: Resource ID to toggle 

299 activate: True to activate, False to deactivate 

300 

301 Returns: 

302 Updated resource information 

303 

304 Raises: 

305 ResourceNotFoundError: If resource not found 

306 ResourceError: For other errors 

307 """ 

308 try: 

309 resource = db.get(DbResource, resource_id) 

310 if not resource: 

311 raise ResourceNotFoundError(f"Resource not found: {resource_id}") 

312 

313 # Update status if it's different 

314 if resource.is_active != activate: 

315 resource.is_active = activate 

316 resource.updated_at = datetime.utcnow() 

317 db.commit() 

318 db.refresh(resource) 

319 

320 # Notify subscribers 

321 if activate: 

322 await self._notify_resource_activated(resource) 

323 else: 

324 await self._notify_resource_deactivated(resource) 

325 

326 logger.info(f"Resource {resource.uri} {'activated' if activate else 'deactivated'}") 

327 

328 return self._convert_resource_to_read(resource) 

329 

330 except Exception as e: 

331 db.rollback() 

332 raise ResourceError(f"Failed to toggle resource status: {str(e)}") 

333 

334 async def subscribe_resource(self, db: Session, subscription: ResourceSubscription) -> None: 

335 """Subscribe to resource updates. 

336 

337 Args: 

338 db: Database session 

339 subscription: Subscription details 

340 

341 Raises: 

342 ResourceNotFoundError: If resource not found 

343 ResourceError: For other subscription errors 

344 """ 

345 try: 

346 # Verify resource exists 

347 resource = db.execute(select(DbResource).where(DbResource.uri == subscription.uri).where(DbResource.is_active)).scalar_one_or_none() 

348 

349 if not resource: 

350 # Check if inactive resource exists 

351 inactive_resource = db.execute(select(DbResource).where(DbResource.uri == subscription.uri).where(not_(DbResource.is_active))).scalar_one_or_none() 

352 

353 if inactive_resource: 

354 raise ResourceNotFoundError(f"Resource '{subscription.uri}' exists but is inactive") 

355 

356 raise ResourceNotFoundError(f"Resource not found: {subscription.uri}") 

357 

358 # Create subscription 

359 db_sub = DbSubscription(resource_id=resource.id, subscriber_id=subscription.subscriber_id) 

360 db.add(db_sub) 

361 db.commit() 

362 

363 logger.info(f"Added subscription for {subscription.uri} by {subscription.subscriber_id}") 

364 

365 except Exception as e: 

366 db.rollback() 

367 raise ResourceError(f"Failed to subscribe: {str(e)}") 

368 

369 async def unsubscribe_resource(self, db: Session, subscription: ResourceSubscription) -> None: 

370 """Unsubscribe from resource updates. 

371 

372 Args: 

373 db: Database session 

374 subscription: Subscription to remove 

375 """ 

376 try: 

377 # Find resource 

378 resource = db.execute(select(DbResource).where(DbResource.uri == subscription.uri)).scalar_one_or_none() 

379 

380 if not resource: 

381 return 

382 

383 # Remove subscription 

384 db.execute(select(DbSubscription).where(DbSubscription.resource_id == resource.id).where(DbSubscription.subscriber_id == subscription.subscriber_id)).delete() 

385 db.commit() 

386 

387 logger.info(f"Removed subscription for {subscription.uri} by {subscription.subscriber_id}") 

388 

389 except Exception as e: 

390 db.rollback() 

391 logger.error(f"Failed to unsubscribe: {str(e)}") 

392 

393 async def update_resource(self, db: Session, uri: str, resource_update: ResourceUpdate) -> ResourceRead: 

394 """Update a resource. 

395 

396 Args: 

397 db: Database session 

398 uri: Resource URI to update 

399 resource_update: Updated resource data 

400 

401 Returns: 

402 Updated resource information 

403 

404 Raises: 

405 ResourceNotFoundError: If resource not found 

406 ResourceError: For other update errors 

407 Exception: If resource not found 

408 """ 

409 try: 

410 # Find resource 

411 resource = db.execute(select(DbResource).where(DbResource.uri == uri).where(DbResource.is_active)).scalar_one_or_none() 

412 

413 if not resource: 

414 # Check if inactive resource exists 

415 inactive_resource = db.execute(select(DbResource).where(DbResource.uri == uri).where(not_(DbResource.is_active))).scalar_one_or_none() 

416 

417 if inactive_resource: 

418 raise ResourceNotFoundError(f"Resource '{uri}' exists but is inactive") 

419 

420 raise ResourceNotFoundError(f"Resource not found: {uri}") 

421 

422 # Update fields if provided 

423 if resource_update.name is not None: 

424 resource.name = resource_update.name 

425 if resource_update.description is not None: 

426 resource.description = resource_update.description 

427 if resource_update.mime_type is not None: 

428 resource.mime_type = resource_update.mime_type 

429 if resource_update.template is not None: 

430 resource.template = resource_update.template 

431 

432 # Update content if provided 

433 if resource_update.content is not None: 

434 # Determine content storage 

435 is_text = resource.mime_type and resource.mime_type.startswith("text/") or isinstance(resource_update.content, str) 

436 

437 resource.text_content = resource_update.content if is_text else None 

438 resource.binary_content = ( 

439 resource_update.content.encode() if is_text and isinstance(resource_update.content, str) else resource_update.content if isinstance(resource_update.content, bytes) else None 

440 ) 

441 resource.size = len(resource_update.content) 

442 

443 resource.updated_at = datetime.utcnow() 

444 db.commit() 

445 db.refresh(resource) 

446 

447 # Notify subscribers 

448 await self._notify_resource_updated(resource) 

449 

450 logger.info(f"Updated resource: {uri}") 

451 return self._convert_resource_to_read(resource) 

452 

453 except Exception as e: 

454 db.rollback() 

455 if isinstance(e, ResourceNotFoundError): 

456 raise e 

457 raise ResourceError(f"Failed to update resource: {str(e)}") 

458 

459 async def delete_resource(self, db: Session, uri: str) -> None: 

460 """Permanently delete a resource. 

461 

462 Args: 

463 db: Database session 

464 uri: Resource URI to delete 

465 

466 Raises: 

467 ResourceNotFoundError: If resource not found 

468 ResourceError: For other deletion errors 

469 """ 

470 try: 

471 # Find resource by its URI. 

472 resource = db.execute(select(DbResource).where(DbResource.uri == uri)).scalar_one_or_none() 

473 

474 if not resource: 474 ↛ 476line 474 didn't jump to line 476 because the condition on line 474 was never true

475 # If resource doesn't exist, rollback and re-raise a ResourceNotFoundError. 

476 db.rollback() 

477 raise ResourceNotFoundError(f"Resource not found: {uri}") 

478 

479 # Store resource info for notification before deletion. 

480 resource_info = { 

481 "id": resource.id, 

482 "uri": resource.uri, 

483 "name": resource.name, 

484 } 

485 

486 # Remove subscriptions using SQLAlchemy's delete() expression. 

487 db.execute(delete(DbSubscription).where(DbSubscription.resource_id == resource.id)) 

488 

489 # Hard delete the resource. 

490 db.delete(resource) 

491 db.commit() 

492 

493 # Notify subscribers. 

494 await self._notify_resource_deleted(resource_info) 

495 

496 logger.info(f"Permanently deleted resource: {uri}") 

497 

498 except ResourceNotFoundError: 

499 # ResourceNotFoundError is re-raised to be handled in the endpoint. 

500 raise 

501 except Exception as e: 

502 db.rollback() 

503 raise ResourceError(f"Failed to delete resource: {str(e)}") 

504 

505 async def get_resource_by_uri(self, db: Session, uri: str, include_inactive: bool = False) -> ResourceRead: 

506 """Get resource by URI. 

507 

508 Args: 

509 db: Database session 

510 uri: Resource URI 

511 include_inactive: Whether to include inactive resources 

512 

513 Returns: 

514 Resource information 

515 

516 Raises: 

517 ResourceNotFoundError: If resource not found 

518 """ 

519 query = select(DbResource).where(DbResource.uri == uri) 

520 

521 if not include_inactive: 

522 query = query.where(DbResource.is_active) 

523 

524 resource = db.execute(query).scalar_one_or_none() 

525 

526 if not resource: 

527 if not include_inactive: 

528 # Check if inactive resource exists 

529 inactive_resource = db.execute(select(DbResource).where(DbResource.uri == uri).where(not_(DbResource.is_active))).scalar_one_or_none() 

530 

531 if inactive_resource: 

532 raise ResourceNotFoundError(f"Resource '{uri}' exists but is inactive") 

533 

534 raise ResourceNotFoundError(f"Resource not found: {uri}") 

535 

536 return self._convert_resource_to_read(resource) 

537 

538 async def _notify_resource_activated(self, resource: DbResource) -> None: 

539 """ 

540 Notify subscribers of resource activation. 

541 

542 Args: 

543 resource: Resource to activate 

544 """ 

545 event = { 

546 "type": "resource_activated", 

547 "data": { 

548 "id": resource.id, 

549 "uri": resource.uri, 

550 "name": resource.name, 

551 "is_active": True, 

552 }, 

553 "timestamp": datetime.utcnow().isoformat(), 

554 } 

555 await self._publish_event(resource.uri, event) 

556 

557 async def _notify_resource_deactivated(self, resource: DbResource) -> None: 

558 """ 

559 Notify subscribers of resource deactivation. 

560 

561 Args: 

562 resource: Resource to deactivate 

563 """ 

564 event = { 

565 "type": "resource_deactivated", 

566 "data": { 

567 "id": resource.id, 

568 "uri": resource.uri, 

569 "name": resource.name, 

570 "is_active": False, 

571 }, 

572 "timestamp": datetime.utcnow().isoformat(), 

573 } 

574 await self._publish_event(resource.uri, event) 

575 

576 async def _notify_resource_deleted(self, resource_info: Dict[str, Any]) -> None: 

577 """ 

578 Notify subscribers of resource deletion. 

579 

580 Args: 

581 resource_info: Dictionary of resource to delete 

582 """ 

583 event = { 

584 "type": "resource_deleted", 

585 "data": resource_info, 

586 "timestamp": datetime.utcnow().isoformat(), 

587 } 

588 await self._publish_event(resource_info["uri"], event) 

589 

590 async def _notify_resource_removed(self, resource: DbResource) -> None: 

591 """ 

592 Notify subscribers of resource removal. 

593 

594 Args: 

595 resource: Resource to remove 

596 """ 

597 event = { 

598 "type": "resource_removed", 

599 "data": { 

600 "id": resource.id, 

601 "uri": resource.uri, 

602 "name": resource.name, 

603 "is_active": False, 

604 }, 

605 "timestamp": datetime.utcnow().isoformat(), 

606 } 

607 await self._publish_event(resource.uri, event) 

608 

609 async def subscribe_events(self, uri: Optional[str] = None) -> AsyncGenerator[Dict[str, Any], None]: 

610 """Subscribe to resource events. 

611 

612 Args: 

613 uri: Optional URI to filter events 

614 

615 Yields: 

616 Resource event messages 

617 """ 

618 queue: asyncio.Queue = asyncio.Queue() 

619 

620 if uri: 

621 if uri not in self._event_subscribers: 

622 self._event_subscribers[uri] = [] 

623 self._event_subscribers[uri].append(queue) 

624 else: 

625 self._event_subscribers["*"] = self._event_subscribers.get("*", []) 

626 self._event_subscribers["*"].append(queue) 

627 

628 try: 

629 while True: 

630 event = await queue.get() 

631 yield event 

632 finally: 

633 if uri: 

634 self._event_subscribers[uri].remove(queue) 

635 if not self._event_subscribers[uri]: 

636 del self._event_subscribers[uri] 

637 else: 

638 self._event_subscribers["*"].remove(queue) 

639 if not self._event_subscribers["*"]: 

640 del self._event_subscribers["*"] 

641 

642 def _is_valid_uri(self, uri: str) -> bool: 

643 """Validate a resource URI. 

644 

645 Args: 

646 uri: URI to validate 

647 

648 Returns: 

649 True if URI is valid 

650 """ 

651 try: 

652 parsed = urlparse(uri) 

653 return bool(parsed.scheme and parsed.path) 

654 except Exception: 

655 return False 

656 

657 def _detect_mime_type(self, uri: str, content: Union[str, bytes]) -> str: 

658 """Detect mime type from URI and content. 

659 

660 Args: 

661 uri: Resource URI 

662 content: Resource content 

663 

664 Returns: 

665 Detected mime type 

666 """ 

667 # Try from URI first 

668 mime_type, _ = mimetypes.guess_type(uri) 

669 if mime_type: 

670 return mime_type 

671 

672 # Check content type 

673 if isinstance(content, str): 

674 return "text/plain" 

675 

676 return "application/octet-stream" 

677 

678 async def _read_template_resource(self, uri: str) -> ResourceContent: 

679 """Read a templated resource. 

680 

681 Args: 

682 uri: Template URI with parameters 

683 

684 Returns: 

685 Resource content 

686 

687 Raises: 

688 ResourceNotFoundError: If template not found 

689 ResourceError: For other template errors 

690 NotImplementedError: When binary template is passed 

691 """ 

692 # Find matching template 

693 template = None 

694 for cached in self._template_cache.values(): 

695 if self._uri_matches_template(uri, cached.uri_template): 

696 template = cached 

697 break 

698 

699 if not template: 

700 raise ResourceNotFoundError(f"No template matches URI: {uri}") 

701 

702 try: 

703 # Extract parameters 

704 params = self._extract_template_params(uri, template.uri_template) 

705 

706 # Generate content 

707 if template.mime_type and template.mime_type.startswith("text/"): 

708 content = template.uri_template.format(**params) 

709 return TextContent(type="text", text=content) 

710 

711 # Handle binary template 

712 raise NotImplementedError("Binary resource templates not yet supported") 

713 

714 except Exception as e: 

715 raise ResourceError(f"Failed to process template: {str(e)}") 

716 

717 def _uri_matches_template(self, uri: str, template: str) -> bool: 

718 """Check if URI matches a template pattern. 

719 

720 Args: 

721 uri: URI to check 

722 template: Template pattern 

723 

724 Returns: 

725 True if URI matches template 

726 """ 

727 # Convert template to regex pattern 

728 

729 pattern = re.escape(template).replace(r"\{.*?\}", r"[^/]+") 

730 return bool(re.match(pattern, uri)) 

731 

732 def _extract_template_params(self, uri: str, template: str) -> Dict[str, str]: 

733 """Extract parameters from URI based on template. 

734 

735 Args: 

736 uri: URI with parameter values 

737 template: Template pattern 

738 

739 Returns: 

740 Dict of parameter names and values 

741 """ 

742 

743 result = parse.parse(template, uri) 

744 return result.named if result else {} 

745 

746 async def _notify_resource_added(self, resource: DbResource) -> None: 

747 """ 

748 Notify subscribers of resource addition. 

749 

750 Args: 

751 resource: Resource to add 

752 """ 

753 event = { 

754 "type": "resource_added", 

755 "data": { 

756 "id": resource.id, 

757 "uri": resource.uri, 

758 "name": resource.name, 

759 "description": resource.description, 

760 "is_active": resource.is_active, 

761 }, 

762 "timestamp": datetime.utcnow().isoformat(), 

763 } 

764 await self._publish_event(resource.uri, event) 

765 

766 async def _notify_resource_updated(self, resource: DbResource) -> None: 

767 """ 

768 Notify subscribers of resource update. 

769 

770 Args: 

771 resource: Resource to update 

772 """ 

773 event = { 

774 "type": "resource_updated", 

775 "data": { 

776 "id": resource.id, 

777 "uri": resource.uri, 

778 "content": resource.content, 

779 "is_active": resource.is_active, 

780 }, 

781 "timestamp": datetime.utcnow().isoformat(), 

782 } 

783 await self._publish_event(resource.uri, event) 

784 

785 async def _publish_event(self, uri: str, event: Dict[str, Any]) -> None: 

786 """Publish event to relevant subscribers. 

787 

788 Args: 

789 uri: Resource URI event relates to 

790 event: Event data to publish 

791 """ 

792 # Notify resource-specific subscribers 

793 if uri in self._event_subscribers: 

794 for queue in self._event_subscribers[uri]: 

795 await queue.put(event) 

796 

797 # Notify global subscribers 

798 if "*" in self._event_subscribers: 

799 for queue in self._event_subscribers["*"]: 

800 await queue.put(event) 

801 

802 # --- Resource templates --- 

803 async def list_resource_templates(self, db: Session, include_inactive: bool = False) -> List[ResourceTemplate]: 

804 """ 

805 Retrieve a list of resource templates from the database. 

806 

807 This method retrieves resource templates (resources with a defined template field) from the database 

808 and converts them into a list of ResourceTemplate objects. It supports filtering out inactive templates 

809 based on the include_inactive parameter. The cursor parameter is reserved for future pagination support 

810 but is currently not implemented. 

811 

812 Args: 

813 db (Session): The SQLAlchemy database session. 

814 include_inactive (bool): If True, include inactive resource templates in the result. 

815 Defaults to False. 

816 

817 Returns: 

818 List[ResourceTemplate]: A list of resource templates. 

819 """ 

820 query = select(DbResource).where(DbResource.template.isnot(None)) 

821 if not include_inactive: 

822 query = query.where(DbResource.is_active) 

823 # Cursor-based pagination logic can be implemented here in the future. 

824 templates = db.execute(query).scalars().all() 

825 return [ResourceTemplate.model_validate(t) for t in templates] 

826 

827 # --- Metrics --- 

828 async def aggregate_metrics(self, db: Session) -> ResourceMetrics: 

829 """ 

830 Aggregate metrics for all resource invocations across all resources. 

831 

832 Args: 

833 db: Database session 

834 

835 Returns: 

836 ResourceMetrics: Aggregated metrics computed from all ResourceMetric records. 

837 """ 

838 total_executions = db.execute(select(func.count()).select_from(ResourceMetric)).scalar() or 0 # pylint: disable=not-callable 

839 

840 successful_executions = db.execute(select(func.count()).select_from(ResourceMetric).where(ResourceMetric.is_success)).scalar() or 0 # pylint: disable=not-callable 

841 

842 failed_executions = db.execute(select(func.count()).select_from(ResourceMetric).where(not_(ResourceMetric.is_success))).scalar() or 0 # pylint: disable=not-callable 

843 

844 min_response_time = db.execute(select(func.min(ResourceMetric.response_time))).scalar() 

845 

846 max_response_time = db.execute(select(func.max(ResourceMetric.response_time))).scalar() 

847 

848 avg_response_time = db.execute(select(func.avg(ResourceMetric.response_time))).scalar() 

849 

850 last_execution_time = db.execute(select(func.max(ResourceMetric.timestamp))).scalar() 

851 

852 return ResourceMetrics( 

853 total_executions=total_executions, 

854 successful_executions=successful_executions, 

855 failed_executions=failed_executions, 

856 failure_rate=(failed_executions / total_executions) if total_executions > 0 else 0.0, 

857 min_response_time=min_response_time, 

858 max_response_time=max_response_time, 

859 avg_response_time=avg_response_time, 

860 last_execution_time=last_execution_time, 

861 ) 

862 

863 async def reset_metrics(self, db: Session) -> None: 

864 """ 

865 Reset all resource metrics by deleting all records from the resource metrics table. 

866 

867 Args: 

868 db: Database session 

869 """ 

870 db.execute(delete(ResourceMetric)) 

871 db.commit()