This is an automated email from the ASF dual-hosted git repository.

villebro pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 0d5827ac42 chore(extensions): unified contribution api and automatic 
prefixing (#38412)
0d5827ac42 is described below

commit 0d5827ac42878de1bc4218017a1ff307a3865fdb
Author: Ville Brofeldt <[email protected]>
AuthorDate: Wed Mar 4 14:51:22 2026 -0800

    chore(extensions): unified contribution api and automatic prefixing (#38412)
---
 .../extensions/contribution-types.md               | 51 +++++++++---
 docs/developer_docs/extensions/development.md      | 62 +++++++++-----
 docs/developer_docs/extensions/overview.md         |  2 +-
 docs/developer_docs/extensions/quick-start.md      | 44 +++++-----
 superset-core/src/superset_core/api/rest_api.py    | 97 ++++++++++++++++------
 superset/core/api/core_api_injection.py            | 64 +++++++++++---
 superset/core/mcp/core_mcp_injection.py            | 66 +++++++++++++--
 superset/extensions/context.py                     | 90 ++++++++++++++++++++
 superset/extensions/contributions.py               | 94 +++++++++++++++++++++
 superset/initialization/__init__.py                |  5 +-
 superset/tasks/decorators.py                       | 15 +++-
 11 files changed, 490 insertions(+), 100 deletions(-)

diff --git a/docs/developer_docs/extensions/contribution-types.md 
b/docs/developer_docs/extensions/contribution-types.md
index e6fddef7ab..945e288b93 100644
--- a/docs/developer_docs/extensions/contribution-types.md
+++ b/docs/developer_docs/extensions/contribution-types.md
@@ -115,22 +115,51 @@ Backend contribution types allow extensions to extend 
Superset's server-side cap
 
 ### REST API Endpoints
 
-Extensions can register custom REST API endpoints under the 
`/api/v1/extensions/` namespace. This dedicated namespace prevents conflicts 
with built-in endpoints and provides a clear separation between core and 
extension functionality.
+Extensions can register custom REST API endpoints under the `/extensions/` 
namespace. This dedicated namespace prevents conflicts with built-in endpoints 
and provides a clear separation between core and extension functionality.
 
-```json
-"backend": {
-  "entryPoints": ["my_extension.entrypoint"],
-  "files": ["backend/src/my_extension/**/*.py"]
-}
+```python
+from superset_core.api.rest_api import RestApi, api
+from flask_appbuilder.api import expose, protect
+
+@api(
+    id="my_extension_api",
+    name="My Extension API",
+    description="Custom API endpoints for my extension"
+)
+class MyExtensionAPI(RestApi):
+    @expose("/hello", methods=("GET",))
+    @protect()
+    def hello(self) -> Response:
+        return self.response(200, result={"message": "Hello from extension!"})
+
+# Import the class in entrypoint.py to register it
+from .api import MyExtensionAPI
 ```
 
-The entry point module registers the API with Superset:
+**Note**: The [`@api`](superset-core/src/superset_core/api/rest_api.py:59) 
decorator automatically detects context and generates appropriate paths:
 
-```python
-from superset_core.api.rest_api import add_extension_api
-from .api import MyExtensionAPI
+- **Extension context**: `/extensions/{publisher}/{name}/` with ID prefixed as 
`extensions.{publisher}.{name}.{id}`
+- **Host context**: `/api/v1/` with original ID
 
-add_extension_api(MyExtensionAPI)
+For an extension with publisher `my-org` and name `dataset-tools`, the 
endpoint above would be accessible at:
+```
+/extensions/my-org/dataset-tools/hello
+```
+
+You can also specify a `resource_name` parameter to add an additional path 
segment:
+
+```python
+@api(
+    id="analytics_api",
+    name="Analytics API",
+    resource_name="analytics"  # Adds /analytics to the path
+)
+class AnalyticsAPI(RestApi):
+    @expose("/insights", methods=("GET",))
+    def insights(self):
+        # This endpoint will be available at:
+        # /extensions/my-org/dataset-tools/analytics/insights
+        return self.response(200, result={"insights": []})
 ```
 
 ### MCP Tools and Prompts
diff --git a/docs/developer_docs/extensions/development.md 
b/docs/developer_docs/extensions/development.md
index 41b112abb8..c6baa63598 100644
--- a/docs/developer_docs/extensions/development.md
+++ b/docs/developer_docs/extensions/development.md
@@ -203,31 +203,51 @@ Extension endpoints are registered under a dedicated 
`/extensions` namespace to
 ```python
 from superset_core.api.models import Database, get_session
 from superset_core.api.daos import DatabaseDAO
-from superset_core.api.rest_api import add_extension_api
-from .api import DatasetReferencesAPI
+from superset_core.api.rest_api import RestApi, api
+from flask_appbuilder.api import expose, protect
 
-# Register a new extension REST API
-add_extension_api(DatasetReferencesAPI)
-
-# Fetch Superset entities via the DAO to apply base filters that filter out 
entities
-# that the user doesn't have access to
-databases = DatabaseDAO.find_all()
+@api(
+    id="dataset_references_api",
+    name="Dataset References API",
+    description="API for managing dataset references"
+)
+class DatasetReferencesAPI(RestApi):
+    @expose("/datasets", methods=("GET",))
+    @protect()
+    def get_datasets(self) -> Response:
+        """Get all accessible datasets."""
+        # Fetch Superset entities via the DAO to apply base filters that 
filter out entities
+        # that the user doesn't have access to
+        databases = DatabaseDAO.find_all()
+
+        # ..or apply simple filters on top of base filters
+        databases = DatabaseDAO.filter_by(uuid=database.uuid)
+        if not databases:
+            raise Exception("Database not found")
+
+        return self.response(200, result={"databases": databases})
+
+    @expose("/search", methods=("GET",))
+    @protect()
+    def search_databases(self) -> Response:
+        """Search databases with complex queries."""
+        # Perform complex queries using SQLAlchemy Query, also filtering out
+        # inaccessible entities
+        session = get_session()
+        databases_query = session.query(Database).filter(
+            Database.database_name.ilike("%abc%")
+        )
+        databases = DatabaseDAO.query(databases_query)
+
+        return self.response(200, result={"databases": databases})
+```
 
-# ..or apply simple filters on top of base filters
-databases = DatabaseDAO.filter_by(uuid=database.uuid)
-if not databases:
-    raise Exception("Database not found")
+### Automatic Context Detection
 
-return databases[0]
+The [`@api`](superset-core/src/superset_core/api/rest_api.py:59) decorator 
automatically detects whether it's being used in host or extension code:
 
-# Perform complex queries using SQLAlchemy Query, also filtering out
-# inaccessible entities
-session = get_session()
-databases_query = session.query(Database).filter(
-    Database.database_name.ilike("%abc%")
-)
-return DatabaseDAO.query(databases_query)
-```
+- **Extension APIs**: Registered under `/extensions/{publisher}/{name}/` with 
IDs prefixed as `extensions.{publisher}.{name}.{id}`
+- **Host APIs**: Registered under `/api/v1/` with original IDs
 
 In the future, we plan to expand the backend APIs to support configuring 
security models, database engines, SQL Alchemy dialects, etc.
 
diff --git a/docs/developer_docs/extensions/overview.md 
b/docs/developer_docs/extensions/overview.md
index be8628836d..2a6bce06c9 100644
--- a/docs/developer_docs/extensions/overview.md
+++ b/docs/developer_docs/extensions/overview.md
@@ -38,7 +38,7 @@ Extensions can provide:
 
 - **Custom UI Components**: New panels, views, and interactive elements
 - **Commands and Menus**: Custom actions accessible via menus and keyboard 
shortcuts
-- **REST API Endpoints**: Backend services under the `/api/v1/extensions/` 
namespace
+- **REST API Endpoints**: Backend services under the `/extensions/` namespace
 - **MCP Tools and Prompts**: AI agent capabilities for enhanced user assistance
 
 ## UI Components for Extensions
diff --git a/docs/developer_docs/extensions/quick-start.md 
b/docs/developer_docs/extensions/quick-start.md
index a3ac8390bd..1f0e97720b 100644
--- a/docs/developer_docs/extensions/quick-start.md
+++ b/docs/developer_docs/extensions/quick-start.md
@@ -129,11 +129,15 @@ The CLI generated a basic 
`backend/src/superset_extensions/my_org/hello_world/en
 ```python
 from flask import Response
 from flask_appbuilder.api import expose, protect, safe
-from superset_core.api.rest_api import RestApi
+from superset_core.api.rest_api import RestApi, api
 
 
+@api(
+    id="hello_world_api",
+    name="Hello World API",
+    description="API endpoints for the Hello World extension"
+)
 class HelloWorldAPI(RestApi):
-    resource_name = "hello_world"
     openapi_spec_tag = "Hello World"
     class_permission_name = "hello_world"
 
@@ -170,25 +174,25 @@ class HelloWorldAPI(RestApi):
 
 **Key points:**
 
-- Extends `RestApi` from `superset_core.api.types.rest_api`
+- Uses [`@api`](superset-core/src/superset_core/api/rest_api.py:59) decorator 
with automatic context detection
+- Extends `RestApi` from `superset_core.api.rest_api`
 - Uses Flask-AppBuilder decorators (`@expose`, `@protect`, `@safe`)
 - Returns responses using `self.response(status_code, result=data)`
-- The endpoint will be accessible at `/extensions/my-org/hello-world/message`
+- The endpoint will be accessible at `/extensions/my-org/hello-world/message` 
(automatic extension context)
 - OpenAPI docstrings are crucial - Flask-AppBuilder uses them to automatically 
generate interactive API documentation at `/swagger/v1`, allowing developers to 
explore endpoints, understand schemas, and test the API directly from the 
browser
 
 **Update `backend/src/superset_extensions/my_org/hello_world/entrypoint.py`**
 
-Replace the generated print statement with API registration:
+Replace the generated print statement with API import to trigger registration:
 
 ```python
-from superset_core.api import rest_api
-
+# Importing the API class triggers the @api decorator registration
 from .api import HelloWorldAPI
 
-rest_api.add_extension_api(HelloWorldAPI)
+print("Hello World extension loaded successfully!")
 ```
 
-This registers your API with Superset when the extension loads.
+The [`@api`](superset-core/src/superset_core/api/rest_api.py:59) decorator 
automatically detects extension context and registers your API with proper 
namespacing.
 
 ## Step 5: Create Frontend Component
 
@@ -328,16 +332,16 @@ const HelloWorldPanel: React.FC = () => {
   const [error, setError] = useState<string>('');
 
   useEffect(() => {
-    const fetchMessage = async () => {
-      try {
-        const csrfToken = await authentication.getCSRFToken();
-        const response = await fetch('/extensions/my-org/hello-world/message', 
{
-          method: 'GET',
-          headers: {
-            'Content-Type': 'application/json',
-            'X-CSRFToken': csrfToken!,
-          },
-        });
+      const fetchMessage = async () => {
+          try {
+              const csrfToken = await authentication.getCSRFToken();
+              const response = await 
fetch('/extensions/my-org/hello-world/message', {
+                  method: 'GET',
+                  headers: {
+                      'Content-Type': 'application/json',
+                      'X-CSRFToken': csrfToken!,
+                  },
+              });
 
         if (!response.ok) {
           throw new Error(`Server returned ${response.status}`);
@@ -493,7 +497,7 @@ Superset will extract and validate the extension metadata, 
load the assets, regi
 Here's what happens when your extension loads:
 
 1. **Superset starts**: Reads `extension.json` and loads the backend entrypoint
-2. **Backend registration**: `entrypoint.py` registers your API via 
`rest_api.add_extension_api()`
+2. **Backend registration**: `entrypoint.py` imports your API class, 
triggering the [`@api`](superset-core/src/superset_core/api/rest_api.py:59) 
decorator to register it automatically
 3. **Frontend loads**: When SQL Lab opens, Superset fetches the remote entry 
file
 4. **Module Federation**: Webpack loads your extension module and resolves 
`@apache-superset/core` to `window.superset`
 5. **Registration**: The module executes at load time, calling 
`views.registerView` to register your panel
diff --git a/superset-core/src/superset_core/api/rest_api.py 
b/superset-core/src/superset_core/api/rest_api.py
index 05ead50a90..40d7d2ce8a 100644
--- a/superset-core/src/superset_core/api/rest_api.py
+++ b/superset-core/src/superset_core/api/rest_api.py
@@ -16,20 +16,31 @@
 # under the License.
 
 """
-REST API functions for superset-core.
+REST API functions and decorators for superset-core.
 
-Provides dependency-injected REST API utility functions that will be replaced 
by
-host implementations during initialization.
+Provides dependency-injected REST API utility functions and decorators that 
will be
+replaced by host implementations during initialization.
 
 Usage:
-    from superset_core.api.rest_api import add_api, add_extension_api
-
-    add_api(MyCustomAPI)
-    add_extension_api(MyExtensionAPI)
+    from superset_core.api.rest_api import api
+
+    # Unified decorator for both host and extension APIs
+    @api(
+        id="main_api",
+        name="Main API",
+        description="Primary endpoints"
+    )
+    class MyAPI(RestApi):
+        pass
 """
 
+from typing import Callable, TypeVar
+
 from flask_appbuilder.api import BaseApi
 
+# Type variable for decorated API classes
+T = TypeVar("T", bound=type["RestApi"])
+
 
 class RestApi(BaseApi):
     """
@@ -42,31 +53,65 @@ class RestApi(BaseApi):
     allow_browser_login = True
 
 
-def add_api(api: type[RestApi]) -> None:
-    """
-    Add a REST API to the Superset API.
-
-    Host implementations will replace this function during initialization
-    with a concrete implementation providing actual functionality.
-
-    :param api: A REST API instance.
-    :returns: None.
+def api(
+    id: str,
+    name: str,
+    description: str | None = None,
+    resource_name: str | None = None,
+) -> Callable[[T], T]:
     """
-    raise NotImplementedError("Function will be replaced during 
initialization")
+    Unified API decorator for both host and extension APIs.
 
-
-def add_extension_api(api: type[RestApi]) -> None:
-    """
-    Add an extension REST API to the Superset API.
+    Automatically detects context:
+    - Host context: /api/v1/{resource_name}/
+    - Extension context: /extensions/{publisher}/{name}/{resource_name}/
 
     Host implementations will replace this function during initialization
     with a concrete implementation providing actual functionality.
 
-    :param api: An extension REST API instance. These are placed under
-        the /extensions resource.
-    :returns: None.
+    Args:
+        id: Unique API identifier (e.g., "main_api", "analytics_api")
+        name: Human-readable display name (e.g., "Main API")
+        description: Optional description for documentation
+        resource_name: Optional additional path segment for API grouping
+
+    Returns:
+        Decorated API class with automatic path configuration
+
+    Raises:
+        NotImplementedError: If called before host implementation is 
initialized
+
+    Example:
+        @api(
+            id="main_api",
+            name="Main API",
+            description="Primary extension endpoints"
+        )
+        class MyExtensionAPI(RestApi):
+            @expose("/hello", methods=("GET",))
+            @protect()
+            def hello(self) -> Response:
+                # Available at: /extensions/acme/tools/hello (extension 
context)
+                # Available at: /api/v1/hello (host context)
+                return self.response(200, result={"message": "hello"})
+
+        @api(
+            id="analytics_api",
+            name="Analytics API",
+            resource_name="analytics"
+        )
+        class AnalyticsAPI(RestApi):
+            @expose("/insights", methods=("GET",))
+            @protect()
+            def insights(self) -> Response:
+                # Available at: /extensions/acme/tools/analytics/insights 
(extension)
+                # Available at: /api/v1/analytics/insights (host)
+                return self.response(200, result={})
     """
-    raise NotImplementedError("Function will be replaced during 
initialization")
+    raise NotImplementedError(
+        "API decorator not initialized. "
+        "This decorator should be replaced during Superset startup."
+    )
 
 
-__all__ = ["RestApi", "add_api", "add_extension_api"]
+__all__ = ["RestApi", "api"]
diff --git a/superset/core/api/core_api_injection.py 
b/superset/core/api/core_api_injection.py
index be4ea69db4..6744faded8 100644
--- a/superset/core/api/core_api_injection.py
+++ b/superset/core/api/core_api_injection.py
@@ -23,10 +23,12 @@ into the abstract superset-core API modules. This allows 
the core API
 to be used with direct imports while maintaining loose coupling.
 """
 
-from typing import Any, TYPE_CHECKING
+from typing import Any, Callable, TYPE_CHECKING, TypeVar
 
 from sqlalchemy.orm import scoped_session
 
+from superset.extensions.context import get_current_extension_context
+
 if TYPE_CHECKING:
     from superset_core.api.models import Database
     from superset_core.api.rest_api import RestApi
@@ -137,24 +139,66 @@ def inject_task_implementations() -> None:
 
 def inject_rest_api_implementations() -> None:
     """
-    Replace abstract REST API functions in superset_core.api.rest_api with 
concrete
-    implementations from Superset.
+    Replace abstract REST API functions and decorators in 
superset_core.api.rest_api
+    with concrete implementations from Superset.
     """
     import superset_core.api.rest_api as core_rest_api_module
 
     from superset.extensions import appbuilder
 
-    def add_api(api: "type[RestApi]") -> None:
-        view = appbuilder.add_api(api)
-        appbuilder._add_permission(view, True)
+    T = TypeVar("T", bound=type["RestApi"])
 
-    def add_extension_api(api: "type[RestApi]") -> None:
-        api.route_base = "/extensions/" + (api.resource_name or "")
+    def add_api(api: "type[RestApi]") -> None:
         view = appbuilder.add_api(api)
         appbuilder._add_permission(view, True)
 
-    core_rest_api_module.add_api = add_api
-    core_rest_api_module.add_extension_api = add_extension_api
+    def api_impl(
+        id: str,
+        name: str,
+        description: str | None = None,
+        resource_name: str | None = None,
+    ) -> Callable[[T], T]:
+        def decorator(api_class: T) -> T:
+            # Check for ambient extension context
+            context = get_current_extension_context()
+
+            if context:
+                # EXTENSION CONTEXT
+                manifest = context.manifest
+                base_path = f"/extensions/{manifest.publisher}/{manifest.name}"
+                prefixed_id = 
f"extensions.{manifest.publisher}.{manifest.name}.{id}"
+
+            else:
+                # HOST CONTEXT
+                base_path = "/api/v1"
+                prefixed_id = id
+
+            # Add resource_name to path for both contexts
+            if resource_name:
+                base_path += f"/{resource_name}"
+
+            # Set route base and register immediately
+            api_class.route_base = base_path
+            api_class._api_id = prefixed_id
+            api_class._api_metadata = {
+                "id": prefixed_id,
+                "name": name,
+                "description": description,
+                "resource_name": resource_name,
+                "is_extension": context is not None,
+                "context": context,
+            }
+
+            # Register with Flask-AppBuilder immediately
+            view = appbuilder.add_api(api_class)
+            appbuilder._add_permission(view, True)
+
+            return api_class
+
+        return decorator
+
+    # Replace core implementations with unified API decorator
+    core_rest_api_module.api = api_impl
 
 
 def inject_model_session_implementation() -> None:
diff --git a/superset/core/mcp/core_mcp_injection.py 
b/superset/core/mcp/core_mcp_injection.py
index 0ecde4ff59..b580e13e28 100644
--- a/superset/core/mcp/core_mcp_injection.py
+++ b/superset/core/mcp/core_mcp_injection.py
@@ -25,12 +25,34 @@ that replaces the abstract functions in superset-core 
during initialization.
 import logging
 from typing import Any, Callable, Optional, TypeVar
 
+from superset.extensions.context import get_current_extension_context
+
 # Type variable for decorated functions
 F = TypeVar("F", bound=Callable[..., Any])
 
 logger = logging.getLogger(__name__)
 
 
+def _get_prefixed_id_with_context(base_id: str) -> tuple[str, str]:
+    """
+    Get ID with extension prefixing based on ambient context.
+
+    Returns:
+        Tuple of (prefixed_id, context_type) where context_type is 'extension' 
or 'host'
+    """
+    if context := get_current_extension_context():
+        # Extension context: prefix ID to prevent collisions
+        manifest = context.manifest
+        prefixed_id = 
f"extensions.{manifest.publisher}.{manifest.name}.{base_id}"
+        context_type = "extension"
+    else:
+        # Host context: use original ID
+        prefixed_id = base_id
+        context_type = "host"
+
+    return prefixed_id, context_type
+
+
 def create_tool_decorator(
     func_or_name: str | Callable[..., Any] | None = None,
     *,
@@ -66,10 +88,13 @@ def create_tool_decorator(
             from superset.mcp_service.app import mcp
 
             # Use provided values or extract from function
-            tool_name = name or func.__name__
-            tool_description = description or func.__doc__ or f"Tool: 
{tool_name}"
+            base_tool_name = name or func.__name__
+            tool_description = description or func.__doc__ or f"Tool: 
{base_tool_name}"
             tool_tags = tags or []
 
+            # Get prefixed ID based on ambient context
+            tool_name, context_type = 
_get_prefixed_id_with_context(base_tool_name)
+
             # Conditionally apply authentication wrapper
             if protect:
                 from superset.mcp_service.auth import mcp_auth_hook
@@ -89,7 +114,12 @@ def create_tool_decorator(
             mcp.add_tool(tool)
 
             protected_status = "protected" if protect else "public"
-            logger.info("Registered MCP tool: %s (%s)", tool_name, 
protected_status)
+            logger.info(
+                "Registered MCP tool: %s (%s, %s)",
+                tool_name,
+                protected_status,
+                context_type,
+            )
             return wrapped_func
 
         except Exception as e:
@@ -153,11 +183,16 @@ def create_prompt_decorator(
             from superset.mcp_service.app import mcp
 
             # Use provided values or extract from function
-            prompt_name = name or func.__name__
+            base_prompt_name = name or func.__name__
             prompt_title = title or func.__name__
-            prompt_description = description or func.__doc__ or f"Prompt: 
{prompt_name}"
+            prompt_description = (
+                description or func.__doc__ or f"Prompt: {base_prompt_name}"
+            )
             prompt_tags = tags or set()
 
+            # Get prefixed ID based on ambient context
+            prompt_name, context_type = 
_get_prefixed_id_with_context(base_prompt_name)
+
             # Conditionally apply authentication wrapper
             if protect:
                 from superset.mcp_service.auth import mcp_auth_hook
@@ -175,7 +210,12 @@ def create_prompt_decorator(
             )(wrapped_func)
 
             protected_status = "protected" if protect else "public"
-            logger.info("Registered MCP prompt: %s (%s)", prompt_name, 
protected_status)
+            logger.info(
+                "Registered MCP prompt: %s (%s, %s)",
+                prompt_name,
+                protected_status,
+                context_type,
+            )
             return wrapped_func
 
         except Exception as e:
@@ -208,18 +248,26 @@ def initialize_core_mcp_dependencies() -> None:
     """
     Initialize MCP dependency injection by replacing abstract functions
     in superset_core.api.mcp with concrete implementations.
+
+    Also imports MCP service app to register all host tools BEFORE extension 
loading.
     """
     try:
+        # Replace the abstract decorators with concrete implementations
+
         import superset_core.api.mcp
 
-        # Replace the abstract decorators with concrete implementations
         superset_core.api.mcp.tool = create_tool_decorator
         superset_core.api.mcp.prompt = create_prompt_decorator
 
         logger.info("MCP dependency injection initialized successfully")
 
-    except ImportError as e:
-        logger.warning("superset_core not available, skipping MCP injection: 
%s", e)
+        # Import MCP service app to register host tools BEFORE extension 
loading
+        # This prevents host tools from being registered during extension 
context
+
+        from superset.mcp_service import app  # noqa: F401
+
+        logger.info("MCP service app imported - host tools registered")
+
     except Exception as e:
         logger.error("Failed to initialize MCP dependencies: %s", e)
         raise
diff --git a/superset/extensions/context.py b/superset/extensions/context.py
new file mode 100644
index 0000000000..c2b1ac9ab6
--- /dev/null
+++ b/superset/extensions/context.py
@@ -0,0 +1,90 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""
+Extension Context Management - provides ambient context during extension 
loading.
+
+This module provides a thread-local context system that allows decorators to
+automatically detect whether they are being called in host or extension code
+during extension loading.
+"""
+
+from __future__ import annotations
+
+import contextlib
+from threading import local
+from typing import Any, Generator
+
+from superset_core.extensions.types import Manifest
+
+# Thread-local storage for extension context
+_extension_context: local = local()
+
+
+class ExtensionContext:
+    """Manages ambient extension context during loading."""
+
+    def __init__(self, manifest: Manifest):
+        self.manifest = manifest
+
+    def __enter__(self) -> "ExtensionContext":
+        if getattr(_extension_context, "current", None) is not None:
+            current_extension = _extension_context.current.manifest.id
+            raise RuntimeError(
+                f"Cannot initialize extension {self.manifest.id} while 
extension "
+                f"{current_extension} is already being initialized. "
+                f"Nested extension initialization is not supported."
+            )
+
+        _extension_context.current = self
+        return self
+
+    def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
+        # Clear the current context
+        _extension_context.current = None
+
+
+class ExtensionContextWrapper:
+    """Wrapper for extension context with extensible properties."""
+
+    def __init__(self, manifest: Manifest):
+        self._manifest = manifest
+
+    @property
+    def manifest(self) -> Manifest:
+        """Get the extension manifest."""
+        return self._manifest
+
+    # Future: Add other context properties here
+    # @property
+    # def security_context(self) -> SecurityContext: ...
+    # @property
+    # def build_info(self) -> BuildInfo: ...
+
+
+def get_current_extension_context() -> ExtensionContextWrapper | None:
+    """Get the currently active extension context wrapper, or None if in host 
code."""
+    if context := getattr(_extension_context, "current", None):
+        return ExtensionContextWrapper(context.manifest)
+    return None
+
+
[email protected]
+def extension_context(manifest: Manifest) -> Generator[None, None, None]:
+    """Context manager for setting extension context during loading."""
+    with ExtensionContext(manifest):
+        yield
diff --git a/superset/extensions/contributions.py 
b/superset/extensions/contributions.py
new file mode 100644
index 0000000000..2af407500c
--- /dev/null
+++ b/superset/extensions/contributions.py
@@ -0,0 +1,94 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""
+Unified Contribution Processing System
+
+This module provides a centralized system for processing pending contributions
+from decorators across all contribution types (extension APIs, MCP tools, 
tasks, etc.)
+after extension loading when publisher/name context is available.
+"""
+
+import logging
+from typing import Protocol
+
+from superset_core.extensions.types import Manifest
+
+logger = logging.getLogger(__name__)
+
+
+class ContributionProcessor(Protocol):
+    """Protocol for contribution processor functions."""
+
+    def __call__(self, manifest: Manifest) -> None:
+        """Process pending contributions for an extension."""
+        ...
+
+
+class ContributionProcessorRegistry:
+    """Registry for contribution processors from different decorator 
systems."""
+
+    def __init__(self) -> None:
+        self._processors: list[ContributionProcessor] = []
+
+    def register_processor(self, processor: ContributionProcessor) -> None:
+        """Register a contribution processor function."""
+        self._processors.append(processor)
+        logger.debug(
+            "Registered contribution processor: %s",
+            getattr(processor, "__name__", repr(processor)),
+        )
+
+    def process_all_contributions(self, manifest: Manifest) -> None:
+        """Process all pending contributions for an extension."""
+        logger.debug(
+            "Processing %d contribution processors for %s",
+            len(self._processors),
+            manifest.id,
+        )
+
+        for processor in self._processors:
+            try:
+                processor(manifest)
+            except Exception as e:
+                logger.error(
+                    "Failed to process contributions with %s for %s: %s",
+                    getattr(processor, "__name__", repr(processor)),
+                    manifest.id,
+                    e,
+                )
+
+
+# Global registry instance
+_contribution_registry = ContributionProcessorRegistry()
+
+
+def register_contribution_processor(processor: ContributionProcessor) -> None:
+    """Register a contribution processor function."""
+    _contribution_registry.register_processor(processor)
+
+
+def process_extension_contributions(manifest: Manifest) -> None:
+    """Process all pending contributions for an extension."""
+    _contribution_registry.process_all_contributions(manifest)
+
+
+__all__ = [
+    "ContributionProcessor",
+    "register_contribution_processor",
+    "process_extension_contributions",
+]
diff --git a/superset/initialization/__init__.py 
b/superset/initialization/__init__.py
index 25cc42e16d..abd2943f1f 100644
--- a/superset/initialization/__init__.py
+++ b/superset/initialization/__init__.py
@@ -59,6 +59,7 @@ from superset.extensions import (
     stats_logger_manager,
     talisman,
 )
+from superset.extensions.context import extension_context
 from superset.security import SupersetSecurityManager
 from superset.sql.parse import SQLGLOT_DIALECTS
 from superset.superset_typing import FlaskResponse
@@ -589,7 +590,9 @@ class SupersetAppInitializer:  # pylint: 
disable=too-many-public-methods
 
             if backend and backend.entrypoint:
                 try:
-                    eager_import(backend.entrypoint)
+                    with extension_context(extension.manifest):
+                        eager_import(backend.entrypoint)
+
                 except Exception as ex:  # pylint: disable=broad-except  # 
noqa: S110
                     # Surface exceptions during initialization of extensions
                     print(ex)
diff --git a/superset/tasks/decorators.py b/superset/tasks/decorators.py
index e2f753a94a..fd795af35d 100644
--- a/superset/tasks/decorators.py
+++ b/superset/tasks/decorators.py
@@ -112,7 +112,20 @@ def task(
 
     def decorator(f: Callable[P, R]) -> "TaskWrapper[P]":
         # Use function name if no name provided
-        task_name = name if name is not None else f.__name__
+        base_task_name = name if name is not None else f.__name__
+
+        # Apply ambient context detection for ID prefixing (like MCP 
decorators)
+        from superset.extensions.context import get_current_extension_context
+
+        if context := get_current_extension_context():
+            # Extension context: prefix task name to prevent collisions
+            manifest = context.manifest
+            task_name = (
+                
f"extensions.{manifest.publisher}.{manifest.name}.{base_task_name}"
+            )
+        else:
+            # Host context: use original task name
+            task_name = base_task_name
 
         # Create default options with no scope (scope is now in decorator)
         default_options = TaskOptions()

Reply via email to