Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,31 @@ This detects all standard and Windows Store installation directories, sets up vi

### Widgets (UI tools)

Applications with registered widgets expose an `application/json` manifest at
`widget://examples`. Discover it through MCP `resources/list` and read it with
`resources/read`, using `{"uri": "widget://examples"}` as the request parameters.
The manifest contains `widgets`, `count`, and `loaded`; each widget includes its
route, URI, mode-specific MIME type, metadata, associated tool names, and any
declared `ToolExamples` inputs and outputs. It is built at startup from registered
decorators, including helper-generated options, without executing tools.

For Python access:

```python
from nitrostack import get_widget_registry

registry = get_widget_registry(app) # also available as app.widget_registry
widgets = registry.get_all_widgets()
widget_info = registry.get_widget("ui://widget/card.html") # None if absent
manifest = registry.get_manifest().to_dict()
```

Each application owns its registry, and reads return detached copies. Shared
routes appear once in sorted order, retaining examples from every associated
tool. Applications without widgets expose no additional resource. In widget
applications, `widget://examples` is reserved; a conflicting resource registration
raises a startup error. Existing widget HTML resources and rendering are unchanged.

Bind a static HTML template to a tool with `@widget` and return domain JSON from the handler:

```python
Expand Down
13 changes: 13 additions & 0 deletions nitrostack/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# NitroStack Python SDK Public API Exports

from nitrostack.widgets.registry import (
WidgetExample,
WidgetManifest,
WidgetMetadata,
WidgetRegistry,
get_widget_registry,
)

from nitrostack.core.decorators import (
tool,
resource,
Expand Down Expand Up @@ -228,6 +236,11 @@
"TaskAlreadyTerminalError",
"InvalidTaskTransitionError",
"TaskExpiredError",
"WidgetExample",
"WidgetManifest",
"WidgetMetadata",
"WidgetRegistry",
"get_widget_registry",
"widget_resource_uri",
"Component",
"WidgetCsp",
Expand Down
25 changes: 25 additions & 0 deletions nitrostack/core/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@
from nitrostack.runtime.caller import caller_identity
from nitrostack.tasks.authorization import extract_task_access_context
from nitrostack.widgets.component import Component, find_project_root, load_widget_html, parse_widget_options
from nitrostack.widgets.registry import WIDGET_EXAMPLES_URI, WidgetRegistry
from nitrostack.widgets.mcp_meta import build_call_tool_result_meta, build_tool_list_meta, resource_read_contents_meta
from nitrostack.widgets.route_templates import build_missing_widget

Expand Down Expand Up @@ -686,6 +687,10 @@ def _bootstrap(self) -> None:
event_name = getattr(member, "_mcp_event_name")
EventEmitter.get_instance().bind_instance(event_name, member, instance)

self.widget_registry: WidgetRegistry = WidgetRegistry._from_tools(self._tools.values())
if self.widget_registry.get_all_widgets():
self._register_widget_examples_resource()

# 4. Register Built-in Health check Resource if any checks exist
if HealthCheckRegistry.get_checks():
self._register_health_resource()
Expand Down Expand Up @@ -805,6 +810,26 @@ async def widget_resource_handler(context: ExecutionContext) -> str:
component=component,
)

def _register_widget_examples_resource(self) -> None:
if WIDGET_EXAMPLES_URI in self._resources:
raise ValueError(
f"Resource URI {WIDGET_EXAMPLES_URI!r} is reserved for the widget manifest"
)

async def widget_examples(context: ExecutionContext) -> dict:
return self.widget_registry.get_manifest().to_dict()

self._resources[WIDGET_EXAMPLES_URI] = _ResourceEntry(
config=ResourceConfig(
uri=WIDGET_EXAMPLES_URI,
name="Widget Examples",
description="Provides metadata and examples for all registered UI widgets",
mime_type="application/json",
),
instance=None,
method=widget_examples,
)

def _register_resource(self, instance: Any, method: Callable, resource_config: ResourceConfig) -> None:
gate_registered_schema(
getattr(resource_config, "schema", None)
Expand Down
12 changes: 12 additions & 0 deletions nitrostack/widgets/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
from nitrostack.widgets.registry import (
WidgetExample,
WidgetManifest,
WidgetMetadata,
WidgetRegistry,
get_widget_registry,
)
from nitrostack.core.app_mode import (
OPENAI_SKYBRIDGE_MIME_TYPE,
RESOURCE_MIME_TYPE_MCP_APP,
Expand Down Expand Up @@ -28,6 +35,11 @@
)

__all__ = [
"WidgetExample",
"WidgetManifest",
"WidgetMetadata",
"WidgetRegistry",
"get_widget_registry",
"Component",
"WidgetCsp",
"WidgetOptions",
Expand Down
106 changes: 106 additions & 0 deletions nitrostack/widgets/registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""Application-owned descriptions of bootstrapped widgets, without their HTML."""

from __future__ import annotations

from copy import deepcopy
from dataclasses import asdict, dataclass, field
from typing import TYPE_CHECKING, Any, Iterable

from nitrostack.core.app_mode import get_widget_mime_type
from nitrostack.widgets.mcp_meta import resource_read_contents_meta

if TYPE_CHECKING:
from nitrostack.core.app import McpApplication


WIDGET_EXAMPLES_URI = "widget://examples"


@dataclass(frozen=True)
class WidgetExample:
tool_name: str
input: Any
output: Any
description: str | None = None


@dataclass(frozen=True)
class WidgetMetadata:
route: str
uri: str
name: str
description: str | None
mime_type: str
metadata: dict[str, Any] = field(default_factory=dict)
tool_names: list[str] = field(default_factory=list)
examples: list[WidgetExample] = field(default_factory=list)


@dataclass(frozen=True)
class WidgetManifest:
widgets: list[WidgetMetadata]
count: int
loaded: bool

def to_dict(self) -> dict[str, Any]:
"""Return a detached, serializable manifest, including declared examples."""
return asdict(self)


class WidgetRegistry:
"""A startup snapshot; all reads return copies owned by the caller."""

def __init__(self, widgets: Iterable[WidgetMetadata] = ()) -> None:
self._widgets = {
widget.uri: deepcopy(widget)
for widget in sorted(widgets, key=lambda widget: widget.route)
}

@classmethod
def _from_tools(cls, tools: Iterable[Any]) -> WidgetRegistry:
# Work with final app registrations, after Python has evaluated helpers.
groups: dict[str, list[Any]] = {}
for entry in tools:
if entry.component is not None:
groups.setdefault(entry.component.id, []).append(entry)

widgets = []
for route, entries in sorted(groups.items()):
entries.sort(key=lambda entry: entry.config.name)
component = entries[0].component
examples = [
WidgetExample(
tool_name=entry.config.name,
input=entry.config.examples.input,
output=entry.config.examples.output,
description=entry.config.examples.description,
)
for entry in entries
if entry.config.examples is not None
]
widgets.append(WidgetMetadata(
route=route,
uri=component.resource_uri,
name=component.name,
description=component.description,
mime_type=get_widget_mime_type(),
metadata=resource_read_contents_meta(component) or {},
tool_names=[entry.config.name for entry in entries],
examples=examples,
))
return cls(widgets)

def get_all_widgets(self) -> list[WidgetMetadata]:
return deepcopy(list(self._widgets.values()))

def get_widget(self, uri: str) -> WidgetMetadata | None:
return deepcopy(self._widgets.get(uri))

def get_manifest(self) -> WidgetManifest:
widgets = self.get_all_widgets()
return WidgetManifest(widgets=widgets, count=len(widgets), loaded=True)


def get_widget_registry(app: McpApplication) -> WidgetRegistry:
"""Return this application's registry; no global catalog is maintained."""
return app.widget_registry
61 changes: 61 additions & 0 deletions tests/support/widget_registry_probe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Isolated template bootstrap and real stdio server for registry integration tests."""

import asyncio
import json
import os
from pathlib import Path
import socket
import sys

ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))


def template_manifest(template):
# Fail if bootstrap accidentally tries to contact any external service.
def no_network(*args, **kwargs):
raise AssertionError("Template bootstrap must not make network requests")

socket.socket.connect = no_network
socket.create_connection = no_network
socket.getaddrinfo = no_network
os.environ["DUFFEL_API_KEY"] = "test"
path = ROOT / "nitrostack" / "templates" / template
sys.path.insert(0, str(path))
os.chdir(path)
from app_module import AppModule
from nitrostack.core.app import McpApplication

app = McpApplication(AppModule)
return app.widget_registry.get_manifest().to_dict()


async def stdio_server():
from pydantic import BaseModel
from nitrostack import ExecutionContext, WidgetOptions, module, tool, widget
from nitrostack.core.app import McpApplication
from nitrostack.transports.stdio import run_stdio, safe_stdio_transport

class Empty(BaseModel):
pass

class Tools:
@tool("example", "Example", Empty)
@widget(WidgetOptions(route="stdio-example", html="<p>Example</p>"))
async def example(self, input: Empty, context: ExecutionContext):
raise AssertionError("Listing widgets must not execute tools")

@module(name="stdio-registry", providers=[Tools])
class AppModule:
pass

with safe_stdio_transport():
app = McpApplication(AppModule)
await run_stdio(app.mcp_server, "auto")


if __name__ == "__main__":
if sys.argv[1] == "stdio":
asyncio.run(stdio_server())
else:
print(json.dumps(template_manifest(sys.argv[1])))
Loading
Loading