From b8d58a66fa2c2ab5e3676565a0d151bf14eee4de Mon Sep 17 00:00:00 2001 From: Pratik Gandhi Date: Fri, 11 Sep 2026 08:27:47 +0100 Subject: [PATCH] feat: prefix federated resource names with gateway names Refs #6621. Preserve resource URIs and local names; deterministic same-URI routing remains outside this change. Signed-off-by: Pratik Gandhi --- .secrets.baseline | 56 +++--- docker-compose.yml | 2 + docs/docs/manage/export-import.md | 21 ++- .../c7e91a2b4d60_add_resource_namespacing.py | 72 +++++++ mcpgateway/db.py | 116 +++++++++++- mcpgateway/schemas.py | 2 + mcpgateway/services/export_service.py | 37 +++- mcpgateway/services/gateway_service.py | 24 ++- mcpgateway/services/import_service.py | 2 + mcpgateway/services/resource_service.py | 12 +- .../live_gateway/mcp/test_mcp_protocol_e2e.py | 139 +++++++++++++- .../db/test_resource_namespacing.py | 178 ++++++++++++++++++ .../services/test_export_service.py | 69 +++++++ .../services/test_gateway_service.py | 30 ++- .../services/test_gateway_service_extended.py | 6 + .../services/test_resource_service.py | 37 ++++ 16 files changed, 752 insertions(+), 51 deletions(-) create mode 100644 mcpgateway/alembic/versions/c7e91a2b4d60_add_resource_namespacing.py create mode 100644 tests/unit/mcpgateway/db/test_resource_namespacing.py diff --git a/.secrets.baseline b/.secrets.baseline index 6332d0f736..2d661ee98d 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "(?x)( package-lock\\.json$ |Cargo\\.lock$ |uv\\.lock$ |go\\.sum$ |mcpgateway/sri_hashes\\.json$ )|^\\.secrets\\.baseline$", "lines": null }, - "generated_at": "2026-09-10T13:37:04Z", + "generated_at": "2026-09-11T07:24:11Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -730,7 +730,7 @@ "hashed_secret": "b6f3674b78a02881de33177e6f6fb8b851e0101c", "is_secret": false, "is_verified": false, - "line_number": 250, + "line_number": 251, "type": "Secret Keyword", "verified_result": null }, @@ -738,7 +738,7 @@ "hashed_secret": "108b310facc1a193833fc2971fd83081f775ea0c", "is_secret": false, "is_verified": false, - "line_number": 345, + "line_number": 346, "type": "Secret Keyword", "verified_result": null }, @@ -746,7 +746,7 @@ "hashed_secret": "a6e38ae8688f390d45039a635c394dea67b9bc41", "is_secret": false, "is_verified": false, - "line_number": 395, + "line_number": 396, "type": "Secret Keyword", "verified_result": null }, @@ -754,7 +754,7 @@ "hashed_secret": "bba409d5cd2d77fe052d5ecc39b35f167641118c", "is_secret": false, "is_verified": false, - "line_number": 403, + "line_number": 404, "type": "Secret Keyword", "verified_result": null }, @@ -762,7 +762,7 @@ "hashed_secret": "d08f88df745fa7950b104e4a707a31cfce7b5841", "is_secret": false, "is_verified": false, - "line_number": 450, + "line_number": 451, "type": "Secret Keyword", "verified_result": null }, @@ -770,7 +770,7 @@ "hashed_secret": "08cd923367890009657eab812753379bdb321eeb", "is_secret": false, "is_verified": false, - "line_number": 974, + "line_number": 975, "type": "Secret Keyword", "verified_result": null }, @@ -778,7 +778,7 @@ "hashed_secret": "bd0160c2cf35d950843c88f3be2b9412ed71f485", "is_secret": false, "is_verified": false, - "line_number": 1077, + "line_number": 1078, "type": "Secret Keyword", "verified_result": null }, @@ -786,7 +786,7 @@ "hashed_secret": "3879c93c04cab8707ab8ab6a4f97d51ea8fb6f47", "is_secret": false, "is_verified": false, - "line_number": 1247, + "line_number": 1249, "type": "Secret Keyword", "verified_result": null }, @@ -794,7 +794,7 @@ "hashed_secret": "756fa1fd4f0c6d5249cc2ad68d5a5a6bfe96aacd", "is_secret": false, "is_verified": false, - "line_number": 1282, + "line_number": 1284, "type": "Secret Keyword", "verified_result": null }, @@ -802,7 +802,7 @@ "hashed_secret": "2df14e4719f299249cd9a97cf68cc87232a27cbb", "is_secret": false, "is_verified": false, - "line_number": 1863, + "line_number": 1865, "type": "Hex High Entropy String", "verified_result": null }, @@ -810,7 +810,7 @@ "hashed_secret": "afba9d98073dfb79fba7b21516c4d4d676c72935", "is_secret": false, "is_verified": false, - "line_number": 2306, + "line_number": 2308, "type": "Secret Keyword", "verified_result": null }, @@ -818,7 +818,7 @@ "hashed_secret": "db521f60ff25f37ce29401ad93cb12cf4dc9f814", "is_secret": false, "is_verified": false, - "line_number": 2309, + "line_number": 2311, "type": "Secret Keyword", "verified_result": null }, @@ -826,7 +826,7 @@ "hashed_secret": "b7aa580d298f50cfece35543607ccd68177c6413", "is_secret": false, "is_verified": false, - "line_number": 2310, + "line_number": 2312, "type": "Secret Keyword", "verified_result": null }, @@ -834,7 +834,7 @@ "hashed_secret": "543d4766b92de8d18b1899c24e825638fd2a2bb7", "is_secret": false, "is_verified": false, - "line_number": 2410, + "line_number": 2412, "type": "Secret Keyword", "verified_result": null }, @@ -842,7 +842,7 @@ "hashed_secret": "76a5af8c9ee406ff91da84664bc6f58cacb2ad76", "is_secret": false, "is_verified": false, - "line_number": 2410, + "line_number": 2412, "type": "Base64 High Entropy String", "verified_result": null }, @@ -850,7 +850,7 @@ "hashed_secret": "64f5490a2808803712ef99d9dfb8bc3ea5a15077", "is_secret": false, "is_verified": false, - "line_number": 2423, + "line_number": 2425, "type": "Secret Keyword", "verified_result": null }, @@ -858,7 +858,7 @@ "hashed_secret": "fa9beb99e4029ad5a6615399e7bbae21356086b3", "is_secret": false, "is_verified": false, - "line_number": 2573, + "line_number": 2575, "type": "Secret Keyword", "verified_result": null }, @@ -866,7 +866,7 @@ "hashed_secret": "12be9f7db42eb4a2d881a99fa9ba847e1f83677f", "is_secret": false, "is_verified": false, - "line_number": 2594, + "line_number": 2596, "type": "Secret Keyword", "verified_result": null }, @@ -874,7 +874,7 @@ "hashed_secret": "c3de40d5e3fc71ed62771c2127a8e42585026c97", "is_secret": false, "is_verified": false, - "line_number": 2602, + "line_number": 2604, "type": "Secret Keyword", "verified_result": null }, @@ -882,7 +882,7 @@ "hashed_secret": "ce53277317aa3cd27c1619d7d371306ded2ddd1e", "is_secret": false, "is_verified": false, - "line_number": 2607, + "line_number": 2609, "type": "Secret Keyword", "verified_result": null } @@ -2030,7 +2030,7 @@ "hashed_secret": "5ef3af6cd9b5aa907fa618fac829baaec6763b42", "is_secret": false, "is_verified": false, - "line_number": 142, + "line_number": 161, "type": "Secret Keyword", "verified_result": null }, @@ -2038,7 +2038,7 @@ "hashed_secret": "d0cc84a2d2492e2218a3b6013f26d4185bed3d45", "is_secret": false, "is_verified": false, - "line_number": 220, + "line_number": 239, "type": "Secret Keyword", "verified_result": null }, @@ -2046,7 +2046,7 @@ "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_secret": false, "is_verified": false, - "line_number": 224, + "line_number": 243, "type": "Basic Auth Credentials", "verified_result": null }, @@ -2054,7 +2054,7 @@ "hashed_secret": "85b60d811d16ff56b3654587d4487f713bfa33b7", "is_secret": false, "is_verified": false, - "line_number": 539, + "line_number": 558, "type": "Secret Keyword", "verified_result": null }, @@ -2062,7 +2062,7 @@ "hashed_secret": "7ffacf341eafd6cb8bca49b6c28d452cc211228b", "is_secret": false, "is_verified": false, - "line_number": 558, + "line_number": 577, "type": "Secret Keyword", "verified_result": null }, @@ -2070,7 +2070,7 @@ "hashed_secret": "4a0a2df96d4c9a13a282268cab33ac4b8cbb2c72", "is_secret": false, "is_verified": false, - "line_number": 566, + "line_number": 585, "type": "Secret Keyword", "verified_result": null } @@ -3888,7 +3888,7 @@ "hashed_secret": "c377074d6473f35a91001981355da793dc808ffd", "is_secret": false, "is_verified": false, - "line_number": 4811, + "line_number": 4813, "type": "Hex High Entropy String", "verified_result": null } diff --git a/docker-compose.yml b/docker-compose.yml index 914fa6b8b9..e5a7e114e6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -148,6 +148,7 @@ services: - HOST=${GATEWAY_HOST:-0.0.0.0} # Transport: sse, streamablehttp, http, or all (default: all) - TRANSPORT_TYPE=streamablehttp + - GATEWAY_TOOL_NAME_SEPARATOR=${GATEWAY_TOOL_NAME_SEPARATOR:--} # High-level Rust MCP UX: # Deprecated as of 2026-06-11; sunsets on 2026-07-07. Prefer RUST_MCP_MODE=off and the Python MCP transport. # RUST_MCP_MODE=off -> Python MCP transport @@ -1075,6 +1076,7 @@ services: dockerfile: Containerfile environment: - DATABASE_URL=postgresql+psycopg://postgres:${POSTGRES_PASSWORD:-mysecretpassword}@postgres:5432/mcp + - GATEWAY_TOOL_NAME_SEPARATOR=${GATEWAY_TOOL_NAME_SEPARATOR:--} - "JWT_SECRET_KEY=${JWT_SECRET_KEY:?JWT_SECRET_KEY is not set. Run: make setup (first time) or make init-secrets-patch-env (if .env exists)}" # Basic auth is DISABLED by default for security (API_ALLOW_BASIC_AUTH unset here) # Only set these if you explicitly enable Basic auth for this container diff --git a/docs/docs/manage/export-import.md b/docs/docs/manage/export-import.md index 11682704c4..a5869fa331 100644 --- a/docs/docs/manage/export-import.md +++ b/docs/docs/manage/export-import.md @@ -15,7 +15,26 @@ The export/import system enables complete backup and restoration of your Context - **Resources** (locally defined resources) - **Roots** (filesystem and HTTP root paths) -> **Note**: Only locally configured entities are exported. Dynamic content from federated MCP servers is excluded to ensure exports contain only your gateway's configuration. +> **Note**: Exports describe gateway configuration and resource metadata; they do not back up upstream resource content. + +### Federated Resource Names + +Federated resource names use the gateway slug, `GATEWAY_TOOL_NAME_SEPARATOR` (default `-`), and the resource base +slug. Local resource names and resource URIs are unchanged. Names are limited to 255 characters and are not unique: +long prefixes can consume the resource portion, and gateway slugs can collide. A virtual-server-scoped read resolves +a shared URI only when that server has one matching resource; prefixing names does not change URI routing. + +Exports preserve local names verbatim. Federated exports select the effective base, then the upstream name, then the +derived name, using the first candidate that validates after applying the configured length limit (at most 255). +If none validates, the derived name is exported unchanged with a warning. Full `original_name` and `custom_name_slug` +values accompany federated exports as provenance; import consumes only `name` and does not restore that provenance. +Empty, oversized, or configuration-invalid bases can therefore change on import. Import also applies the destination's +validation settings. Lowering a validation limit does not truncate local names during export. + +Changing the separator does not rewrite stored resource bases. They retain their separator until a manual rename or +an applicable upstream-name change replaces them, so derived names can contain mixed separators after a configuration +change. Separator-only differences do not count as manual overrides. Downgrading the resource-namespacing migration +restores upstream names for federated resources and discards manual base overrides. --- diff --git a/mcpgateway/alembic/versions/c7e91a2b4d60_add_resource_namespacing.py b/mcpgateway/alembic/versions/c7e91a2b4d60_add_resource_namespacing.py new file mode 100644 index 0000000000..5fe9446ab0 --- /dev/null +++ b/mcpgateway/alembic/versions/c7e91a2b4d60_add_resource_namespacing.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +"""Location: ./mcpgateway/alembic/versions/c7e91a2b4d60_add_resource_namespacing.py +Copyright contributors to the MCP-CONTEXT-FORGE project +SPDX-License-Identifier: Apache-2.0 + +Add persisted resource namespacing. + +Revision ID: c7e91a2b4d60 +Revises: 5e211ec89cad + +Downgrade restores federated upstream names and discards manual base overrides. +It uses stored values only, independently of the current separator configuration. +""" + +# Standard +from typing import Sequence, Union + +# Third-Party +from alembic import op +import sqlalchemy as sa + +# First-Party +from mcpgateway.config import settings +from mcpgateway.utils.create_slug import slugify + +revision: str = "c7e91a2b4d60" # pragma: allowlist secret +down_revision: Union[str, Sequence[str], None] = "5e211ec89cad" # pragma: allowlist secret +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Initialize naming state once and prefix federated resources.""" + bind = op.get_bind() + inspector = sa.inspect(bind) + if not inspector.has_table("resources"): + return + columns = {column["name"] for column in inspector.get_columns("resources")} + for name in ("original_name", "custom_name_slug"): + if name not in columns: + op.add_column("resources", sa.Column(name, sa.Text(), nullable=True)) + + rows = ( + bind.execute(sa.text("SELECT r.id, r.name, r.gateway_id, g.name AS gateway_name " "FROM resources r LEFT JOIN gateways g ON r.gateway_id = g.id " "WHERE r.original_name IS NULL")) + .mappings() + .all() + ) + for row in rows: + base = slugify(row["name"]) + gateway_slug = slugify(row["gateway_name"]) if row["gateway_id"] and row["gateway_name"] else "" + name = row["name"] + if gateway_slug: + name = (f"{gateway_slug}{settings.gateway_tool_name_separator}{base}" if base else gateway_slug)[:255] + bind.execute( + sa.text("UPDATE resources SET original_name = :original, custom_name_slug = :base, name = :name WHERE id = :id"), + {"id": row["id"], "original": row["name"], "base": base, "name": name}, + ) + + +def downgrade() -> None: + """Restore upstream names, leaving local names unchanged, then drop naming state.""" + bind = op.get_bind() + inspector = sa.inspect(bind) + if not inspector.has_table("resources"): + return + columns = {column["name"] for column in inspector.get_columns("resources")} + if "original_name" in columns: + bind.execute(sa.text("UPDATE resources SET name = original_name WHERE gateway_id IS NOT NULL AND original_name IS NOT NULL")) + with op.batch_alter_table("resources") as batch: + for name in ("custom_name_slug", "original_name"): + if name in columns: + batch.drop_column(name) diff --git a/mcpgateway/db.py b/mcpgateway/db.py index 638afc55ce..1a8bdd1cd3 100644 --- a/mcpgateway/db.py +++ b/mcpgateway/db.py @@ -25,6 +25,7 @@ from datetime import datetime, timedelta, timezone import logging import os +import re from typing import Any, cast, Dict, Generator, List, Optional, TYPE_CHECKING import uuid @@ -33,11 +34,11 @@ from sqlalchemy import BigInteger, Boolean, CheckConstraint, Column, create_engine, DateTime, event, Float, ForeignKey, func, Index from sqlalchemy import inspect as sa_inspect from sqlalchemy import Integer, JSON, make_url, MetaData, select, String, Table, text, Text, UniqueConstraint -from sqlalchemy.engine import Engine +from sqlalchemy.engine import Connection, Engine from sqlalchemy.event import listen from sqlalchemy.exc import OperationalError, ProgrammingError, SQLAlchemyError from sqlalchemy.ext.hybrid import hybrid_property -from sqlalchemy.orm import DeclarativeBase, joinedload, Mapped, mapped_column, relationship, Session, sessionmaker +from sqlalchemy.orm import DeclarativeBase, joinedload, Mapped, mapped_column, Mapper, relationship, Session, sessionmaker from sqlalchemy.orm.attributes import get_history from sqlalchemy.pool import NullPool, QueuePool from sqlalchemy.types import TypeDecorator @@ -3692,6 +3693,8 @@ class Resource(Base): id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex) uri: Mapped[str] = mapped_column(String(767), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False) + original_name: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + custom_name_slug: Mapped[Optional[str]] = mapped_column(Text, nullable=True) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) mime_type: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) @@ -4911,6 +4914,40 @@ def update_prompt_names_on_gateway_update(_mapper, connection, target): connection.execute(stmt) +@event.listens_for(Gateway, "after_update") +def update_resource_names_on_gateway_update(_mapper: Mapper[Any], connection: Connection, target: Gateway) -> None: + """Recompose resource names after a gateway rename without truncating their bases. + + Args: + _mapper: Gateway mapper. + connection: Connection for the current transaction. + target: Updated gateway. + """ + # Third-Party + from sqlalchemy import case # pylint: disable=import-outside-toplevel + + if not get_history(target, "name").has_changes(): + return + gateway_slug = slugify(target.name) + if not gateway_slug: + return + resources = cast(Table, Resource.__table__) + connection.execute( + resources.update() + .where(resources.c.gateway_id == target.id) + .values( + name=func.substr( + case( + (func.coalesce(resources.c.custom_name_slug, "") == "", gateway_slug), + else_=gateway_slug + settings.gateway_tool_name_separator + resources.c.custom_name_slug, + ), + 1, + 255, + ) + ) + ) + + class A2AAgent(Base): """ ORM model for A2A (Agent-to-Agent) compatible agents. @@ -6881,6 +6918,81 @@ def set_prompt_name_and_slug(mapper, connection, target): # pylint: disable=unu target.name = target.custom_name_slug +def compose_resource_name(gateway_slug: str, base: str) -> str: + """Compose a resource name within its database column bound. + + Args: + gateway_slug: Non-empty gateway slug. + base: Full, possibly empty resource base slug. + + Returns: + Namespaced name containing at most 255 characters. + """ + return (f"{gateway_slug}{settings.gateway_tool_name_separator}{base}" if base else gateway_slug)[:255] + + +def resource_has_name_override(resource: Resource) -> bool: + """Compare a resource base with upstream identity independently of separators. + + Args: + resource: Resource whose upstream name has not yet been changed. + + Returns: + Whether the stored base represents a manual override. + """ + base = resource.custom_name_slug + if base is None: + return False + return re.sub(r"[-_.]+", "-", base) != re.sub(r"[-_.]+", "-", slugify(resource.original_name or "")) + + +def _resolve_resource_gateway_slug(connection: Connection, target: Resource) -> str: + """Resolve the gateway slug for a resource, including pending registrations. + + Args: + connection: Connection for the current flush. + target: Resource being persisted. + + Returns: + Gateway slug, or an empty string when it cannot be resolved. + """ + gateway = sa_inspect(target).dict.get("gateway") + if gateway is not None: + return slugify(gateway.name) + if not target.gateway_id: + return "" + cached_name = getattr(target, "gateway_name_cache", None) + if cached_name: + return slugify(cached_name) + try: + name = connection.execute(text("SELECT name FROM gateways WHERE id = :gw_id"), {"gw_id": target.gateway_id}).scalar_one_or_none() + return slugify(name) if name else "" + except SQLAlchemyError: + logger.warning("Unable to resolve gateway name for resource %s", target.id) + return "" + + +@event.listens_for(Resource, "before_insert") +@event.listens_for(Resource, "before_update") +def set_resource_name_and_slug(_mapper: Mapper[Any], connection: Connection, target: Resource) -> None: + """Maintain federated resource naming while preserving local names verbatim. + + Args: + _mapper: Resource mapper. + connection: Connection for the current flush. + target: Resource being persisted. + """ + if target.original_name is None: + target.original_name = target.name + base = target.custom_name_slug + if base is None: + base = slugify(target.original_name or "") + target.custom_name_slug = base + gateway_slug = _resolve_resource_gateway_slug(connection, target) + if gateway_slug: + target.name = compose_resource_name(gateway_slug, base) + + # --------------------------------------------------------------------------- # ToolPluginBinding — per-tool per-tenant plugin policy bindings # --------------------------------------------------------------------------- diff --git a/mcpgateway/schemas.py b/mcpgateway/schemas.py index 2bc3e61e2f..bb30b69cb2 100644 --- a/mcpgateway/schemas.py +++ b/mcpgateway/schemas.py @@ -2482,6 +2482,8 @@ class ResourceRead(BaseModelWithConfigDict): id: str = Field(description="Unique ID of the resource") uri: str name: str + original_name: Optional[str] = Field(None, description="Upstream name as reported by the federated server") + custom_name_slug: Optional[str] = Field(None, description="Slugified base used to compose the namespaced name") description: Optional[str] mime_type: Optional[str] gateway_id: Optional[str] = Field(None, description="ID of the gateway for the resource") diff --git a/mcpgateway/services/export_service.py b/mcpgateway/services/export_service.py index c9c2aa7a7c..d854ba53b1 100644 --- a/mcpgateway/services/export_service.py +++ b/mcpgateway/services/export_service.py @@ -25,6 +25,7 @@ from sqlalchemy.orm import selectinload, Session, with_loader_criteria # First-Party +from mcpgateway.common.validators import SecurityValidator from mcpgateway.config import settings from mcpgateway.db import A2AAgent as DbA2AAgent from mcpgateway.db import Gateway as DbGateway @@ -32,6 +33,7 @@ from mcpgateway.db import Resource as DbResource from mcpgateway.db import Server as DbServer from mcpgateway.db import Tool as DbTool +from mcpgateway.schemas import ResourceRead from mcpgateway.utils.services_auth import encode_auth # Service singletons are imported lazily in __init__ to avoid circular imports @@ -39,6 +41,33 @@ logger = logging.getLogger(__name__) +def _exportable_resource_base(resource: DbResource | ResourceRead) -> str: + """Select a resource export name without changing local names. + + Args: + resource: Resource to export. + + Returns: + Validated base, or the unchanged name when no candidate validates. + """ + if resource.gateway_id is None: + try: + SecurityValidator.validate_name(resource.name, "Resource name") + except ValueError: + logger.warning("Local resource %s (uri=%s) exports a name invalid under current validation settings", resource.id, resource.uri) + return resource.name + + cap = min(255, settings.validation_max_name_length) + for candidate in (resource.custom_name_slug, resource.original_name, resource.name): + if candidate: + try: + return SecurityValidator.validate_name(candidate[:cap], "Resource name") + except ValueError: + continue + logger.warning("Federated resource %s (uri=%s) has no importable name under current validation settings; exporting derived name", resource.id, resource.uri) + return resource.name + + class ExportError(Exception): """Base class for export-related errors. @@ -635,7 +664,7 @@ async def _export_resources( for resource in resources: resource_data = { - "name": resource.name, + "name": _exportable_resource_base(resource), "uri": resource.uri, "description": resource.description, "mime_type": resource.mime_type, @@ -644,6 +673,8 @@ async def _export_resources( "last_modified": resource.updated_at.isoformat() if resource.updated_at else None, } + if resource.gateway_id is not None: + resource_data.update(original_name=resource.original_name, custom_name_slug=resource.custom_name_slug) exported_resources.append(resource_data) return exported_resources @@ -1125,7 +1156,7 @@ async def _export_selected_resources(self, db: Session, resource_uris: List[str] exported_resources = [] for db_resource in db_resources: resource_data = { - "name": db_resource.name, + "name": _exportable_resource_base(db_resource), "uri": db_resource.uri, "description": db_resource.description, "mime_type": db_resource.mime_type, @@ -1134,6 +1165,8 @@ async def _export_selected_resources(self, db: Session, resource_uris: List[str] "last_modified": db_resource.updated_at.isoformat() if db_resource.updated_at else None, } + if db_resource.gateway_id is not None: + resource_data.update(original_name=db_resource.original_name, custom_name_slug=db_resource.custom_name_slug) exported_resources.append(resource_data) return exported_resources diff --git a/mcpgateway/services/gateway_service.py b/mcpgateway/services/gateway_service.py index 50a620d53f..84491b022e 100644 --- a/mcpgateway/services/gateway_service.py +++ b/mcpgateway/services/gateway_service.py @@ -92,8 +92,9 @@ from mcpgateway.db import Prompt as DbPrompt from mcpgateway.db import PromptMetric from mcpgateway.db import Resource as DbResource -from mcpgateway.db import ResourceMetric, ResourceSubscription, server_prompt_association, server_resource_association, server_tool_association, SessionLocal +from mcpgateway.db import resource_has_name_override, ResourceMetric, ResourceSubscription from mcpgateway.db import Server as DbServer +from mcpgateway.db import server_prompt_association, server_resource_association, server_tool_association, SessionLocal from mcpgateway.db import Tool as DbTool from mcpgateway.db import ToolMetric from mcpgateway.observability import create_span, set_span_attribute, set_span_error @@ -1872,7 +1873,11 @@ async def register_gateway( if lookup_key in orphaned_resources_map: # Update orphaned resource - reassign to new gateway existing = orphaned_resources_map[lookup_key] - existing.name = r.name + has_override = resource_has_name_override(existing) + if existing.original_name != r.name: + existing.original_name = r.name + if not has_override: + existing.custom_name_slug = slugify(r.name) existing.description = r.description existing.mime_type = mime_type existing.uri_template = r.uri_template or None @@ -4858,7 +4863,7 @@ def get_httpx_client_factory( # are treated as "gateway reachable" (handled below in exception logic). try: # First-Party - from mcpgateway.services.token_storage_service import TokenStorageService, build_token_user_context # pylint: disable=import-outside-toplevel + from mcpgateway.services.token_storage_service import build_token_user_context, TokenStorageService # pylint: disable=import-outside-toplevel # Get user-specific OAuth token only if user_email is provided if user_email: @@ -6133,10 +6138,12 @@ def _update_or_create_resources(self, db: Session, resources: List[Any], gateway if existing_resource: # Update existing resource if there are changes fields_to_update = False + has_override = resource_has_name_override(existing_resource) + upstream_renamed = existing_resource.original_name != resource.name upstream_visibility = getattr(resource, "visibility", None) if ( - existing_resource.name != resource.name + upstream_renamed or existing_resource.description != resource.description or existing_resource.mime_type != resource.mime_type or existing_resource.uri_template != resource.uri_template @@ -6147,7 +6154,10 @@ def _update_or_create_resources(self, db: Session, resources: List[Any], gateway fields_to_update = True if fields_to_update: - existing_resource.name = resource.name + if upstream_renamed: + existing_resource.original_name = resource.name + if not has_override: + existing_resource.custom_name_slug = slugify(resource.name) existing_resource.description = resource.description existing_resource.mime_type = resource.mime_type existing_resource.uri_template = resource.uri_template @@ -6171,6 +6181,7 @@ def _update_or_create_resources(self, db: Session, resources: List[Any], gateway created_via=created_via, visibility=getattr(resource, "visibility", None) or gateway.visibility, ) + db_resource.gateway = gateway resources_to_add.append(db_resource) logger.debug("Created new resource: %s", resource.uri) except Exception as e: @@ -8007,6 +8018,7 @@ def _failure(failure_class: Literal["transport", "protocol", "auth", "invalid_re # Deferred import: `main` imports this module at load time, so importing the # ASGI `app` singleton at module scope here would be circular. + # First-Party from mcpgateway.main import app # pylint: disable=import-outside-toplevel,cyclic-import def get_httpx_client_factory( @@ -8132,7 +8144,7 @@ async def test_gateway_connectivity( # For Authorization Code flow, try to get stored tokens try: # First-Party - from mcpgateway.services.token_storage_service import TokenStorageService, build_token_user_context # pylint: disable=import-outside-toplevel + from mcpgateway.services.token_storage_service import build_token_user_context, TokenStorageService # pylint: disable=import-outside-toplevel # SECURITY: Use token_teams from the authenticated user dict — this is # already resolved by auth middleware and must not be widened by diff --git a/mcpgateway/services/import_service.py b/mcpgateway/services/import_service.py index 078714a5e7..66edbac9c3 100644 --- a/mcpgateway/services/import_service.py +++ b/mcpgateway/services/import_service.py @@ -1521,6 +1521,7 @@ def _convert_to_resource_create(self, resource_data: Dict[str, Any]) -> Resource Returns: ResourceCreate schema object """ + # Naming provenance is export metadata; name remains the literal import value. return ResourceCreate( uri=resource_data["uri"], name=resource_data["name"], @@ -1539,6 +1540,7 @@ def _convert_to_resource_update(self, resource_data: Dict[str, Any]) -> Resource Returns: ResourceUpdate schema object """ + # As with creation, do not restore the exported upstream/base provenance. return ResourceUpdate( name=resource_data.get("name"), description=resource_data.get("description"), mime_type=resource_data.get("mime_type"), content=resource_data.get("content"), tags=resource_data.get("tags") ) diff --git a/mcpgateway/services/resource_service.py b/mcpgateway/services/resource_service.py index dcdcc781c4..452f476464 100644 --- a/mcpgateway/services/resource_service.py +++ b/mcpgateway/services/resource_service.py @@ -76,6 +76,7 @@ from mcpgateway.services.upstream_session_registry import downstream_session_id_from_request_context as _downstream_session_id_from_request from mcpgateway.services.upstream_session_registry import get_upstream_session_registry, RegistryNotInitializedError, TransportType from mcpgateway.utils.admin_check import is_admin_bypass_granted, is_user_admin +from mcpgateway.utils.create_slug import slugify from mcpgateway.utils.gateway_access import build_gateway_auth_headers, check_gateway_access from mcpgateway.utils.identity_propagation import build_identity_headers from mcpgateway.utils.metrics_common import build_top_performers @@ -1948,7 +1949,7 @@ def _get_httpx_client_factory( # For Authorization Code flow, try to get stored tokens try: # First-Party - from mcpgateway.services.token_storage_service import TokenStorageService, build_token_user_context # pylint: disable=import-outside-toplevel + from mcpgateway.services.token_storage_service import build_token_user_context, TokenStorageService # pylint: disable=import-outside-toplevel # Use fresh DB session for token lookup (original db was closed) access_token = None @@ -3202,7 +3203,14 @@ async def update_resource( if resource_update.uri is not None: resource.uri = resource_update.uri if resource_update.name is not None: - resource.name = resource_update.name + if resource.gateway_id: + # Admin forms resubmit the derived name even on description-only edits. + # A rename to that exact value is intentionally a no-op. + if resource_update.name not in (resource.name, resource.custom_name_slug): + resource.custom_name_slug = slugify(resource_update.name) + else: + resource.name = resource_update.name + resource.custom_name_slug = slugify(resource_update.name) if resource_update.title is not None: resource.title = resource_update.title if resource_update.description is not None: diff --git a/tests/live_gateway/mcp/test_mcp_protocol_e2e.py b/tests/live_gateway/mcp/test_mcp_protocol_e2e.py index 9defc627e8..652e5aa070 100644 --- a/tests/live_gateway/mcp/test_mcp_protocol_e2e.py +++ b/tests/live_gateway/mcp/test_mcp_protocol_e2e.py @@ -34,8 +34,11 @@ from datetime import timedelta import json import os +import socket import subprocess import sys +import threading +import time from typing import Any import uuid @@ -43,9 +46,11 @@ import httpx from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client +from mcp.server.fastmcp import FastMCP from mcp.shared.exceptions import McpError from mcp.types import InitializeResult import pytest +import uvicorn # Local from ..helpers.mcp_test_helpers import ( @@ -167,6 +172,132 @@ async def _session_runner() -> None: # --------------------------------------------------------------------------- # Connectivity / lifecycle # --------------------------------------------------------------------------- + + +@pytest.fixture +def resource_namespacing_upstreams(): + """Serve two real MCP peers reachable from the gateway under test. + + Compose on Docker Desktop/Colima uses host.docker.internal. For a gateway + running on the host, set MCP_NAMESPACING_UPSTREAM_HOST=127.0.0.1. Linux + container deployments need a gateway-reachable host address or host-gateway + mapping. No database writes or gateway internals are used by this fixture. + """ + host = os.getenv("MCP_NAMESPACING_UPSTREAM_HOST", "host.docker.internal") + identifier = uuid.uuid4().hex[:12] + uri = f"test://namespacing/{identifier}" + long_name = "a-" * 127 + "a" + peers = [] + running = [] + try: + for index in range(2): + content = f"upstream-{identifier}-{index}" + app = FastMCP(f"namespacing-{index}", host="0.0.0.0", stateless_http=True, json_response=True) + + def make_reader(value: str): + """Bind each peer's response independently of the registration loop.""" + + def read() -> str: + """Return this peer's distinctive resource content.""" + return value + + return read + + app.resource(uri, name="Shared Report")(make_reader(content)) + app.resource(f"{uri}/long", name=long_name)(make_reader(content)) + listener = socket.socket() + listener.bind(("0.0.0.0", 0)) + port = listener.getsockname()[1] + server = uvicorn.Server(uvicorn.Config(app.streamable_http_app(), log_level="error")) + thread = threading.Thread(target=server.run, kwargs={"sockets": [listener]}, daemon=True) + running.append((server, thread, listener)) + thread.start() + deadline = time.monotonic() + 10 + while not server.started and thread.is_alive() and time.monotonic() < deadline: + time.sleep(0.05) + assert server.started, "Resource namespacing upstream failed to start" + peers.append({"url": f"http://{host}:{port}/mcp", "content": content, "uri": uri}) + yield peers + finally: + for server, thread, listener in running: + server.should_exit = True + thread.join(timeout=10) + listener.close() + + +@pytest.mark.asyncio +async def test_resource_namespacing_federation_and_scoped_reads(jwt_token, resource_namespacing_upstreams): + """Federate colliding URIs and verify prefixing, full bases, and scoped reads. + + Run the gateway and migration services with GATEWAY_TOOL_NAME_SEPARATOR=--. + The normal resource assertion checks the effective separator before the + expansion assertion. The pre-merge Compose deployment uses PostgreSQL. + """ + headers = {"Authorization": f"Bearer {jwt_token}"} + gateway_ids = [] + server_ids = [] + expected_names = [] + identifier = uuid.uuid4().hex[:12] + async with httpx.AsyncClient(base_url=BASE_URL, headers=headers, timeout=60) as http: + try: + for index, peer in enumerate(resource_namespacing_upstreams): + gateway_name = f"namespacing{identifier}{index}" + response = await http.post("/gateways", json={"name": gateway_name, "url": peer["url"], "transport": "STREAMABLEHTTP", "visibility": "public"}) + assert response.status_code in (200, 201, 202), response.text + gateway_id = response.json()["id"] + gateway_ids.append(gateway_id) + deadline = time.monotonic() + 60 + rows = [] + while time.monotonic() < deadline: + response = await http.get("/resources", params={"gateway_id": gateway_id, "limit": 100}) + assert response.status_code == 200, response.text + rows = response.json() + if len(rows) == 2: + break + await asyncio.sleep(0.5) + assert len(rows) == 2, f"Gateway did not discover both upstream resources: {rows}; check MCP_NAMESPACING_UPSTREAM_HOST" + resource = next(row for row in rows if row["uri"] == peer["uri"]) + expected = f"{gateway_name}--shared--report" + assert resource["name"] == expected, "Gateway must run with GATEWAY_TOOL_NAME_SEPARATOR=-- for this regression" + expected_names.append(expected) + expanded = next(row for row in rows if row["uri"].endswith("/long")) + assert expanded["customNameSlug"] == "a--" * 127 + "a" + assert len(expanded["customNameSlug"]) == 382 + assert len(expanded["name"]) == 255 + response = await http.post( + "/servers", + json={"server": {"name": f"namespacing{identifier}{index}", "associated_resources": [resource["id"]]}, "visibility": "public"}, + ) + assert response.status_code in (200, 201), response.text + server_id = response.json()["id"] + server_ids.append(server_id) + async with streamablehttp_client(f"{BASE_URL}/servers/{server_id}/mcp/", headers=headers) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + listed = await session.list_resources() + assert [(str(item.uri), item.name) for item in listed.resources] == [(peer["uri"], expected)] + result = await session.read_resource(peer["uri"]) + assert result.contents[0].text == peer["content"] + + async with streamablehttp_client(f"{BASE_URL}/mcp/", headers=headers) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + names = [] + cursor = None + while True: + page = await session.list_resources(cursor=cursor) + names.extend(item.name for item in page.resources if str(item.uri) == resource_namespacing_upstreams[0]["uri"]) + cursor = page.nextCursor + if not cursor: + break + assert sorted(names) == sorted(expected_names) + finally: + for server_id in server_ids: + await http.delete(f"/servers/{server_id}") + for gateway_id in gateway_ids: + await http.delete(f"/gateways/{gateway_id}") + + class TestConnectivity: async def test_ping(self, client: GatewayClientSession) -> None: @@ -375,13 +506,9 @@ async def _require_declared_output_schema(client: GatewayClientSession, tool_nam """Require a synced tool with a declared output schema.""" tools = (await client.list_tools()).tools match = next((tool for tool in tools if tool.name == tool_name), None) - assert match is not None, ( - f"Tool {tool_name!r} is not registered in the gateway. " - "Check that register_fast_time completed and gateway synchronization finished." - ) + assert match is not None, f"Tool {tool_name!r} is not registered in the gateway. " "Check that register_fast_time completed and gateway synchronization finished." assert match.outputSchema, ( - f"Tool {tool_name!r} has no outputSchema declared in the gateway: {match}. " - "Check that the upstream tool declares an output_schema and gateway synchronization completed successfully." + f"Tool {tool_name!r} has no outputSchema declared in the gateway: {match}. " "Check that the upstream tool declares an output_schema and gateway synchronization completed successfully." ) return match diff --git a/tests/unit/mcpgateway/db/test_resource_namespacing.py b/tests/unit/mcpgateway/db/test_resource_namespacing.py new file mode 100644 index 0000000000..a5532104c9 --- /dev/null +++ b/tests/unit/mcpgateway/db/test_resource_namespacing.py @@ -0,0 +1,178 @@ +# -*- coding: utf-8 -*- +"""Location: ./tests/unit/mcpgateway/db/test_resource_namespacing.py +Copyright contributors to the MCP-CONTEXT-FORGE project +SPDX-License-Identifier: Apache-2.0 + +Persistence and lifecycle regressions for resource namespacing. +""" + +# Standard +import importlib + +# Third-Party +from alembic.migration import MigrationContext +from alembic.operations import Operations +import pytest +import sqlalchemy as sa +from sqlalchemy.orm import Session + +# First-Party +from mcpgateway.config import settings +from mcpgateway.db import Base, Gateway, Resource, resource_has_name_override +from mcpgateway.schemas import ResourceCreate +from mcpgateway.services.gateway_service import GatewayService +from mcpgateway.utils.create_slug import slugify + + +@pytest.fixture +def naming_db(): + """Use real ORM events and an isolated database for resource lifecycle tests.""" + engine = sa.create_engine("sqlite://") + Base.metadata.create_all(engine) + with Session(engine) as session: + yield session + engine.dispose() + + +@pytest.mark.parametrize("separator", ["-", "--", "_", "."]) +@pytest.mark.parametrize("source", ["Daily Report", "---", "a-" * 127 + "a"]) +def test_migration_listener_and_rename_agree(naming_db, monkeypatch, separator, source): + """Back-fill, registration, and bulk gateway rename compose the same bytes.""" + monkeypatch.setattr(settings, "gateway_tool_name_separator", separator) + migration = importlib.import_module("mcpgateway.alembic.versions.c7e91a2b4d60_add_resource_namespacing") + base = slugify(source) + prefix = "long" * 70 + expected = (prefix + separator + base if base else prefix)[:255] + gateway = Gateway(name=prefix, slug=prefix, url="https://example.com/mcp", capabilities={}) + resource = Resource(uri="test://shared", name=source) + gateway.resources = [resource] + local = Resource(uri="test://local", name="My Report") + naming_db.add_all([gateway, local]) + naming_db.flush() + assert resource.name == expected + assert resource.custom_name_slug == base + assert local.name == "My Report" + + # Exercise migration against legacy data independently of ORM insert events. + connection = naming_db.connection() + connection.execute(sa.update(Resource).where(Resource.id == resource.id).values(name=source, original_name=None, custom_name_slug=None)) + with Operations.context(MigrationContext.configure(connection)): + migration.upgrade() + migration.upgrade() + naming_db.expire(resource) + assert resource.name == expected + assert resource.original_name == source + assert resource.custom_name_slug == base + + gateway.name = "Short" + naming_db.flush() + naming_db.expire(resource) + assert resource.name == ("short" + separator + base if base else "short")[:255] + assert resource.custom_name_slug == base + local.description = "Changed" + naming_db.flush() + assert local.name == "My Report" + + +def test_refresh_preserves_override_and_is_idempotent(naming_db): + """URI identity permits renames without repeated resource updates.""" + gateway = Gateway(name="Upstream", slug="upstream", url="https://example.com/mcp", capabilities={}) + naming_db.add(gateway) + naming_db.flush() + service = GatewayService() + upstream = ResourceCreate(uri="test://shared", name="Daily Report", content="") + resources = service._update_or_create_resources(naming_db, [upstream], gateway, "federation") + naming_db.add_all(resources) + naming_db.flush() + resource = resources[0] + assert resource.name == "upstream-daily-report" + statements = [] + + def capture(_connection, _cursor, statement, _parameters, _context, _executemany): + if statement.startswith("UPDATE resources"): + statements.append(statement) + + sa.event.listen(naming_db.bind, "before_cursor_execute", capture) + for _ in range(2): + assert service._update_or_create_resources(naming_db, [upstream], gateway, "federation") == [] + naming_db.flush() + assert statements == [] + upstream.name = "Weekly Report" + service._update_or_create_resources(naming_db, [upstream], gateway, "federation") + naming_db.flush() + assert resource.name == "upstream-weekly-report" + assert len(statements) == 1 + resource.custom_name_slug = "chosen" + naming_db.flush() + upstream.name = "Monthly Report" + service._update_or_create_resources(naming_db, [upstream], gateway, "federation") + naming_db.flush() + assert resource.original_name == "Monthly Report" + assert resource.name == "upstream-chosen" + count = len(statements) + service._update_or_create_resources(naming_db, [upstream], gateway, "federation") + naming_db.flush() + assert len(statements) == count + + +def test_duplicate_names_and_uris_preserve_resource_identity(naming_db): + """Names never become a uniqueness key, within or across gateways.""" + gateways = [Gateway(name=name, slug=name, url=f"https://{name}.example.com/mcp", capabilities={}) for name in ("first", "second")] + for gateway in gateways: + gateway.resources = [Resource(uri="test://same", name="Report"), Resource(uri="test://other", name="Report")] + naming_db.add_all(gateways) + naming_db.flush() + rows = naming_db.scalars(sa.select(Resource)).all() + assert len(rows) == 4 + assert sorted(row.name for row in rows) == ["first-report", "first-report", "second-report", "second-report"] + + +def test_empty_gateway_slug_preserves_names(naming_db): + """An empty gateway slug never writes a separator-led resource name.""" + gateway = Gateway(name="Upstream", slug="upstream", url="https://example.com/mcp", capabilities={}, resources=[Resource(uri="test://same", name="Report")]) + naming_db.add(gateway) + naming_db.flush() + resource = gateway.resources[0] + previous = resource.name + gateway.name = "---" + naming_db.flush() + naming_db.expire(resource) + assert resource.name == previous + resource.description = "Changed" + naming_db.flush() + assert resource.name == previous + + +def test_override_comparison_survives_separator_change(monkeypatch): + """A configuration change does not turn a default base into an override.""" + resource = Resource(name="gateway-daily-report", original_name="Daily Report", custom_name_slug="daily-report") + monkeypatch.setattr(settings, "gateway_tool_name_separator", "_") + assert not resource_has_name_override(resource) + resource.custom_name_slug = "chosen" + assert resource_has_name_override(resource) + resource.custom_name_slug = "" + assert resource_has_name_override(resource) + + +def test_resource_migration_upgrade_downgrade(monkeypatch): + """Legacy names survive repeat upgrade; downgrade uses stored values only.""" + migration = importlib.import_module("mcpgateway.alembic.versions.c7e91a2b4d60_add_resource_namespacing") + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + connection.execute(sa.text("CREATE TABLE gateways (id TEXT PRIMARY KEY, name TEXT)")) + connection.execute(sa.text("CREATE TABLE resources (id TEXT PRIMARY KEY, name VARCHAR(255) NOT NULL, gateway_id TEXT)")) + connection.execute(sa.text("INSERT INTO gateways VALUES ('gw', 'Modern Server')")) + connection.execute(sa.text("INSERT INTO resources VALUES ('remote', 'Daily Report', 'gw'), ('local', 'My Report', NULL)")) + with Operations.context(MigrationContext.configure(connection)): + migration.upgrade() + first = connection.execute(sa.text("SELECT * FROM resources ORDER BY id")).all() + migration.upgrade() + assert connection.execute(sa.text("SELECT * FROM resources ORDER BY id")).all() == first + assert connection.execute(sa.text("SELECT name FROM resources WHERE id = 'remote'")).scalar_one() == "modern-server-daily-report" + connection.execute(sa.text("UPDATE resources SET name = 'operator-choice' WHERE id = 'remote'")) + monkeypatch.setattr(settings, "gateway_tool_name_separator", ".") + migration.downgrade() + migration.downgrade() + assert connection.execute(sa.text("SELECT name FROM resources ORDER BY id")).scalars().all() == ["My Report", "Daily Report"] + assert {column["name"] for column in sa.inspect(connection).get_columns("resources")} == {"id", "name", "gateway_id"} + engine.dispose() diff --git a/tests/unit/mcpgateway/services/test_export_service.py b/tests/unit/mcpgateway/services/test_export_service.py index 8ecf23bde2..1ac6328df4 100644 --- a/tests/unit/mcpgateway/services/test_export_service.py +++ b/tests/unit/mcpgateway/services/test_export_service.py @@ -818,6 +818,7 @@ async def test_export_resources_with_data(export_service, mock_db): # Create mock resource mock_resource = MagicMock() mock_resource.name = "test_resource" + mock_resource.gateway_id = None mock_resource.uri = "file:///workspace/test.txt" mock_resource.description = "Test resource file" mock_resource.mime_type = "text/plain" @@ -842,6 +843,72 @@ async def test_export_resources_with_data(export_service, mock_db): assert resource_data["last_modified"] is not None +@pytest.mark.parametrize( + "base,original,expected", + [("chosen", "Upstream", "chosen"), ("", "Upstream", "Upstream"), ("a" * 382, "Upstream", "a" * 255)], +) +@pytest.mark.asyncio +async def test_resource_namespacing_export_paths_and_import(export_service, mock_db, base, original, expected): + """Both export paths select the same base and literal import ignores provenance.""" + # First-Party + from mcpgateway.db import Resource + from mcpgateway.services.import_service import ImportService + + resource = Resource( + id="namespaced", + gateway_id="gateway", + uri="test://report", + name="gateway-report", + original_name=original, + custom_name_slug=base, + description="Report", + mime_type="text/plain", + tags=[], + enabled=True, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + size=0, + ) + # Use the actual response conversion: bulk export receives ResourceRead, not ORM rows. + # First-Party + from mcpgateway.services.resource_service import ResourceService + + response = ResourceService().convert_resource_to_read(resource, include_metrics=False) + export_service._fetch_all_resources = AsyncMock(return_value=[response]) + mock_db.execute.return_value.scalars.return_value.all.return_value = [resource] + bulk = await export_service._export_resources(mock_db, None, False) + selective = await export_service._export_selected_resources(mock_db, [resource.uri]) + assert bulk == selective + assert bulk[0]["name"] == expected + assert bulk[0]["custom_name_slug"] == base + assert bulk[0]["original_name"] == original + importer = ImportService() + assert importer._convert_to_resource_create(bulk[0]).name == expected + assert importer._convert_to_resource_update(bulk[0]).name == expected + + +def test_resource_namespacing_export_validation_policy(monkeypatch, caplog): + """Local names stay verbatim; invalid federated candidates fall through.""" + # First-Party + from mcpgateway.common.validators import SecurityValidator + from mcpgateway.config import settings + from mcpgateway.db import Resource + from mcpgateway.services.export_service import _exportable_resource_base + + monkeypatch.setattr(settings, "validation_max_name_length", 5) + monkeypatch.setattr(SecurityValidator, "MAX_NAME_LENGTH", 5) + local = Resource(id="local", name="My Report", uri="test://local") + assert _exportable_resource_base(local) == "My Report" + assert "Local resource" in caplog.text + resource = Resource(id="remote", gateway_id="gw", uri="test://remote", name="gw-choice", original_name="Valid", custom_name_slug="long-base") + assert _exportable_resource_base(resource) == "long-" + monkeypatch.setattr(SecurityValidator, "NAME_PATTERN", r"^Valid$") + assert _exportable_resource_base(resource) == "Valid" + monkeypatch.setattr(SecurityValidator, "NAME_PATTERN", r"^Never$") + assert _exportable_resource_base(resource) == "gw-choice" + assert "no importable name" in caplog.text + + @pytest.mark.asyncio async def test_validate_export_data_empty_version(export_service): """Test validation failure for empty version.""" @@ -1428,6 +1495,7 @@ async def test_export_selected_resources_success_and_empty_list(export_service, db_resource = MagicMock() db_resource.id = "r1" db_resource.name = "res" + db_resource.gateway_id = None db_resource.uri = "file:///x" db_resource.description = "desc" db_resource.mime_type = "text/plain" @@ -1668,6 +1736,7 @@ async def test_export_selected_resources_scoped_visibility_filters_non_visible(e visible_resource = MagicMock() visible_resource.uri = "file:///visible.txt" visible_resource.name = "visible" + visible_resource.gateway_id = None visible_resource.description = "visible" visible_resource.mime_type = "text/plain" visible_resource.tags = [] diff --git a/tests/unit/mcpgateway/services/test_gateway_service.py b/tests/unit/mcpgateway/services/test_gateway_service.py index a743d46859..88c89ac888 100644 --- a/tests/unit/mcpgateway/services/test_gateway_service.py +++ b/tests/unit/mcpgateway/services/test_gateway_service.py @@ -4418,7 +4418,9 @@ async def _fake_wait_for(coro, timeout): # noqa: ARG001 @pytest.mark.asyncio -async def test_register_gateway_reassigns_orphaned_resource(gateway_service, monkeypatch): +@pytest.mark.parametrize("base,expected_base", [("old-resource", "resource"), ("chosen", "chosen"), ("", "")]) +async def test_register_gateway_reassigns_orphaned_resource(gateway_service, monkeypatch, base, expected_base): + """Adoption follows upstream identity while preserving an operator's base.""" # First-Party from mcpgateway.schemas import PromptCreate, ResourceCreate @@ -4433,6 +4435,9 @@ async def test_register_gateway_reassigns_orphaned_resource(gateway_service, mon prompt = PromptCreate(name="Prompt", title="Prompt Title", description="Test prompt", template="Hello") existing = MagicMock() + existing.name = "old-gateway-old-resource" + existing.original_name = "Old Resource" + existing.custom_name_slug = base existing.gateway_id = None existing.team_id = "team-1" existing.owner_email = "owner@example.com" @@ -4485,6 +4490,9 @@ async def test_register_gateway_reassigns_orphaned_resource(gateway_service, mon added_gateway = db.add.call_args[0][0] assert existing in added_gateway.resources assert existing.title == "Resource Title" + assert existing.original_name == "Resource" + assert existing.custom_name_slug == expected_base + assert existing.name == "old-gateway-old-resource" assert existing_prompt in added_gateway.prompts assert existing_prompt.title == "Prompt Title" @@ -5685,7 +5693,11 @@ def test_empty_resources_returns_empty(self, gateway_service, mock_gateway): result = gateway_service._update_or_create_resources(MagicMock(), [], mock_gateway, "test") assert result == [] - def test_new_resource_created(self, gateway_service, mock_gateway): + def test_new_resource_created(self, gateway_service): + """New resources carry a real gateway relationship before persistence.""" + from mcpgateway.db import Gateway + + mock_gateway = Gateway(id="gw-1", name="gateway", slug="gateway", url="https://example.com", capabilities={}) db = MagicMock() db.execute.return_value.scalars.return_value.all.return_value = [] resource = SimpleNamespace( @@ -5704,6 +5716,8 @@ def test_existing_resource_updated(self, gateway_service, mock_gateway): existing = MagicMock() existing.uri = "file:///res" existing.name = "old-name" + existing.original_name = "old-name" + existing.custom_name_slug = "old-name" existing.description = "old" existing.mime_type = "text/plain" existing.uri_template = None @@ -5720,7 +5734,9 @@ def test_existing_resource_updated(self, gateway_service, mock_gateway): mock_gateway.visibility = "public" result = gateway_service._update_or_create_resources(db, [resource], mock_gateway, "update") assert result == [] - assert existing.name == "new-name" + assert existing.original_name == "new-name" + assert existing.custom_name_slug == "new-name" + assert existing.name == "old-name" # Recomposition is owned by the ORM listener. assert existing.mime_type == "text/html" def test_existing_resource_title_updated(self, gateway_service, mock_gateway): @@ -5728,6 +5744,8 @@ def test_existing_resource_title_updated(self, gateway_service, mock_gateway): existing = SimpleNamespace( uri="file:///res", name="res", + original_name="res", + custom_name_slug="res", description="desc", mime_type="text/plain", uri_template=None, @@ -5749,7 +5767,11 @@ def test_existing_resource_title_updated(self, gateway_service, mock_gateway): assert result == [] assert existing.title == "new title" - def test_none_resource_skipped(self, gateway_service, mock_gateway): + def test_none_resource_skipped(self, gateway_service): + """An invalid catalog entry does not discard valid resource entries.""" + from mcpgateway.db import Gateway + + mock_gateway = Gateway(id="gw-1", name="gateway", slug="gateway", url="https://example.com", capabilities={}) db = MagicMock() db.execute.return_value.scalars.return_value.all.return_value = [] resource = SimpleNamespace( diff --git a/tests/unit/mcpgateway/services/test_gateway_service_extended.py b/tests/unit/mcpgateway/services/test_gateway_service_extended.py index 937239755f..d707f928f7 100644 --- a/tests/unit/mcpgateway/services/test_gateway_service_extended.py +++ b/tests/unit/mcpgateway/services/test_gateway_service_extended.py @@ -902,6 +902,8 @@ async def test_update_or_create_resources_existing_resources(self): existing_resource = MagicMock() existing_resource.uri = "file:///test.txt" existing_resource.name = "test.txt" + existing_resource.original_name = "test.txt" + existing_resource.custom_name_slug = "test-txt" existing_resource.description = "Old description" existing_resource.mime_type = "text/plain" existing_resource.uri_template = None @@ -1651,6 +1653,8 @@ async def test_helper_methods_resource_removal_scenario(self): existing_resource1 = MagicMock() existing_resource1.uri = "file:///keep.txt" existing_resource1.name = "keep.txt" + existing_resource1.original_name = "keep.txt" + existing_resource1.custom_name_slug = "keep-txt" existing_resource1.description = "Keep this resource" existing_resource1.mime_type = "text/plain" existing_resource1.template = None @@ -1659,6 +1663,8 @@ async def test_helper_methods_resource_removal_scenario(self): existing_resource3 = MagicMock() existing_resource3.uri = "file:///update.txt" existing_resource3.name = "update.txt" + existing_resource3.original_name = "update.txt" + existing_resource3.custom_name_slug = "update-txt" existing_resource3.description = "Old description" existing_resource3.mime_type = "text/plain" existing_resource3.template = None diff --git a/tests/unit/mcpgateway/services/test_resource_service.py b/tests/unit/mcpgateway/services/test_resource_service.py index 0919457299..3f7b5b0fb3 100644 --- a/tests/unit/mcpgateway/services/test_resource_service.py +++ b/tests/unit/mcpgateway/services/test_resource_service.py @@ -1077,6 +1077,43 @@ async def test_update_resource_accepts_ui_extension_metadata(self, resource_serv assert result == {"id": mock_resource.id} assert mock_resource.extension_metadata == metadata + @pytest.mark.asyncio + @pytest.mark.parametrize("submitted,expected_base", [("gateway-report", "report"), ("report", "report"), ("Chosen Name", "chosen-name"), ("---", "")]) + async def test_resource_namespacing_admin_save(self, resource_service, mock_db, mock_resource, submitted, expected_base): + """Repeated Admin saves preserve the base unless the submitted name changes.""" + mock_resource.gateway_id = "gateway" + mock_resource.name = "gateway-report" + mock_resource.original_name = "Report" + mock_resource.custom_name_slug = "report" + mock_db.get.return_value = mock_resource + mock_db.execute.return_value.scalar_one_or_none.return_value = mock_resource + with ( + patch.object(resource_service, "_notify_resource_updated", new_callable=AsyncMock), + patch.object(resource_service, "convert_resource_to_read", return_value={"id": mock_resource.id}), + ): + for _ in range(2): + await resource_service.update_resource(mock_db, mock_resource.id, ResourceUpdate(name=submitted, description="Changed description")) + assert mock_resource.custom_name_slug == expected_base + assert mock_resource.original_name == "Report" + # The ORM listener, not this service, owns the derived name. + assert mock_resource.name == "gateway-report" + + @pytest.mark.asyncio + async def test_resource_namespacing_local_rename_records_base(self, resource_service, mock_db, mock_resource): + """Local names remain verbatim while preserving rename intent for adoption.""" + mock_resource.original_name = "Original Name" + mock_resource.custom_name_slug = "original-name" + mock_db.get.return_value = mock_resource + mock_db.execute.return_value.scalar_one_or_none.return_value = mock_resource + with ( + patch.object(resource_service, "_notify_resource_updated", new_callable=AsyncMock), + patch.object(resource_service, "convert_resource_to_read", return_value={"id": mock_resource.id}), + ): + await resource_service.update_resource(mock_db, mock_resource.id, ResourceUpdate(name="My Report")) + assert mock_resource.name == "My Report" + assert mock_resource.custom_name_slug == "my-report" + assert mock_resource.original_name == "Original Name" + @pytest.mark.asyncio async def test_update_resource_rejects_ui_uri_without_policy_metadata(self, resource_service, mock_db, mock_resource, monkeypatch): """Updating a resource into ui:// must enforce the same policy metadata as creation."""