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