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

302 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-09 11:03 +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 

17# Standard 

18import asyncio 

19from datetime import datetime, timezone 

20import logging 

21import mimetypes 

22import re 

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

24from urllib.parse import urlparse 

25 

26# Third-Party 

27import parse 

28from sqlalchemy import delete, func, not_, select 

29from sqlalchemy.exc import IntegrityError 

30from sqlalchemy.orm import Session 

31 

32# First-Party 

33from mcpgateway.db import Resource as DbResource 

34from mcpgateway.db import ResourceMetric 

35from mcpgateway.db import ResourceSubscription as DbSubscription 

36from mcpgateway.db import server_resource_association 

37from mcpgateway.models import ResourceContent, ResourceTemplate, TextContent 

38from mcpgateway.schemas import ( 

39 ResourceCreate, 

40 ResourceMetrics, 

41 ResourceRead, 

42 ResourceSubscription, 

43 ResourceUpdate, 

44) 

45 

46logger = logging.getLogger(__name__) 

47 

48 

49class ResourceError(Exception): 

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

51 

52 

53class ResourceNotFoundError(ResourceError): 

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

55 

56 

57class ResourceURIConflictError(ResourceError): 

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

59 

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

61 """Initialize the error with resource information. 

62 

63 Args: 

64 uri: The conflicting resource URI 

65 is_active: Whether the existing resource is active 

66 resource_id: ID of the existing resource if available 

67 """ 

68 self.uri = uri 

69 self.is_active = is_active 

70 self.resource_id = resource_id 

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

72 if not is_active: 

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

74 super().__init__(message) 

75 

76 

77class ResourceValidationError(ResourceError): 

78 """Raised when resource validation fails.""" 

79 

80 

81class ResourceService: 

82 """Service for managing resources. 

83 

84 Handles: 

85 - Resource registration and retrieval 

86 - Resource templates and URIs 

87 - Resource subscriptions 

88 - Content type detection 

89 - Active/inactive status management 

90 """ 

91 

92 def __init__(self): 

93 """Initialize the resource service.""" 

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

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

96 

97 # Initialize mime types 

98 mimetypes.init() 

99 

100 async def initialize(self) -> None: 

101 """Initialize the service.""" 

102 logger.info("Initializing resource service") 

103 

104 async def shutdown(self) -> None: 

105 """Shutdown the service.""" 

106 # Clear subscriptions 

107 self._event_subscribers.clear() 

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

109 

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

111 """ 

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

113 

114 Args: 

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

116 

117 Returns: 

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

119 """ 

120 resource_dict = resource.__dict__.copy() 

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

122 resource_dict.pop("_sa_instance_state", None) 

123 resource_dict.pop("metrics", None) 

124 

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

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

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

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

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

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

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

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

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

134 

135 resource_dict["metrics"] = { 

136 "total_executions": total, 

137 "successful_executions": successful, 

138 "failed_executions": failed, 

139 "failure_rate": failure_rate, 

140 "min_response_time": min_rt, 

141 "max_response_time": max_rt, 

142 "avg_response_time": avg_rt, 

143 "last_execution_time": last_time, 

144 } 

145 return ResourceRead.model_validate(resource_dict) 

146 

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

148 """Register a new resource. 

149 

150 Args: 

151 db: Database session 

152 resource: Resource creation schema 

153 

154 Returns: 

155 Created resource information 

156 

157 Raises: 

158 ResourceURIConflictError: If resource URI already exists 

159 ResourceValidationError: If resource validation fails 

160 ResourceError: For other resource registration errors 

161 """ 

162 try: 

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

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

165 

166 if existing_resource: 

167 raise ResourceURIConflictError( 

168 resource.uri, 

169 is_active=existing_resource.is_active, 

170 resource_id=existing_resource.id, 

171 ) 

172 

173 # Validate URI 

174 if not self._is_valid_uri(resource.uri): 

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

176 

177 # Detect mime type if not provided 

178 mime_type = resource.mime_type 

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

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

181 

182 # Determine content storage 

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

184 

185 # Create DB model 

186 db_resource = DbResource( 

187 uri=resource.uri, 

188 name=resource.name, 

189 description=resource.description, 

190 mime_type=mime_type, 

191 template=resource.template, 

192 text_content=resource.content if is_text else None, 

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

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

195 ) 

196 

197 # Add to DB 

198 db.add(db_resource) 

199 db.commit() 

200 db.refresh(db_resource) 

201 

202 # Notify subscribers 

203 await self._notify_resource_added(db_resource) 

204 

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

206 return self._convert_resource_to_read(db_resource) 

207 

208 except IntegrityError: 

209 db.rollback() 

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

211 except Exception as e: 

212 db.rollback() 

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

214 

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

216 """ 

217 Retrieve a list of registered resources from the database. 

218 

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

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

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

222 but is currently not implemented. 

223 

224 Args: 

225 db (Session): The SQLAlchemy database session. 

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

227 Defaults to False. 

228 

229 Returns: 

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

231 """ 

232 query = select(DbResource) 

233 if not include_inactive: 

234 query = query.where(DbResource.is_active) 

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

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

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

238 

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

240 """ 

241 Retrieve a list of registered resources from the database. 

242 

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

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

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

246 but is currently not implemented. 

247 

248 Args: 

249 db (Session): The SQLAlchemy database session. 

250 server_id (str): Server ID 

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

252 Defaults to False. 

253 

254 Returns: 

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

256 """ 

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

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

259 query = query.where(DbResource.is_active) 

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

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

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

263 

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

265 """Read a resource's content. 

266 

267 Args: 

268 db: Database session 

269 uri: Resource URI to read 

270 

271 Returns: 

272 Resource content object 

273 

274 Raises: 

275 ResourceNotFoundError: If resource not found 

276 """ 

277 # Check for template 

278 if "{" in uri and "}" in uri: 

279 return await self._read_template_resource(uri) 

280 

281 # Find resource 

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

283 

284 if not resource: 

285 # Check if inactive resource exists 

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

287 

288 if inactive_resource: 

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

290 

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

292 

293 # Return content 

294 return resource.content 

295 

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

297 """Toggle resource active status. 

298 

299 Args: 

300 db: Database session 

301 resource_id: Resource ID to toggle 

302 activate: True to activate, False to deactivate 

303 

304 Returns: 

305 Updated resource information 

306 

307 Raises: 

308 ResourceNotFoundError: If resource not found 

309 ResourceError: For other errors 

310 """ 

311 try: 

312 resource = db.get(DbResource, resource_id) 

313 if not resource: 

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

315 

316 # Update status if it's different 

317 if resource.is_active != activate: 

318 resource.is_active = activate 

319 resource.updated_at = datetime.now(timezone.utc) 

320 db.commit() 

321 db.refresh(resource) 

322 

323 # Notify subscribers 

324 if activate: 

325 await self._notify_resource_activated(resource) 

326 else: 

327 await self._notify_resource_deactivated(resource) 

328 

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

330 

331 return self._convert_resource_to_read(resource) 

332 

333 except Exception as e: 

334 db.rollback() 

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

336 

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

338 """Subscribe to resource updates. 

339 

340 Args: 

341 db: Database session 

342 subscription: Subscription details 

343 

344 Raises: 

345 ResourceNotFoundError: If resource not found 

346 ResourceError: For other subscription errors 

347 """ 

348 try: 

349 # Verify resource exists 

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

351 

352 if not resource: 

353 # Check if inactive resource exists 

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

355 

356 if inactive_resource: 

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

358 

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

360 

361 # Create subscription 

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

363 db.add(db_sub) 

364 db.commit() 

365 

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

367 

368 except Exception as e: 

369 db.rollback() 

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

371 

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

373 """Unsubscribe from resource updates. 

374 

375 Args: 

376 db: Database session 

377 subscription: Subscription to remove 

378 """ 

379 try: 

380 # Find resource 

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

382 

383 if not resource: 

384 return 

385 

386 # Remove subscription 

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

388 db.commit() 

389 

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

391 

392 except Exception as e: 

393 db.rollback() 

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

395 

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

397 """Update a resource. 

398 

399 Args: 

400 db: Database session 

401 uri: Resource URI to update 

402 resource_update: Updated resource data 

403 

404 Returns: 

405 Updated resource information 

406 

407 Raises: 

408 ResourceNotFoundError: If resource not found 

409 ResourceError: For other update errors 

410 Exception: If resource not found 

411 """ 

412 try: 

413 # Find resource 

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

415 

416 if not resource: 

417 # Check if inactive resource exists 

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

419 

420 if inactive_resource: 

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

422 

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

424 

425 # Update fields if provided 

426 if resource_update.name is not None: 

427 resource.name = resource_update.name 

428 if resource_update.description is not None: 

429 resource.description = resource_update.description 

430 if resource_update.mime_type is not None: 430 ↛ 431line 430 didn't jump to line 431 because the condition on line 430 was never true

431 resource.mime_type = resource_update.mime_type 

432 if resource_update.template is not None: 432 ↛ 433line 432 didn't jump to line 433 because the condition on line 432 was never true

433 resource.template = resource_update.template 

434 

435 # Update content if provided 

436 if resource_update.content is not None: 

437 # Determine content storage 

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

439 

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

441 resource.binary_content = ( 

442 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 

443 ) 

444 resource.size = len(resource_update.content) 

445 

446 resource.updated_at = datetime.now(timezone.utc) 

447 db.commit() 

448 db.refresh(resource) 

449 

450 # Notify subscribers 

451 await self._notify_resource_updated(resource) 

452 

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

454 return self._convert_resource_to_read(resource) 

455 

456 except Exception as e: 

457 db.rollback() 

458 if isinstance(e, ResourceNotFoundError): 

459 raise e 

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

461 

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

463 """Permanently delete a resource. 

464 

465 Args: 

466 db: Database session 

467 uri: Resource URI to delete 

468 

469 Raises: 

470 ResourceNotFoundError: If resource not found 

471 ResourceError: For other deletion errors 

472 """ 

473 try: 

474 # Find resource by its URI. 

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

476 

477 if not resource: 

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

479 db.rollback() 

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

481 

482 # Store resource info for notification before deletion. 

483 resource_info = { 

484 "id": resource.id, 

485 "uri": resource.uri, 

486 "name": resource.name, 

487 } 

488 

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

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

491 

492 # Hard delete the resource. 

493 db.delete(resource) 

494 db.commit() 

495 

496 # Notify subscribers. 

497 await self._notify_resource_deleted(resource_info) 

498 

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

500 

501 except ResourceNotFoundError: 

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

503 raise 

504 except Exception as e: 

505 db.rollback() 

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

507 

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

509 """Get resource by URI. 

510 

511 Args: 

512 db: Database session 

513 uri: Resource URI 

514 include_inactive: Whether to include inactive resources 

515 

516 Returns: 

517 Resource information 

518 

519 Raises: 

520 ResourceNotFoundError: If resource not found 

521 """ 

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

523 

524 if not include_inactive: 

525 query = query.where(DbResource.is_active) 

526 

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

528 

529 if not resource: 

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

531 # Check if inactive resource exists 

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

533 

534 if inactive_resource: 

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

536 

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

538 

539 return self._convert_resource_to_read(resource) 

540 

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

542 """ 

543 Notify subscribers of resource activation. 

544 

545 Args: 

546 resource: Resource to activate 

547 """ 

548 event = { 

549 "type": "resource_activated", 

550 "data": { 

551 "id": resource.id, 

552 "uri": resource.uri, 

553 "name": resource.name, 

554 "is_active": True, 

555 }, 

556 "timestamp": datetime.now(timezone.utc).isoformat(), 

557 } 

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

559 

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

561 """ 

562 Notify subscribers of resource deactivation. 

563 

564 Args: 

565 resource: Resource to deactivate 

566 """ 

567 event = { 

568 "type": "resource_deactivated", 

569 "data": { 

570 "id": resource.id, 

571 "uri": resource.uri, 

572 "name": resource.name, 

573 "is_active": False, 

574 }, 

575 "timestamp": datetime.now(timezone.utc).isoformat(), 

576 } 

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

578 

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

580 """ 

581 Notify subscribers of resource deletion. 

582 

583 Args: 

584 resource_info: Dictionary of resource to delete 

585 """ 

586 event = { 

587 "type": "resource_deleted", 

588 "data": resource_info, 

589 "timestamp": datetime.now(timezone.utc).isoformat(), 

590 } 

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

592 

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

594 """ 

595 Notify subscribers of resource removal. 

596 

597 Args: 

598 resource: Resource to remove 

599 """ 

600 event = { 

601 "type": "resource_removed", 

602 "data": { 

603 "id": resource.id, 

604 "uri": resource.uri, 

605 "name": resource.name, 

606 "is_active": False, 

607 }, 

608 "timestamp": datetime.now(timezone.utc).isoformat(), 

609 } 

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

611 

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

613 """Subscribe to resource events. 

614 

615 Args: 

616 uri: Optional URI to filter events 

617 

618 Yields: 

619 Resource event messages 

620 """ 

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

622 

623 if uri: 

624 if uri not in self._event_subscribers: 

625 self._event_subscribers[uri] = [] 

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

627 else: 

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

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

630 

631 try: 

632 while True: 

633 event = await queue.get() 

634 yield event 

635 finally: 

636 if uri: 

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

638 if not self._event_subscribers[uri]: 

639 del self._event_subscribers[uri] 

640 else: 

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

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

643 del self._event_subscribers["*"] 

644 

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

646 """Validate a resource URI. 

647 

648 Args: 

649 uri: URI to validate 

650 

651 Returns: 

652 True if URI is valid 

653 """ 

654 try: 

655 parsed = urlparse(uri) 

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

657 except Exception: 

658 return False 

659 

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

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

662 

663 Args: 

664 uri: Resource URI 

665 content: Resource content 

666 

667 Returns: 

668 Detected mime type 

669 """ 

670 # Try from URI first 

671 mime_type, _ = mimetypes.guess_type(uri) 

672 if mime_type: 

673 return mime_type 

674 

675 # Check content type 

676 if isinstance(content, str): 

677 return "text/plain" 

678 

679 return "application/octet-stream" 

680 

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

682 """Read a templated resource. 

683 

684 Args: 

685 uri: Template URI with parameters 

686 

687 Returns: 

688 Resource content 

689 

690 Raises: 

691 ResourceNotFoundError: If template not found 

692 ResourceError: For other template errors 

693 NotImplementedError: When binary template is passed 

694 """ 

695 # Find matching template 

696 template = None 

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

698 if self._uri_matches_template(uri, cached.uri_template): 698 ↛ 697line 698 didn't jump to line 697 because the condition on line 698 was always true

699 template = cached 

700 break 

701 

702 if not template: 

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

704 

705 try: 

706 # Extract parameters 

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

708 

709 # Generate content 

710 if template.mime_type and template.mime_type.startswith("text/"): 710 ↛ 711line 710 didn't jump to line 711 because the condition on line 710 was never true

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

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

713 

714 # Handle binary template 

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

716 

717 except Exception as e: 

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

719 

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

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

722 

723 Args: 

724 uri: URI to check 

725 template: Template pattern 

726 

727 Returns: 

728 True if URI matches template 

729 """ 

730 # Convert template to regex pattern 

731 

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

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

734 

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

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

737 

738 Args: 

739 uri: URI with parameter values 

740 template: Template pattern 

741 

742 Returns: 

743 Dict of parameter names and values 

744 """ 

745 

746 result = parse.parse(template, uri) 

747 return result.named if result else {} 

748 

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

750 """ 

751 Notify subscribers of resource addition. 

752 

753 Args: 

754 resource: Resource to add 

755 """ 

756 event = { 

757 "type": "resource_added", 

758 "data": { 

759 "id": resource.id, 

760 "uri": resource.uri, 

761 "name": resource.name, 

762 "description": resource.description, 

763 "is_active": resource.is_active, 

764 }, 

765 "timestamp": datetime.now(timezone.utc).isoformat(), 

766 } 

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

768 

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

770 """ 

771 Notify subscribers of resource update. 

772 

773 Args: 

774 resource: Resource to update 

775 """ 

776 event = { 

777 "type": "resource_updated", 

778 "data": { 

779 "id": resource.id, 

780 "uri": resource.uri, 

781 "content": resource.content, 

782 "is_active": resource.is_active, 

783 }, 

784 "timestamp": datetime.now(timezone.utc).isoformat(), 

785 } 

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

787 

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

789 """Publish event to relevant subscribers. 

790 

791 Args: 

792 uri: Resource URI event relates to 

793 event: Event data to publish 

794 """ 

795 # Notify resource-specific subscribers 

796 if uri in self._event_subscribers: 796 ↛ 801line 796 didn't jump to line 801 because the condition on line 796 was always true

797 for queue in self._event_subscribers[uri]: 

798 await queue.put(event) 

799 

800 # Notify global subscribers 

801 if "*" in self._event_subscribers: 801 ↛ exitline 801 didn't return from function '_publish_event' because the condition on line 801 was always true

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

803 await queue.put(event) 

804 

805 # --- Resource templates --- 

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

807 """ 

808 Retrieve a list of resource templates from the database. 

809 

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

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

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

813 but is currently not implemented. 

814 

815 Args: 

816 db (Session): The SQLAlchemy database session. 

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

818 Defaults to False. 

819 

820 Returns: 

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

822 """ 

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

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

825 query = query.where(DbResource.is_active) 

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

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

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

829 

830 # --- Metrics --- 

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

832 """ 

833 Aggregate metrics for all resource invocations across all resources. 

834 

835 Args: 

836 db: Database session 

837 

838 Returns: 

839 ResourceMetrics: Aggregated metrics computed from all ResourceMetric records. 

840 """ 

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

842 

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

844 

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

846 

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

848 

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

850 

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

852 

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

854 

855 return ResourceMetrics( 

856 total_executions=total_executions, 

857 successful_executions=successful_executions, 

858 failed_executions=failed_executions, 

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

860 min_response_time=min_response_time, 

861 max_response_time=max_response_time, 

862 avg_response_time=avg_response_time, 

863 last_execution_time=last_execution_time, 

864 ) 

865 

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

867 """ 

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

869 

870 Args: 

871 db: Database session 

872 """ 

873 db.execute(delete(ResourceMetric)) 

874 db.commit()