diff --git a/docs/docs/manage/.pages b/docs/docs/manage/.pages index 07d89c902..599f08691 100644 --- a/docs/docs/manage/.pages +++ b/docs/docs/manage/.pages @@ -2,6 +2,7 @@ nav: - index.md - backup.md - bulk-import.md + - metadata-tracking.md - export-import.md - export-import-tutorial.md - export-import-reference.md diff --git a/docs/docs/manage/index.md b/docs/docs/manage/index.md index dc49be6dd..07e40e7fb 100644 --- a/docs/docs/manage/index.md +++ b/docs/docs/manage/index.md @@ -15,6 +15,7 @@ Whether you're self-hosting, running in the cloud, or deploying to Kubernetes, t | [Export/Import Tutorial](export-import-tutorial.md) | Step-by-step tutorial for getting started with export/import | | [Export/Import Reference](export-import-reference.md) | Quick reference guide for export/import commands and APIs | | [Bulk Import](bulk-import.md) | Import multiple tools at once for migrations and team onboarding | +| [Metadata Tracking](metadata-tracking.md) | 📊 **NEW** - Comprehensive audit trails and entity metadata tracking | | [Well-Known URIs](well-known-uris.md) | Configure robots.txt, security.txt, and custom well-known files | | [Logging](logging.md) | Configure structured logging, log destinations, and log rotation | diff --git a/docs/docs/manage/metadata-tracking.md b/docs/docs/manage/metadata-tracking.md new file mode 100644 index 000000000..1aba01dff --- /dev/null +++ b/docs/docs/manage/metadata-tracking.md @@ -0,0 +1,353 @@ +# 📊 Metadata Tracking & Audit Trails + +MCP Gateway provides comprehensive metadata tracking for all entities (Tools, Resources, Prompts, Servers, Gateways) to enable enterprise-grade audit trails, compliance monitoring, and operational troubleshooting. + +--- + +## 🎯 **Overview** + +Every entity in MCP Gateway now includes detailed metadata about: +- **Who** created or modified the entity +- **When** the operation occurred +- **From where** (IP address, user agent) +- **How** it was created (UI, API, bulk import, federation) +- **Source tracking** for federated entities and bulk operations + +--- + +## 📊 **Metadata Fields** + +All entities include the following metadata fields: + +| Category | Field | Description | Example Values | +|----------|-------|-------------|----------------| +| **Creation** | `created_by` | Username who created entity | `"admin"`, `"alice"`, `"anonymous"` | +| | `created_at` | Creation timestamp | `"2024-01-15T10:30:00Z"` | +| | `created_from_ip` | IP address of creator | `"192.168.1.100"`, `"10.0.0.1"` | +| | `created_via` | Creation method | `"ui"`, `"api"`, `"import"`, `"federation"` | +| | `created_user_agent` | Browser/client info | `"Mozilla/5.0"`, `"curl/7.68.0"` | +| **Modification** | `modified_by` | Last modifier username | `"bob"`, `"system"`, `"anonymous"` | +| | `modified_at` | Last modification timestamp | `"2024-01-16T14:22:00Z"` | +| | `modified_from_ip` | IP of last modifier | `"172.16.0.1"` | +| | `modified_via` | Modification method | `"ui"`, `"api"` | +| | `modified_user_agent` | Client of last change | `"HTTPie/2.4.0"` | +| **Source** | `import_batch_id` | Bulk import UUID | `"550e8400-e29b-41d4-a716-446655440000"` | +| | `federation_source` | Source gateway name | `"gateway-prod-east"` | +| | `version` | Change tracking version | `1`, `2`, `3`... | + +--- + +## 🖥️ **Viewing Metadata** + +### **Admin UI** + +Metadata is displayed in the detail view modals for all entity types: + +1. **Navigate** to any entity list (Tools, Resources, Prompts, Servers, Gateways) +2. **Click "View"** on any entity +3. **Scroll down** to the "Metadata" section + +**Example metadata display:** +``` +┌─ Metadata ──────────────────────────────────────┐ +│ Created By: admin │ +│ Created At: 1/15/2024, 10:30:00 AM │ +│ Created From: 192.168.1.100 │ +│ Created Via: ui │ +│ Last Modified By: alice │ +│ Last Modified At: 1/16/2024, 2:22:00 PM │ +│ Version: 3 │ +│ Import Batch: N/A │ +└─────────────────────────────────────────────────┘ +``` + +### **API Responses** + +All entity read endpoints include metadata fields in JSON responses: + +```bash +# Get tool with metadata +curl -H "Authorization: Bearer $TOKEN" \ + http://localhost:4444/tools/abc123 + +{ + "id": "abc123", + "name": "example_tool", + "description": "Example tool", + "createdBy": "admin", + "createdAt": "2024-01-15T10:30:00Z", + "createdFromIp": "192.168.1.100", + "createdVia": "ui", + "createdUserAgent": "Mozilla/5.0...", + "modifiedBy": "alice", + "modifiedAt": "2024-01-16T14:22:00Z", + "version": 3, + "importBatchId": null, + "federationSource": null, + ... +} +``` + +--- + +## 🔍 **Metadata by Source Type** + +### **Manual Creation (UI/API)** +- `created_via`: `"ui"` or `"api"` +- `created_by`: Authenticated username +- `created_from_ip`: Client IP address +- `federation_source`: `null` +- `import_batch_id`: `null` + +### **Bulk Import Operations** +- `created_via`: `"import"` +- `import_batch_id`: UUID linking related imports +- `created_by`: User who initiated import +- `federation_source`: `null` + +### **Federation (MCP Server Discovery)** +- `created_via`: `"federation"` +- `federation_source`: Source gateway name +- `created_by`: User who registered the gateway +- `import_batch_id`: `null` + +### **Legacy Entities (Pre-Metadata)** +- All metadata fields: `null` +- UI displays: `"Legacy Entity"`, `"Pre-metadata"` +- `version`: `1` (automatically assigned) + +--- + +## 🛡️ **Authentication Compatibility** + +Metadata tracking works seamlessly across all authentication modes: + +### **With Authentication (`AUTH_REQUIRED=true`)** +```bash +# Example: User "admin" creates a tool +{ + "createdBy": "admin", + "createdVia": "api", + "createdFromIp": "192.168.1.100" +} +``` + +### **Without Authentication (`AUTH_REQUIRED=false`)** +```bash +# Example: Anonymous creation +{ + "createdBy": "anonymous", + "createdVia": "api", + "createdFromIp": "192.168.1.100" +} +``` + +### **JWT vs Basic Authentication** +- **JWT Authentication**: Extracts username from token payload (`username` or `sub` field) +- **Basic Authentication**: Uses provided username directly +- **Both formats handled gracefully** by the `extract_username()` utility + +--- + +## 🔄 **Version Tracking** + +Each entity maintains a version number that increments on modifications: + +```bash +# Initial creation +POST /tools -> version: 1 + +# First update +PUT /tools/123 -> version: 2 + +# Second update +PUT /tools/123 -> version: 3 +``` + +Version tracking helps identify: +- **Configuration drift** between environments +- **Change frequency** for troubleshooting +- **Rollback points** for recovery scenarios + +--- + +## 📈 **Use Cases** + +### **Security Auditing** +- Track who created/modified sensitive configurations +- Identify unauthorized changes by IP address +- Monitor bulk import operations for compliance + +### **Operational Troubleshooting** +- Trace entity origins during incident response +- Identify batch operations that may have caused issues +- Understand federation dependencies between gateways + +### **Compliance Reporting** +- Generate audit reports for regulatory requirements +- Track change management processes +- Demonstrate access controls and change attribution + +### **Development & Testing** +- Identify test vs production entities +- Track deployment-specific configurations +- Monitor cross-environment migrations + +--- + +## 🔧 **Configuration** + +### **No Additional Setup Required** + +Metadata tracking is **automatically enabled** for all new installations and upgrades: + +- **Database migration** runs automatically on startup +- **Existing entities** show graceful fallbacks for missing metadata +- **No environment variables** needed - uses existing `AUTH_REQUIRED` setting + +### **Proxy Support** + +Metadata capture automatically handles reverse proxy scenarios: + +```bash +# Respects X-Forwarded-For headers +X-Forwarded-For: 203.0.113.1, 192.168.1.1, 127.0.0.1 +# Records: created_from_ip = "203.0.113.1" (original client) +``` + +### **Privacy Considerations** + +The system captures IP addresses and user agents for audit purposes: + +- **IP addresses**: Consider GDPR/privacy implications for EU deployments +- **User agents**: May contain personally identifiable information +- **Data retention**: Define policies for metadata archival +- **Access control**: Metadata follows same permissions as parent entity + +--- + +## 🚀 **Migration Guide** + +### **Upgrading Existing Deployments** + +1. **Automatic Migration** + ```bash + # Migration runs automatically on startup + # Or run manually: + alembic upgrade head + ``` + +2. **Verify Migration** + - Check admin UI - all entities show metadata sections + - API responses include new metadata fields + - Legacy entities display gracefully + +3. **No Downtime Required** + - All metadata columns are nullable + - Existing functionality unchanged + - Gradual adoption of metadata features + +### **Metadata Backfill (Optional)** + +For enhanced audit trails, optionally backfill known metadata: + +```sql +-- Backfill system-created entities +UPDATE tools SET + created_by = 'system', + created_via = 'migration', + version = 1 +WHERE created_by IS NULL; + +-- Similar for other entity tables +UPDATE gateways SET created_by = 'system', created_via = 'migration', version = 1 WHERE created_by IS NULL; +UPDATE servers SET created_by = 'system', created_via = 'migration', version = 1 WHERE created_by IS NULL; +UPDATE prompts SET created_by = 'system', created_via = 'migration', version = 1 WHERE created_by IS NULL; +UPDATE resources SET created_by = 'system', created_via = 'migration', version = 1 WHERE created_by IS NULL; +``` + +--- + +## 🔮 **Future Enhancements** + +### **Enhanced Audit Features** +- **Change history tracking** - Before/after state comparison +- **Metadata-based filtering** - Search entities by creator, date, source +- **Audit log export** - Generate compliance reports +- **Custom metadata fields** - User-defined entity attributes + +### **Cross-Gateway Features** +- **Metadata synchronization** across federated gateways +- **Trust scoring** based on metadata quality +- **Provenance tracking** for complex federation scenarios + +### **Analytics Integration** +- **Usage pattern analysis** from metadata +- **Creator activity dashboards** +- **Import/export trend monitoring** + +--- + +## 📋 **API Examples** + +### **Creating Entities with Metadata** + +Metadata is captured automatically - no additional parameters needed: + +```bash +# Create tool - metadata captured automatically +curl -X POST http://localhost:4444/tools \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "example_tool", + "url": "http://example.com/api", + "integration_type": "REST", + "request_type": "GET" + }' + +# Response includes metadata +{ + "id": "abc123", + "name": "example_tool", + "createdBy": "admin", + "createdAt": "2024-01-15T10:30:00Z", + "createdVia": "api", + "version": 1, + ... +} +``` + +### **Filtering by Metadata (Future)** + +```bash +# Future enhancement - filter by creator +GET /tools?created_by=admin + +# Filter by creation method +GET /tools?created_via=federation + +# Filter by date range +GET /tools?created_after=2024-01-01&created_before=2024-01-31 +``` + +--- + +## ❓ **FAQ** + +### **Q: Will this affect existing deployments?** +A: No breaking changes. Existing entities show graceful fallbacks, all APIs work unchanged. + +### **Q: What happens if authentication is disabled?** +A: Metadata still works - `created_by` will be `"anonymous"` instead of a username. + +### **Q: How much storage does metadata require?** +A: Minimal - approximately 13 additional nullable text columns per entity. + +### **Q: Can I disable metadata tracking?** +A: Not currently - metadata is core to the audit system. All fields are optional and backwards compatible. + +### **Q: How do I export metadata for compliance?** +A: Use the standard export functionality - metadata is included in all entity exports. + +This comprehensive metadata system provides enterprise-grade audit capabilities while maintaining full backwards compatibility and operational simplicity. diff --git a/docs/docs/overview/features.md b/docs/docs/overview/features.md index 7f70c0e02..8be56910c 100644 --- a/docs/docs/overview/features.md +++ b/docs/docs/overview/features.md @@ -123,6 +123,16 @@ adding auth, caching, federation, and an HTMX-powered Admin UI. * **FastAPI** + Jinja2 + HTMX + Alpine.js * Tailwind CSS for styling +??? info "📊 Audit & Metadata Tracking" + + * **Comprehensive metadata** for all entities (Tools, Resources, Prompts, Servers, Gateways) + * **Creation tracking** - who, when, from where, how + * **Modification history** - change attribution and versioning + * **Federation source** tracking for MCP server entities + * **Bulk import** batch identification + * **Auth-agnostic** - works with/without authentication + * **Backwards compatible** - legacy entities show graceful fallbacks + --- ## 🗄 Persistence, Caching & Observability diff --git a/docs/docs/overview/index.md b/docs/docs/overview/index.md index 4cc5cf745..f447a4bd0 100644 --- a/docs/docs/overview/index.md +++ b/docs/docs/overview/index.md @@ -14,6 +14,7 @@ This section introduces what the Gateway is, how it fits into the MCP ecosystem, - Federation of multiple MCP servers into one composable catalog - Protocol enforcement, health monitoring, and registry centralization - A visual Admin UI to manage everything in real time +- **Comprehensive audit trails** with metadata tracking for all entities - **Comprehensive doctest coverage** ensuring all code examples are tested and verified Whether you're integrating REST APIs, local functions, or full LLM agents, MCP Gateway standardizes access and transport - over HTTP, WebSockets, SSE, StreamableHttp or stdio. diff --git a/docs/docs/overview/ui.md b/docs/docs/overview/ui.md index b7e0c89ac..724a3bd2e 100644 --- a/docs/docs/overview/ui.md +++ b/docs/docs/overview/ui.md @@ -27,6 +27,7 @@ It provides tabbed access to: - **Gateways**: View and manage federated peers, toggle activity status - **Roots**: Register root URIs for agent or resource scoping - **Metrics**: Real-time usage and performance metrics for all entities +- **📊 Metadata Tracking**: View comprehensive audit information in entity detail modals --- @@ -37,6 +38,7 @@ It provides tabbed access to: | Register a tool | Use the Tools tab → Add Tool form | | Bulk import tools | Use API endpoint `/admin/tools/import` (see [Bulk Import](../manage/bulk-import.md)) | | View prompt output | Go to Prompts → click View | +| **View entity metadata** | Click "View" on any entity → scroll to "Metadata" section | | Toggle server activity | Use the "Activate/Deactivate" buttons in Servers tab | | Delete a resource | Navigate to Resources → click Delete (after confirming) | diff --git a/mcpgateway/admin.py b/mcpgateway/admin.py index 002802fd3..8128ac691 100644 --- a/mcpgateway/admin.py +++ b/mcpgateway/admin.py @@ -27,6 +27,7 @@ from pathlib import Path import time from typing import Any, cast, Dict, List, Optional, Union +import uuid # Third-Party from fastapi import APIRouter, Depends, HTTPException, Request, Response @@ -40,6 +41,7 @@ # First-Party from mcpgateway.config import settings from mcpgateway.db import get_db, GlobalConfig +from mcpgateway.db import Tool as DbTool from mcpgateway.models import LogLevel from mcpgateway.schemas import ( GatewayCreate, @@ -80,6 +82,7 @@ from mcpgateway.services.tool_service import ToolError, ToolNotFoundError, ToolService from mcpgateway.utils.create_jwt_token import get_jwt_token from mcpgateway.utils.error_formatter import ErrorFormatter +from mcpgateway.utils.metadata_capture import MetadataCapture from mcpgateway.utils.passthrough_headers import PassthroughHeadersError from mcpgateway.utils.retry_manager import ResilientHttpClient from mcpgateway.utils.security_cookies import set_auth_cookie @@ -1975,7 +1978,20 @@ async def admin_add_tool( try: tool = ToolCreate(**tool_data) LOGGER.debug(f"Validated tool data: {tool.model_dump(by_alias=True)}") - await tool_service.register_tool(db, tool) + + # Extract creation metadata + metadata = MetadataCapture.extract_creation_metadata(request, user) + + await tool_service.register_tool( + db, + tool, + created_by=metadata["created_by"], + created_from_ip=metadata["created_from_ip"], + created_via=metadata["created_via"], + created_user_agent=metadata["created_user_agent"], + import_batch_id=metadata["import_batch_id"], + federation_source=metadata["federation_source"], + ) return JSONResponse( content={"message": "Tool registered successfully!", "success": True}, status_code=200, @@ -2198,7 +2214,23 @@ async def admin_edit_tool( LOGGER.debug(f"Tool update data built: {tool_data}") try: tool = ToolUpdate(**tool_data) # Pydantic validation happens here - await tool_service.update_tool(db, tool_id, tool) + + # Get current tool to extract current version + current_tool = db.get(DbTool, tool_id) + current_version = getattr(current_tool, "version", 0) if current_tool else 0 + + # Extract modification metadata + mod_metadata = MetadataCapture.extract_modification_metadata(request, user, current_version) + + await tool_service.update_tool( + db, + tool_id, + tool, + modified_by=mod_metadata["modified_by"], + modified_from_ip=mod_metadata["modified_from_ip"], + modified_via=mod_metadata["modified_via"], + modified_user_agent=mod_metadata["modified_user_agent"], + ) return JSONResponse(content={"message": "Edit tool successfully", "success": True}, status_code=200) except IntegrityError as ex: error_message = ErrorFormatter.format_database_error(ex) @@ -2658,7 +2690,17 @@ async def admin_add_gateway(request: Request, db: Session = Depends(get_db), use return JSONResponse(content={"success": False, "message": "; ".join(error_ctx)}, status_code=422) try: - await gateway_service.register_gateway(db, gateway) + # Extract creation metadata + metadata = MetadataCapture.extract_creation_metadata(request, user) + + await gateway_service.register_gateway( + db, + gateway, + created_by=metadata["created_by"], + created_from_ip=metadata["created_from_ip"], + created_via=metadata["created_via"], + created_user_agent=metadata["created_user_agent"], + ) return JSONResponse( content={"message": "Gateway registered successfully!", "success": True}, status_code=200, @@ -3097,7 +3139,19 @@ async def admin_add_resource(request: Request, db: Session = Depends(get_db), us content=str(form["content"]), tags=tags, ) - await resource_service.register_resource(db, resource) + + metadata = MetadataCapture.extract_creation_metadata(request, user) + + await resource_service.register_resource( + db, + resource, + created_by=metadata["created_by"], + created_from_ip=metadata["created_from_ip"], + created_via=metadata["created_via"], + created_user_agent=metadata["created_user_agent"], + import_batch_id=metadata["import_batch_id"], + federation_source=metadata["federation_source"], + ) return JSONResponse( content={"message": "Add resource registered successfully!", "success": True}, status_code=200, @@ -3574,7 +3628,7 @@ async def admin_add_prompt(request: Request, db: Session = Depends(get_db), user try: args_json = "[]" args_value = form.get("arguments") - if isinstance(args_value, str): + if isinstance(args_value, str) and args_value.strip(): args_json = args_value arguments = json.loads(args_json) prompt = PromptCreate( @@ -3584,7 +3638,19 @@ async def admin_add_prompt(request: Request, db: Session = Depends(get_db), user arguments=arguments, tags=tags, ) - await prompt_service.register_prompt(db, prompt) + # Extract creation metadata + metadata = MetadataCapture.extract_creation_metadata(request, user) + + await prompt_service.register_prompt( + db, + prompt, + created_by=metadata["created_by"], + created_from_ip=metadata["created_from_ip"], + created_via=metadata["created_via"], + created_user_agent=metadata["created_user_agent"], + import_batch_id=metadata["import_batch_id"], + federation_source=metadata["federation_source"], + ) return JSONResponse( content={"message": "Prompt registered successfully!", "success": True}, status_code=200, @@ -4458,11 +4524,26 @@ async def admin_import_tools( created, errors = [], [] # ---------- import loop ---------- + # Generate import batch ID for this bulk operation + import_batch_id = str(uuid.uuid4()) + + # Extract base metadata for bulk import + base_metadata = MetadataCapture.extract_creation_metadata(request, user, import_batch_id=import_batch_id) + for i, item in enumerate(payload): name = (item or {}).get("name") try: tool = ToolCreate(**item) # pydantic validation - await tool_service.register_tool(db, tool) + await tool_service.register_tool( + db, + tool, + created_by=base_metadata["created_by"], + created_from_ip=base_metadata["created_from_ip"], + created_via="import", # Override to show this is bulk import + created_user_agent=base_metadata["created_user_agent"], + import_batch_id=import_batch_id, + federation_source=base_metadata["federation_source"], + ) created.append({"index": i, "name": name}) except IntegrityError as ex: # The formatter can itself throw; guard it. diff --git a/mcpgateway/alembic/versions/34492f99a0c4_add_comprehensive_metadata_to_all_.py b/mcpgateway/alembic/versions/34492f99a0c4_add_comprehensive_metadata_to_all_.py new file mode 100644 index 000000000..0c1eae9dc --- /dev/null +++ b/mcpgateway/alembic/versions/34492f99a0c4_add_comprehensive_metadata_to_all_.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +"""add_comprehensive_metadata_to_all_entities + +Revision ID: 34492f99a0c4 +Revises: eb17fd368f9d +Create Date: 2025-08-18 08:06:17.141169 + +""" + +# Standard +from typing import Sequence, Union + +# Third-Party +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "34492f99a0c4" +down_revision: Union[str, Sequence[str], None] = "eb17fd368f9d" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add comprehensive metadata columns to all entity tables for audit tracking.""" + tables = ["tools", "resources", "prompts", "servers", "gateways"] + + for table in tables: + # Creation metadata (nullable=True for backwards compatibility) + op.add_column(table, sa.Column("created_by", sa.String(), nullable=True)) + op.add_column(table, sa.Column("created_from_ip", sa.String(), nullable=True)) + op.add_column(table, sa.Column("created_via", sa.String(), nullable=True)) + op.add_column(table, sa.Column("created_user_agent", sa.Text(), nullable=True)) + + # Modification metadata (nullable=True for backwards compatibility) + op.add_column(table, sa.Column("modified_by", sa.String(), nullable=True)) + op.add_column(table, sa.Column("modified_from_ip", sa.String(), nullable=True)) + op.add_column(table, sa.Column("modified_via", sa.String(), nullable=True)) + op.add_column(table, sa.Column("modified_user_agent", sa.Text(), nullable=True)) + + # Source tracking (nullable=True for backwards compatibility) + op.add_column(table, sa.Column("import_batch_id", sa.String(), nullable=True)) + op.add_column(table, sa.Column("federation_source", sa.String(), nullable=True)) + op.add_column(table, sa.Column("version", sa.Integer(), nullable=False, server_default="1")) + + # Create indexes for query performance (PostgreSQL compatible, SQLite ignores) + try: + op.create_index(f"idx_{table}_created_by", table, ["created_by"]) + op.create_index(f"idx_{table}_created_at", table, ["created_at"]) + op.create_index(f"idx_{table}_modified_at", table, ["modified_at"]) + op.create_index(f"idx_{table}_created_via", table, ["created_via"]) + except Exception: # nosec B110 - database compatibility + # SQLite doesn't support all index types, skip silently + pass + + +def downgrade() -> None: + """Remove comprehensive metadata columns from all entity tables.""" + tables = ["tools", "resources", "prompts", "servers", "gateways"] + + for table in tables: + # Drop indexes first (if they exist) + try: + op.drop_index(f"idx_{table}_created_by", table) + op.drop_index(f"idx_{table}_created_at", table) + op.drop_index(f"idx_{table}_modified_at", table) + op.drop_index(f"idx_{table}_created_via", table) + except Exception: # nosec B110 - database compatibility + # Indexes might not exist on SQLite + pass + + # Drop metadata columns + op.drop_column(table, "version") + op.drop_column(table, "federation_source") + op.drop_column(table, "import_batch_id") + op.drop_column(table, "modified_user_agent") + op.drop_column(table, "modified_via") + op.drop_column(table, "modified_from_ip") + op.drop_column(table, "modified_by") + op.drop_column(table, "created_user_agent") + op.drop_column(table, "created_via") + op.drop_column(table, "created_from_ip") + op.drop_column(table, "created_by") diff --git a/mcpgateway/db.py b/mcpgateway/db.py index e1f0573a8..90d723c85 100644 --- a/mcpgateway/db.py +++ b/mcpgateway/db.py @@ -348,6 +348,21 @@ class Tool(Base): jsonpath_filter: Mapped[str] = mapped_column(default="") tags: Mapped[List[str]] = mapped_column(JSON, default=list, nullable=False) + # Comprehensive metadata for audit tracking + created_by: Mapped[Optional[str]] = mapped_column(String, nullable=True) + created_from_ip: Mapped[Optional[str]] = mapped_column(String, nullable=True) + created_via: Mapped[Optional[str]] = mapped_column(String, nullable=True) + created_user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + modified_by: Mapped[Optional[str]] = mapped_column(String, nullable=True) + modified_from_ip: Mapped[Optional[str]] = mapped_column(String, nullable=True) + modified_via: Mapped[Optional[str]] = mapped_column(String, nullable=True) + modified_user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + import_batch_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) + federation_source: Mapped[Optional[str]] = mapped_column(String, nullable=True) + version: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + # Request type and authentication fields auth_type: Mapped[Optional[str]] = mapped_column(default=None) # "basic", "bearer", or None auth_value: Mapped[Optional[str]] = mapped_column(default=None) @@ -593,6 +608,22 @@ class Resource(Base): updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now) is_active: Mapped[bool] = mapped_column(default=True) tags: Mapped[List[str]] = mapped_column(JSON, default=list, nullable=False) + + # Comprehensive metadata for audit tracking + created_by: Mapped[Optional[str]] = mapped_column(String, nullable=True) + created_from_ip: Mapped[Optional[str]] = mapped_column(String, nullable=True) + created_via: Mapped[Optional[str]] = mapped_column(String, nullable=True) + created_user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + modified_by: Mapped[Optional[str]] = mapped_column(String, nullable=True) + modified_from_ip: Mapped[Optional[str]] = mapped_column(String, nullable=True) + modified_via: Mapped[Optional[str]] = mapped_column(String, nullable=True) + modified_user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + import_batch_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) + federation_source: Mapped[Optional[str]] = mapped_column(String, nullable=True) + version: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + metrics: Mapped[List["ResourceMetric"]] = relationship("ResourceMetric", back_populates="resource", cascade="all, delete-orphan") # Content storage - can be text or binary @@ -804,6 +835,22 @@ class Prompt(Base): updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now) is_active: Mapped[bool] = mapped_column(default=True) tags: Mapped[List[str]] = mapped_column(JSON, default=list, nullable=False) + + # Comprehensive metadata for audit tracking + created_by: Mapped[Optional[str]] = mapped_column(String, nullable=True) + created_from_ip: Mapped[Optional[str]] = mapped_column(String, nullable=True) + created_via: Mapped[Optional[str]] = mapped_column(String, nullable=True) + created_user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + modified_by: Mapped[Optional[str]] = mapped_column(String, nullable=True) + modified_from_ip: Mapped[Optional[str]] = mapped_column(String, nullable=True) + modified_via: Mapped[Optional[str]] = mapped_column(String, nullable=True) + modified_user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + import_batch_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) + federation_source: Mapped[Optional[str]] = mapped_column(String, nullable=True) + version: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + metrics: Mapped[List["PromptMetric"]] = relationship("PromptMetric", back_populates="prompt", cascade="all, delete-orphan") gateway_id: Mapped[Optional[str]] = mapped_column(ForeignKey("gateways.id")) @@ -972,6 +1019,22 @@ class Server(Base): updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now) is_active: Mapped[bool] = mapped_column(default=True) tags: Mapped[List[str]] = mapped_column(JSON, default=list, nullable=False) + + # Comprehensive metadata for audit tracking + created_by: Mapped[Optional[str]] = mapped_column(String, nullable=True) + created_from_ip: Mapped[Optional[str]] = mapped_column(String, nullable=True) + created_via: Mapped[Optional[str]] = mapped_column(String, nullable=True) + created_user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + modified_by: Mapped[Optional[str]] = mapped_column(String, nullable=True) + modified_from_ip: Mapped[Optional[str]] = mapped_column(String, nullable=True) + modified_via: Mapped[Optional[str]] = mapped_column(String, nullable=True) + modified_user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + import_batch_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) + federation_source: Mapped[Optional[str]] = mapped_column(String, nullable=True) + version: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + metrics: Mapped[List["ServerMetric"]] = relationship("ServerMetric", back_populates="server", cascade="all, delete-orphan") # Many-to-many relationships for associated items @@ -1108,6 +1171,21 @@ class Gateway(Base): last_seen: Mapped[Optional[datetime]] tags: Mapped[List[str]] = mapped_column(JSON, default=list, nullable=False) + # Comprehensive metadata for audit tracking + created_by: Mapped[Optional[str]] = mapped_column(String, nullable=True) + created_from_ip: Mapped[Optional[str]] = mapped_column(String, nullable=True) + created_via: Mapped[Optional[str]] = mapped_column(String, nullable=True) + created_user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + modified_by: Mapped[Optional[str]] = mapped_column(String, nullable=True) + modified_from_ip: Mapped[Optional[str]] = mapped_column(String, nullable=True) + modified_via: Mapped[Optional[str]] = mapped_column(String, nullable=True) + modified_user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + import_batch_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) + federation_source: Mapped[Optional[str]] = mapped_column(String, nullable=True) + version: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + # Header passthrough configuration passthrough_headers: Mapped[Optional[List[str]]] = mapped_column(JSON, nullable=True) # Store list of strings as JSON array diff --git a/mcpgateway/main.py b/mcpgateway/main.py index 8e7e03175..b37c540c6 100644 --- a/mcpgateway/main.py +++ b/mcpgateway/main.py @@ -58,6 +58,7 @@ from mcpgateway.config import jsonpath_modifier, settings from mcpgateway.db import Prompt as DbPrompt from mcpgateway.db import PromptMetric, refresh_slugs_on_startup, SessionLocal +from mcpgateway.db import Tool as DbTool from mcpgateway.handlers.sampling import SamplingHandler from mcpgateway.middleware.security_headers import SecurityHeadersMiddleware from mcpgateway.models import InitializeResult, ListResourceTemplatesResult, LogLevel, ResourceContent, Root @@ -103,6 +104,7 @@ from mcpgateway.transports.streamablehttp_transport import SessionManagerWrapper, streamable_http_auth from mcpgateway.utils.db_isready import wait_for_db_ready from mcpgateway.utils.error_formatter import ErrorFormatter +from mcpgateway.utils.metadata_capture import MetadataCapture from mcpgateway.utils.passthrough_headers import set_global_passthrough_headers from mcpgateway.utils.redis_isready import wait_for_redis_ready from mcpgateway.utils.retry_manager import ResilientHttpClient @@ -1245,12 +1247,13 @@ async def list_tools( @tool_router.post("", response_model=ToolRead) @tool_router.post("/", response_model=ToolRead) -async def create_tool(tool: ToolCreate, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> ToolRead: +async def create_tool(tool: ToolCreate, request: Request, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> ToolRead: """ Creates a new tool in the system. Args: tool (ToolCreate): The data needed to create the tool. + request (Request): The FastAPI request object for metadata extraction. db (Session): The database session dependency. user (str): The authenticated user making the request. @@ -1261,8 +1264,21 @@ async def create_tool(tool: ToolCreate, db: Session = Depends(get_db), user: str HTTPException: If the tool name already exists or other validation errors occur. """ try: + + # Extract metadata from request + metadata = MetadataCapture.extract_creation_metadata(request, user) + logger.debug(f"User {user} is creating a new tool") - return await tool_service.register_tool(db, tool) + return await tool_service.register_tool( + db, + tool, + created_by=metadata["created_by"], + created_from_ip=metadata["created_from_ip"], + created_via=metadata["created_via"], + created_user_agent=metadata["created_user_agent"], + import_batch_id=metadata["import_batch_id"], + federation_source=metadata["federation_source"], + ) except Exception as ex: logger.error(f"Error while creating tool: {ex}") if isinstance(ex, ToolNameConflictError): @@ -1324,6 +1340,7 @@ async def get_tool( async def update_tool( tool_id: str, tool: ToolUpdate, + request: Request, db: Session = Depends(get_db), user: str = Depends(require_auth), ) -> ToolRead: @@ -1333,6 +1350,7 @@ async def update_tool( Args: tool_id (str): The ID of the tool to update. tool (ToolUpdate): The updated tool information. + request (Request): The FastAPI request object for metadata extraction. db (Session): The database session dependency. user (str): The authenticated user making the request. @@ -1343,8 +1361,24 @@ async def update_tool( HTTPException: If an error occurs during the update. """ try: + + # Get current tool to extract current version + current_tool = db.get(DbTool, tool_id) + current_version = getattr(current_tool, "version", 0) if current_tool else 0 + + # Extract modification metadata + mod_metadata = MetadataCapture.extract_modification_metadata(request, user, current_version) + logger.debug(f"User {user} is updating tool with ID {tool_id}") - return await tool_service.update_tool(db, tool_id, tool) + return await tool_service.update_tool( + db, + tool_id, + tool, + modified_by=mod_metadata["modified_by"], + modified_from_ip=mod_metadata["modified_from_ip"], + modified_via=mod_metadata["modified_via"], + modified_user_agent=mod_metadata["modified_user_agent"], + ) except Exception as ex: if isinstance(ex, ToolNotFoundError): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(ex)) @@ -1517,6 +1551,7 @@ async def list_resources( @resource_router.post("/", response_model=ResourceRead) async def create_resource( resource: ResourceCreate, + request: Request, db: Session = Depends(get_db), user: str = Depends(require_auth), ) -> ResourceRead: @@ -1525,6 +1560,7 @@ async def create_resource( Args: resource (ResourceCreate): Data for the new resource. + request (Request): FastAPI request object for metadata extraction. db (Session): Database session. user (str): Authenticated user. @@ -1536,8 +1572,18 @@ async def create_resource( """ logger.debug(f"User {user} is creating a new resource") try: - result = await resource_service.register_resource(db, resource) - return result + metadata = MetadataCapture.extract_creation_metadata(request, user) + + return await resource_service.register_resource( + db, + resource, + created_by=metadata["created_by"], + created_from_ip=metadata["created_from_ip"], + created_via=metadata["created_via"], + created_user_agent=metadata["created_user_agent"], + import_batch_id=metadata["import_batch_id"], + federation_source=metadata["federation_source"], + ) except ResourceURIConflictError as e: raise HTTPException(status_code=409, detail=str(e)) except ResourceError as e: @@ -1741,6 +1787,7 @@ async def list_prompts( @prompt_router.post("/", response_model=PromptRead) async def create_prompt( prompt: PromptCreate, + request: Request, db: Session = Depends(get_db), user: str = Depends(require_auth), ) -> PromptRead: @@ -1749,6 +1796,7 @@ async def create_prompt( Args: prompt (PromptCreate): Payload describing the prompt to create. + request (Request): The FastAPI request object for metadata extraction. db (Session): Active SQLAlchemy session. user (str): Authenticated username. @@ -1762,7 +1810,19 @@ async def create_prompt( """ logger.debug(f"User: {user} requested to create prompt: {prompt}") try: - return await prompt_service.register_prompt(db, prompt) + # Extract metadata from request + metadata = MetadataCapture.extract_creation_metadata(request, user) + + return await prompt_service.register_prompt( + db, + prompt, + created_by=metadata["created_by"], + created_from_ip=metadata["created_from_ip"], + created_via=metadata["created_via"], + created_user_agent=metadata["created_user_agent"], + import_batch_id=metadata["import_batch_id"], + federation_source=metadata["federation_source"], + ) except Exception as e: if isinstance(e, PromptNameConflictError): # If the prompt name already exists, return a 409 Conflict error @@ -2053,6 +2113,7 @@ async def list_gateways( @gateway_router.post("/", response_model=GatewayRead) async def register_gateway( gateway: GatewayCreate, + request: Request, db: Session = Depends(get_db), user: str = Depends(require_auth), ) -> GatewayRead: @@ -2061,6 +2122,7 @@ async def register_gateway( Args: gateway: Gateway creation data. + request: The FastAPI request object for metadata extraction. db: Database session. user: Authenticated user. @@ -2069,7 +2131,17 @@ async def register_gateway( """ logger.debug(f"User '{user}' requested to register gateway: {gateway}") try: - return await gateway_service.register_gateway(db, gateway) + # Extract metadata from request + metadata = MetadataCapture.extract_creation_metadata(request, user) + + return await gateway_service.register_gateway( + db, + gateway, + created_by=metadata["created_by"], + created_from_ip=metadata["created_from_ip"], + created_via=metadata["created_via"], + created_user_agent=metadata["created_user_agent"], + ) except Exception as ex: if isinstance(ex, GatewayConnectionError): return JSONResponse(content={"message": "Unable to connect to gateway"}, status_code=status.HTTP_503_SERVICE_UNAVAILABLE) diff --git a/mcpgateway/middleware/security_headers.py b/mcpgateway/middleware/security_headers.py index b4d1124bb..5cc7d3b72 100644 --- a/mcpgateway/middleware/security_headers.py +++ b/mcpgateway/middleware/security_headers.py @@ -75,7 +75,7 @@ async def dispatch(self, request: Request, call_next) -> Response: csp_directives = [ "default-src 'self'", "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdnjs.cloudflare.com https://cdn.tailwindcss.com https://cdn.jsdelivr.net", - "style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com", + "style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net", "img-src 'self' data: https:", "font-src 'self' data:", "connect-src 'self' ws: wss: https:", diff --git a/mcpgateway/schemas.py b/mcpgateway/schemas.py index 9f0a68c1e..24238937e 100644 --- a/mcpgateway/schemas.py +++ b/mcpgateway/schemas.py @@ -836,6 +836,21 @@ class ToolRead(BaseModelWithConfigDict): original_name_slug: str tags: List[str] = Field(default_factory=list, description="Tags for categorizing the tool") + # Comprehensive metadata for audit tracking + created_by: Optional[str] = Field(None, description="Username who created this entity") + created_from_ip: Optional[str] = Field(None, description="IP address of creator") + created_via: Optional[str] = Field(None, description="Creation method: ui|api|import|federation") + created_user_agent: Optional[str] = Field(None, description="User agent of creation request") + + modified_by: Optional[str] = Field(None, description="Username who last modified this entity") + modified_from_ip: Optional[str] = Field(None, description="IP address of last modifier") + modified_via: Optional[str] = Field(None, description="Modification method") + modified_user_agent: Optional[str] = Field(None, description="User agent of modification request") + + import_batch_id: Optional[str] = Field(None, description="UUID of bulk import batch") + federation_source: Optional[str] = Field(None, description="Source gateway for federated entities") + version: Optional[int] = Field(1, description="Entity version for change tracking") + class ToolInvocation(BaseModelWithConfigDict): """Schema for tool invocation requests. @@ -1258,6 +1273,21 @@ class ResourceRead(BaseModelWithConfigDict): metrics: ResourceMetrics tags: List[str] = Field(default_factory=list, description="Tags for categorizing the resource") + # Comprehensive metadata for audit tracking + created_by: Optional[str] = Field(None, description="Username who created this entity") + created_from_ip: Optional[str] = Field(None, description="IP address of creator") + created_via: Optional[str] = Field(None, description="Creation method: ui|api|import|federation") + created_user_agent: Optional[str] = Field(None, description="User agent of creation request") + + modified_by: Optional[str] = Field(None, description="Username who last modified this entity") + modified_from_ip: Optional[str] = Field(None, description="IP address of last modifier") + modified_via: Optional[str] = Field(None, description="Modification method") + modified_user_agent: Optional[str] = Field(None, description="User agent of modification request") + + import_batch_id: Optional[str] = Field(None, description="UUID of bulk import batch") + federation_source: Optional[str] = Field(None, description="Source gateway for federated entities") + version: Optional[int] = Field(1, description="Entity version for change tracking") + class ResourceSubscription(BaseModelWithConfigDict): """Schema for resource subscriptions. @@ -1715,6 +1745,21 @@ class PromptRead(BaseModelWithConfigDict): tags: List[str] = Field(default_factory=list, description="Tags for categorizing the prompt") metrics: PromptMetrics + # Comprehensive metadata for audit tracking + created_by: Optional[str] = Field(None, description="Username who created this entity") + created_from_ip: Optional[str] = Field(None, description="IP address of creator") + created_via: Optional[str] = Field(None, description="Creation method: ui|api|import|federation") + created_user_agent: Optional[str] = Field(None, description="User agent of creation request") + + modified_by: Optional[str] = Field(None, description="Username who last modified this entity") + modified_from_ip: Optional[str] = Field(None, description="IP address of last modifier") + modified_via: Optional[str] = Field(None, description="Modification method") + modified_user_agent: Optional[str] = Field(None, description="User agent of modification request") + + import_batch_id: Optional[str] = Field(None, description="UUID of bulk import batch") + federation_source: Optional[str] = Field(None, description="Source gateway for federated entities") + version: Optional[int] = Field(1, description="Entity version for change tracking") + class PromptInvocation(BaseModelWithConfigDict): """Schema for prompt invocation requests. @@ -2265,6 +2310,21 @@ class GatewayRead(BaseModelWithConfigDict): auth_header_value: Optional[str] = Field(None, description="vallue for custom headers authentication") tags: List[str] = Field(default_factory=list, description="Tags for categorizing the gateway") + # Comprehensive metadata for audit tracking + created_by: Optional[str] = Field(None, description="Username who created this entity") + created_from_ip: Optional[str] = Field(None, description="IP address of creator") + created_via: Optional[str] = Field(None, description="Creation method: ui|api|import|federation") + created_user_agent: Optional[str] = Field(None, description="User agent of creation request") + + modified_by: Optional[str] = Field(None, description="Username who last modified this entity") + modified_from_ip: Optional[str] = Field(None, description="IP address of last modifier") + modified_via: Optional[str] = Field(None, description="Modification method") + modified_user_agent: Optional[str] = Field(None, description="User agent of modification request") + + import_batch_id: Optional[str] = Field(None, description="UUID of bulk import batch") + federation_source: Optional[str] = Field(None, description="Source gateway for federated entities") + version: Optional[int] = Field(1, description="Entity version for change tracking") + slug: str = Field(None, description="Slug for gateway endpoint URL") # This will be the main method to automatically populate fields @@ -2851,6 +2911,21 @@ class ServerRead(BaseModelWithConfigDict): metrics: ServerMetrics tags: List[str] = Field(default_factory=list, description="Tags for categorizing the server") + # Comprehensive metadata for audit tracking + created_by: Optional[str] = Field(None, description="Username who created this entity") + created_from_ip: Optional[str] = Field(None, description="IP address of creator") + created_via: Optional[str] = Field(None, description="Creation method: ui|api|import|federation") + created_user_agent: Optional[str] = Field(None, description="User agent of creation request") + + modified_by: Optional[str] = Field(None, description="Username who last modified this entity") + modified_from_ip: Optional[str] = Field(None, description="IP address of last modifier") + modified_via: Optional[str] = Field(None, description="Modification method") + modified_user_agent: Optional[str] = Field(None, description="User agent of modification request") + + import_batch_id: Optional[str] = Field(None, description="UUID of bulk import batch") + federation_source: Optional[str] = Field(None, description="Source gateway for federated entities") + version: Optional[int] = Field(1, description="Entity version for change tracking") + @model_validator(mode="before") @classmethod def populate_associated_ids(cls, values): diff --git a/mcpgateway/services/gateway_service.py b/mcpgateway/services/gateway_service.py index e85dced2a..79ab646d5 100644 --- a/mcpgateway/services/gateway_service.py +++ b/mcpgateway/services/gateway_service.py @@ -390,12 +390,24 @@ async def shutdown(self) -> None: self._active_gateways.clear() logger.info("Gateway service shutdown complete") - async def register_gateway(self, db: Session, gateway: GatewayCreate) -> GatewayRead: + async def register_gateway( + self, + db: Session, + gateway: GatewayCreate, + created_by: Optional[str] = None, + created_from_ip: Optional[str] = None, + created_via: Optional[str] = None, + created_user_agent: Optional[str] = None, + ) -> GatewayRead: """Register a new gateway. Args: db: Database session gateway: Gateway creation schema + created_by: Username who created this gateway + created_from_ip: IP address of creator + created_via: Creation method (ui, api, federation) + created_user_agent: User agent of creation request Returns: Created gateway information @@ -463,6 +475,13 @@ async def register_gateway(self, db: Session, gateway: GatewayCreate) -> Gateway jsonpath_filter=tool.jsonpath_filter, auth_type=auth_type, auth_value=auth_value, + # Federation metadata + created_by=created_by or "system", + created_from_ip=created_from_ip, + created_via="federation", # These are federated tools + created_user_agent=created_user_agent, + federation_source=gateway.name, + version=1, ) for tool in tools ] @@ -475,6 +494,13 @@ async def register_gateway(self, db: Session, gateway: GatewayCreate) -> Gateway description=resource.description, mime_type=resource.mime_type, template=resource.template, + # Federation metadata + created_by=created_by or "system", + created_from_ip=created_from_ip, + created_via="federation", # These are federated resources + created_user_agent=created_user_agent, + federation_source=gateway.name, + version=1, ) for resource in resources ] @@ -486,6 +512,13 @@ async def register_gateway(self, db: Session, gateway: GatewayCreate) -> Gateway description=prompt.description, template=prompt.template if hasattr(prompt, "template") else "", argument_schema={}, # Use argument_schema instead of arguments + # Federation metadata + created_by=created_by or "system", + created_from_ip=created_from_ip, + created_via="federation", # These are federated prompts + created_user_agent=created_user_agent, + federation_source=gateway.name, + version=1, ) for prompt in prompts ] @@ -505,6 +538,12 @@ async def register_gateway(self, db: Session, gateway: GatewayCreate) -> Gateway tools=tools, resources=db_resources, prompts=db_prompts, + # Gateway metadata + created_by=created_by, + created_from_ip=created_from_ip, + created_via=created_via or "api", + created_user_agent=created_user_agent, + version=1, ) # Add to DB diff --git a/mcpgateway/services/prompt_service.py b/mcpgateway/services/prompt_service.py index 608b8b277..ebe38bd02 100644 --- a/mcpgateway/services/prompt_service.py +++ b/mcpgateway/services/prompt_service.py @@ -240,12 +240,28 @@ def _convert_db_prompt(self, db_prompt: DbPrompt) -> Dict[str, Any]: "tags": db_prompt.tags or [], } - async def register_prompt(self, db: Session, prompt: PromptCreate) -> PromptRead: + async def register_prompt( + self, + db: Session, + prompt: PromptCreate, + created_by: Optional[str] = None, + created_from_ip: Optional[str] = None, + created_via: Optional[str] = None, + created_user_agent: Optional[str] = None, + import_batch_id: Optional[str] = None, + federation_source: Optional[str] = None, + ) -> PromptRead: """Register a new prompt template. Args: db: Database session prompt: Prompt creation schema + created_by: Username who created this prompt + created_from_ip: IP address of creator + created_via: Creation method (ui, api, import, federation) + created_user_agent: User agent of creation request + import_batch_id: UUID for bulk import operations + federation_source: Source gateway for federated prompts Returns: Created prompt information @@ -298,6 +314,14 @@ async def register_prompt(self, db: Session, prompt: PromptCreate) -> PromptRead template=prompt.template, argument_schema=argument_schema, tags=prompt.tags, + # Metadata fields + created_by=created_by, + created_from_ip=created_from_ip, + created_via=created_via, + created_user_agent=created_user_agent, + import_batch_id=import_batch_id, + federation_source=federation_source, + version=1, ) # Add to DB diff --git a/mcpgateway/services/resource_service.py b/mcpgateway/services/resource_service.py index 46b6e953d..18454cb64 100644 --- a/mcpgateway/services/resource_service.py +++ b/mcpgateway/services/resource_service.py @@ -220,12 +220,28 @@ def _convert_resource_to_read(self, resource: DbResource) -> ResourceRead: resource_dict["tags"] = resource.tags or [] return ResourceRead.model_validate(resource_dict) - async def register_resource(self, db: Session, resource: ResourceCreate) -> ResourceRead: + async def register_resource( + self, + db: Session, + resource: ResourceCreate, + created_by: Optional[str] = None, + created_from_ip: Optional[str] = None, + created_via: Optional[str] = None, + created_user_agent: Optional[str] = None, + import_batch_id: Optional[str] = None, + federation_source: Optional[str] = None, + ) -> ResourceRead: """Register a new resource. Args: db: Database session resource: Resource creation schema + created_by: User who created the resource + created_from_ip: IP address of the creator + created_via: Method used to create the resource (e.g., API, UI) + created_user_agent: User agent of the creator + import_batch_id: Optional batch ID for bulk imports + federation_source: Optional source of the resource if federated Returns: Created resource information @@ -272,6 +288,13 @@ async def register_resource(self, db: Session, resource: ResourceCreate) -> Reso binary_content=(resource.content.encode() if is_text and isinstance(resource.content, str) else resource.content if isinstance(resource.content, bytes) else None), size=len(resource.content) if resource.content else 0, tags=resource.tags or [], + created_by=created_by, + created_from_ip=created_from_ip, + created_via=created_via, + created_user_agent=created_user_agent, + import_batch_id=import_batch_id, + federation_source=federation_source, + version=1, ) # Add to DB diff --git a/mcpgateway/services/tool_service.py b/mcpgateway/services/tool_service.py index 381727806..f891b2bcb 100644 --- a/mcpgateway/services/tool_service.py +++ b/mcpgateway/services/tool_service.py @@ -308,12 +308,28 @@ async def _record_tool_metric(self, db: Session, tool: DbTool, start_time: float db.add(metric) db.commit() - async def register_tool(self, db: Session, tool: ToolCreate) -> ToolRead: + async def register_tool( + self, + db: Session, + tool: ToolCreate, + created_by: Optional[str] = None, + created_from_ip: Optional[str] = None, + created_via: Optional[str] = None, + created_user_agent: Optional[str] = None, + import_batch_id: Optional[str] = None, + federation_source: Optional[str] = None, + ) -> ToolRead: """Register a new tool. Args: db: Database session. tool: Tool creation schema. + created_by: Username who created this tool. + created_from_ip: IP address of creator. + created_via: Creation method (ui, api, import, federation). + created_user_agent: User agent of creation request. + import_batch_id: UUID for bulk import operations. + federation_source: Source gateway for federated tools. Returns: Created tool information. @@ -368,6 +384,14 @@ async def register_tool(self, db: Session, tool: ToolCreate) -> ToolRead: auth_value=auth_value, gateway_id=tool.gateway_id, tags=tool.tags or [], + # Metadata fields + created_by=created_by, + created_from_ip=created_from_ip, + created_via=created_via, + created_user_agent=created_user_agent, + import_batch_id=import_batch_id, + federation_source=federation_source, + version=1, ) db.add(db_tool) db.commit() @@ -863,7 +887,16 @@ async def connect_to_streamablehttp_server(server_url: str): span.set_attribute("duration.ms", (time.monotonic() - start_time) * 1000) await self._record_tool_metric(db, tool, start_time, success, error_message) - async def update_tool(self, db: Session, tool_id: str, tool_update: ToolUpdate) -> ToolRead: + async def update_tool( + self, + db: Session, + tool_id: str, + tool_update: ToolUpdate, + modified_by: Optional[str] = None, + modified_from_ip: Optional[str] = None, + modified_via: Optional[str] = None, + modified_user_agent: Optional[str] = None, + ) -> ToolRead: """ Update an existing tool. @@ -871,6 +904,10 @@ async def update_tool(self, db: Session, tool_id: str, tool_update: ToolUpdate) db (Session): The SQLAlchemy database session. tool_id (str): The unique identifier of the tool. tool_update (ToolUpdate): Tool update schema with new data. + modified_by (Optional[str]): Username who modified this tool. + modified_from_ip (Optional[str]): IP address of modifier. + modified_via (Optional[str]): Modification method (ui, api). + modified_user_agent (Optional[str]): User agent of modification request. Returns: The updated ToolRead object. @@ -933,6 +970,22 @@ async def update_tool(self, db: Session, tool_id: str, tool_update: ToolUpdate) if tool_update.tags is not None: tool.tags = tool_update.tags + # Update modification metadata + if modified_by is not None: + tool.modified_by = modified_by + if modified_from_ip is not None: + tool.modified_from_ip = modified_from_ip + if modified_via is not None: + tool.modified_via = modified_via + if modified_user_agent is not None: + tool.modified_user_agent = modified_user_agent + + # Increment version + if hasattr(tool, "version") and tool.version is not None: + tool.version += 1 + else: + tool.version = 1 + tool.updated_at = datetime.now(timezone.utc) db.commit() db.refresh(tool) diff --git a/mcpgateway/static/admin.js b/mcpgateway/static/admin.js index d8fed398b..1fb196aa8 100644 --- a/mcpgateway/static/admin.js +++ b/mcpgateway/static/admin.js @@ -2292,6 +2292,73 @@ async function viewResource(resourceUri) { container.appendChild(metricsDiv); } + // Add metadata section + const metadataDiv = document.createElement("div"); + metadataDiv.className = "mt-6 border-t pt-4"; + + const metadataTitle = document.createElement("strong"); + metadataTitle.textContent = "Metadata:"; + metadataDiv.appendChild(metadataTitle); + + const metadataGrid = document.createElement("div"); + metadataGrid.className = "grid grid-cols-2 gap-4 mt-2 text-sm"; + + const metadataFields = [ + { + label: "Created By", + value: resource.createdBy || "Legacy Entity", + }, + { + label: "Created At", + value: resource.createdAt + ? new Date(resource.createdAt).toLocaleString() + : "Pre-metadata", + }, + { + label: "Created From", + value: resource.createdFromIp || "Unknown", + }, + { + label: "Created Via", + value: resource.createdVia || "Unknown", + }, + { + label: "Last Modified By", + value: resource.modifiedBy || "N/A", + }, + { + label: "Last Modified At", + value: resource.modifiedAt + ? new Date(resource.modifiedAt).toLocaleString() + : "N/A", + }, + { label: "Version", value: resource.version || "1" }, + { + label: "Import Batch", + value: resource.importBatchId || "N/A", + }, + ]; + + metadataFields.forEach((field) => { + const fieldDiv = document.createElement("div"); + + const labelSpan = document.createElement("span"); + labelSpan.className = + "font-medium text-gray-600 dark:text-gray-400"; + labelSpan.textContent = field.label + ":"; + + const valueSpan = document.createElement("span"); + valueSpan.className = "ml-2"; + valueSpan.textContent = field.value; + + fieldDiv.appendChild(labelSpan); + fieldDiv.appendChild(valueSpan); + metadataGrid.appendChild(fieldDiv); + }); + + metadataDiv.appendChild(metadataGrid); + container.appendChild(metadataDiv); + // Replace content safely resourceDetailsDiv.innerHTML = ""; resourceDetailsDiv.appendChild(container); @@ -2580,6 +2647,67 @@ async function viewPrompt(promptName) { container.appendChild(metricsDiv); } + // Add metadata section + const metadataDiv = document.createElement("div"); + metadataDiv.className = "mt-6 border-t pt-4"; + + const metadataTitle = document.createElement("strong"); + metadataTitle.textContent = "Metadata:"; + metadataDiv.appendChild(metadataTitle); + + const metadataGrid = document.createElement("div"); + metadataGrid.className = "grid grid-cols-2 gap-4 mt-2 text-sm"; + + const metadataFields = [ + { + label: "Created By", + value: prompt.createdBy || "Legacy Entity", + }, + { + label: "Created At", + value: prompt.createdAt + ? new Date(prompt.createdAt).toLocaleString() + : "Pre-metadata", + }, + { + label: "Created From", + value: prompt.createdFromIp || "Unknown", + }, + { label: "Created Via", value: prompt.createdVia || "Unknown" }, + { + label: "Last Modified By", + value: prompt.modifiedBy || "N/A", + }, + { + label: "Last Modified At", + value: prompt.modifiedAt + ? new Date(prompt.modifiedAt).toLocaleString() + : "N/A", + }, + { label: "Version", value: prompt.version || "1" }, + { label: "Import Batch", value: prompt.importBatchId || "N/A" }, + ]; + + metadataFields.forEach((field) => { + const fieldDiv = document.createElement("div"); + + const labelSpan = document.createElement("span"); + labelSpan.className = + "font-medium text-gray-600 dark:text-gray-400"; + labelSpan.textContent = field.label + ":"; + + const valueSpan = document.createElement("span"); + valueSpan.className = "ml-2"; + valueSpan.textContent = field.value; + + fieldDiv.appendChild(labelSpan); + fieldDiv.appendChild(valueSpan); + metadataGrid.appendChild(fieldDiv); + }); + + metadataDiv.appendChild(metadataGrid); + container.appendChild(metadataDiv); + // Replace content safely promptDetailsDiv.innerHTML = ""; promptDetailsDiv.appendChild(container); @@ -2792,6 +2920,73 @@ async function viewGateway(gatewayId) { statusP.appendChild(statusSpan); container.appendChild(statusP); + // Add metadata section + const metadataDiv = document.createElement("div"); + metadataDiv.className = "mt-6 border-t pt-4"; + + const metadataTitle = document.createElement("strong"); + metadataTitle.textContent = "Metadata:"; + metadataDiv.appendChild(metadataTitle); + + const metadataGrid = document.createElement("div"); + metadataGrid.className = "grid grid-cols-2 gap-4 mt-2 text-sm"; + + const metadataFields = [ + { + label: "Created By", + value: gateway.createdBy || "Legacy Entity", + }, + { + label: "Created At", + value: gateway.createdAt + ? new Date(gateway.createdAt).toLocaleString() + : "Pre-metadata", + }, + { + label: "Created From", + value: gateway.createdFromIp || "Unknown", + }, + { + label: "Created Via", + value: gateway.createdVia || "Unknown", + }, + { + label: "Last Modified By", + value: gateway.modifiedBy || "N/A", + }, + { + label: "Last Modified At", + value: gateway.modifiedAt + ? new Date(gateway.modifiedAt).toLocaleString() + : "N/A", + }, + { label: "Version", value: gateway.version || "1" }, + { + label: "Import Batch", + value: gateway.importBatchId || "N/A", + }, + ]; + + metadataFields.forEach((field) => { + const fieldDiv = document.createElement("div"); + + const labelSpan = document.createElement("span"); + labelSpan.className = + "font-medium text-gray-600 dark:text-gray-400"; + labelSpan.textContent = field.label + ":"; + + const valueSpan = document.createElement("span"); + valueSpan.className = "ml-2"; + valueSpan.textContent = field.value; + + fieldDiv.appendChild(labelSpan); + fieldDiv.appendChild(valueSpan); + metadataGrid.appendChild(fieldDiv); + }); + + metadataDiv.appendChild(metadataGrid); + container.appendChild(metadataDiv); + gatewayDetailsDiv.innerHTML = ""; gatewayDetailsDiv.appendChild(container); } @@ -3052,6 +3247,67 @@ async function viewServer(serverId) { statusP.appendChild(statusSpan); container.appendChild(statusP); + // Add metadata section + const metadataDiv = document.createElement("div"); + metadataDiv.className = "mt-6 border-t pt-4"; + + const metadataTitle = document.createElement("strong"); + metadataTitle.textContent = "Metadata:"; + metadataDiv.appendChild(metadataTitle); + + const metadataGrid = document.createElement("div"); + metadataGrid.className = "grid grid-cols-2 gap-4 mt-2 text-sm"; + + const metadataFields = [ + { + label: "Created By", + value: server.createdBy || "Legacy Entity", + }, + { + label: "Created At", + value: server.createdAt + ? new Date(server.createdAt).toLocaleString() + : "Pre-metadata", + }, + { + label: "Created From", + value: server.createdFromIp || "Unknown", + }, + { label: "Created Via", value: server.createdVia || "Unknown" }, + { + label: "Last Modified By", + value: server.modifiedBy || "N/A", + }, + { + label: "Last Modified At", + value: server.modifiedAt + ? new Date(server.modifiedAt).toLocaleString() + : "N/A", + }, + { label: "Version", value: server.version || "1" }, + { label: "Import Batch", value: server.importBatchId || "N/A" }, + ]; + + metadataFields.forEach((field) => { + const fieldDiv = document.createElement("div"); + + const labelSpan = document.createElement("span"); + labelSpan.className = + "font-medium text-gray-600 dark:text-gray-400"; + labelSpan.textContent = field.label + ":"; + + const valueSpan = document.createElement("span"); + valueSpan.className = "ml-2"; + valueSpan.textContent = field.value; + + fieldDiv.appendChild(labelSpan); + fieldDiv.appendChild(valueSpan); + metadataGrid.appendChild(fieldDiv); + }); + + metadataDiv.appendChild(metadataGrid); + container.appendChild(metadataDiv); + serverDetailsDiv.innerHTML = ""; serverDetailsDiv.appendChild(container); } @@ -4914,6 +5170,43 @@ async function viewTool(toolId) {
  • Last Execution Time:
  • +
    + Metadata: +
    +
    + Created By: + +
    +
    + Created At: + +
    +
    + Created From: + +
    +
    + Created Via: + +
    +
    + Last Modified By: + +
    +
    + Last Modified At: + +
    +
    + Version: + +
    +
    + Import Batch: + +
    +
    +
    `; @@ -5010,6 +5303,38 @@ async function viewTool(toolId) { ".metric-last-time", tool.metrics?.lastExecutionTime ?? "N/A", ); + + // Set metadata fields safely with appropriate fallbacks for legacy entities + setTextSafely( + ".metadata-created-by", + tool.createdBy || "Legacy Entity", + ); + setTextSafely( + ".metadata-created-at", + tool.createdAt + ? new Date(tool.createdAt).toLocaleString() + : "Pre-metadata", + ); + setTextSafely( + ".metadata-created-from", + tool.createdFromIp || "Unknown", + ); + setTextSafely( + ".metadata-created-via", + tool.createdVia || "Unknown", + ); + setTextSafely(".metadata-modified-by", tool.modifiedBy || "N/A"); + setTextSafely( + ".metadata-modified-at", + tool.modifiedAt + ? new Date(tool.modifiedAt).toLocaleString() + : "N/A", + ); + setTextSafely(".metadata-version", tool.version || "1"); + setTextSafely( + ".metadata-import-batch", + tool.importBatchId || "N/A", + ); } openModal("tool-modal"); diff --git a/mcpgateway/templates/admin.html b/mcpgateway/templates/admin.html index 2953d620f..be530d5da 100644 --- a/mcpgateway/templates/admin.html +++ b/mcpgateway/templates/admin.html @@ -359,13 +359,17 @@

    -

    +

    Configuration Export & Import

    -

    +

    📤 Export Configuration

    @@ -373,82 +377,167 @@

    -
    -
    -