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

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

2"""Resource Service Implementation.

3

4 Copyright 2025

5 SPDX-License-Identifier: Apache-2.0

6 Authors: Mihai Criveti

7

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

9 It 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

18 import asyncio

19 from datetime import datetime, timezone

20 import logging

21 import mimetypes

22 import re

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

24 from urllib.parse import urlparse

25

26# Third-Party

27 import parse

28 from sqlalchemy import delete, func, not_, select

29 from sqlalchemy.exc import IntegrityError

30 from sqlalchemy.orm import Session

31

32# First-Party

33 from mcpgateway.db import Resource as DbResource

34 from mcpgateway.db import ResourceMetric

35 from mcpgateway.db import ResourceSubscription as DbSubscription

36 from mcpgateway.db import server_resource_association

37 from mcpgateway.models import ResourceContent, ResourceTemplate, TextContent

38 from mcpgateway.schemas import (

39 ResourceCreate,

40 ResourceMetrics,

41 ResourceRead,

42 ResourceSubscription,

43 ResourceUpdate,

44)

45

46 logger = logging.getLogger(__name__)

47

48

49 class ResourceError(Exception):

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

51

52

53 class ResourceNotFoundError(ResourceError):

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

55

56

57 class 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

77 class ResourceValidationError(ResourceError):

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

79

80

81 class 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()