From 0a6291ebcd523da21a6055d6c6f136ebf8055b45 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Thu, 29 Oct 2026 17:15:01 +0530 Subject: [PATCH 1/2] feat: add application widget registry and examples resource --- README.md | 4 + docs/issue73-verification.md | 86 +++++++ docs/widget-registry.md | 157 ++++++++++++ nitrostack/__init__.py | 13 + nitrostack/core/app.py | 25 ++ nitrostack/widgets/__init__.py | 12 + nitrostack/widgets/registry.py | 106 +++++++++ tests/support/widget_registry_probe.py | 61 +++++ tests/test_widget_registry.py | 315 +++++++++++++++++++++++++ 9 files changed, 779 insertions(+) create mode 100644 docs/issue73-verification.md create mode 100644 docs/widget-registry.md create mode 100644 nitrostack/widgets/registry.py create mode 100644 tests/support/widget_registry_probe.py create mode 100644 tests/test_widget_registry.py diff --git a/README.md b/README.md index bf299b1..c38bef1 100644 --- a/README.md +++ b/README.md @@ -330,6 +330,10 @@ This detects all standard and Windows Store installation directories, sets up vi ### Widgets (UI tools) +Registered widgets are also available as a JSON manifest at `widget://examples`. +See [Widget registry and examples](docs/widget-registry.md) for resource reads, +Python registry access, declared examples and duplicate-route behavior. + Bind a static HTML template to a tool with `@widget` and return domain JSON from the handler: ```python diff --git a/docs/issue73-verification.md b/docs/issue73-verification.md new file mode 100644 index 0000000..c123f91 --- /dev/null +++ b/docs/issue73-verification.md @@ -0,0 +1,86 @@ +# Issue #73 implementation verification + +Validated locally on 2026-10-29. Branch: `feature/issue-73-widget-registry`, based +on upstream `develop` at `63ecd9240d98bbe06c879089dd887494c7e99293`. +No PR #94, spillover, OAuth-client or HTTP-factory branch commits are included. + +## Acceptance criteria + +- Actual starter, Pizzaz and OAuth/flight-booking modules bootstrap in isolated + subprocesses with networking blocked. Their manifests contain exactly 1, 3 + and 6 unique routes, respectively, with URI and mode-correct MIME type. +- Pizzaz helper-built options retain their CSP/border metadata and examples. +- Shared `order-summary` appears once with both tool names. Registry unit tests + additionally verify merging all explicit examples and deterministic ordering. +- Empty applications keep their resource catalog unchanged and report an empty, + loaded Python registry. Reserved-URI collisions are rejected only in widget apps. +- Modern and legacy HTTP clients can list/read the manifest. A real stdio + subprocess serves both operations with no non-protocol stdout. Additional MCP + server instances have the same resource handlers. Existing widget HTML reads + and metadata continue to match their components. + +## Results + +All runs use official MCP **2.3.0**. Linux runs use local WSL Ubuntu, not hosted +GitHub Actions. Each complete run includes all tests, without ignores, +deselections, or new skips/xfail. + +| Environment | Complete-suite result | Coverage | +|---|---|---| +| Linux Python 3.10.21 | 847 passed | 86.44% | +| Linux Python 3.11.16 | 847 passed | 86.42% | +| Linux Python 3.12.14 | 847 passed | 86.42% | +| Windows Python 3.10.21 | 847 passed | 86.44% | +| Windows Python 3.11.16 | 847 passed | 86.42% | +| Windows Python 3.12.14 | 846 passed, 1 failed; see below | 86.42% | + +- Complete collection: **847 tests**, no errors. +- New feature tests: **22 passed** on Windows 3.12, also included in all complete + matrix runs. New registry module: **100% statement coverage**. +- Initial combined widget regression selection: **72 passed**, before adding + two additional registry cases; the final complete runs include all 74 cases. +- `git diff --check` passes. No dependency or version changes were made. +- SHA-256 comparisons confirm Linux tested the same production and new test + source bytes as the Windows worktree. + +## Windows 3.12 socket limitation and baseline + +The final full Windows 3.12 run failed the existing +`test_discovery_server_idempotent_start_and_dcr_http` in `tests/test_oauth.py`. +Its localhost HTTP request raised `ConnectionAbortedError: [WinError 10053]`. +An earlier Windows 3.12 run also encountered that error in the existing +`test_discovery_dcr_404_when_disabled_or_missing_client_id`. All widget tests pass. + +For comparison, the unchanged `63ecd924...` baseline was exported into a separate +directory and tested with the same Windows Python 3.12.14/MCP 2.3.0 environment: + +- Full baseline suite: **825 passed**, 86.22% coverage. +- Baseline OAuth module alone: **34 passed**. +- The two affected baseline OAuth tests were then run together three times. + Attempts 1 and 2 each passed both tests. Attempt 3 reproduced **the same + `test_discovery_server_idempotent_start_and_dcr_http` WinError 10053 failure** + (1 passed, 1 failed). + +This establishes that the socket failure also occurs without the feature and is +intermittent on this host. It does not establish its underlying operating-system +or server-lifecycle cause. The Windows 3.12 complete suite is not reported green, +and no OAuth code or tests were changed to hide it. + +## Reproduction commands + +```powershell +python -m pytest tests --collect-only -o addopts= -q +python -m pytest tests/test_widget_registry.py -o addopts=--timeout=60 -q +python -m pytest tests --cov=nitrostack --cov-report=term-missing --timeout=60 --tb=short -o addopts= -q +``` + +Run the same complete-suite command on Linux Python 3.10, 3.11 and 3.12. To probe +the Windows failure on the unchanged baseline: + +```powershell +python -m pytest tests/test_oauth.py::test_discovery_server_idempotent_start_and_dcr_http tests/test_oauth.py::test_discovery_dcr_404_when_disabled_or_missing_client_id -o addopts=--timeout=60 -q --tb=short +``` + +Local logs are retained outside tracked sources in `D:/NitroStack/issue73-*.log` +and the worktree's ignored `.pytest_cache/linux-3.*.log`. Hosted CI has not been +run because this implementation has not been pushed or published. diff --git a/docs/widget-registry.md b/docs/widget-registry.md new file mode 100644 index 0000000..243676c --- /dev/null +++ b/docs/widget-registry.md @@ -0,0 +1,157 @@ +# Widget registry and examples resource + +Applications with registered `@widget` tools expose a JSON catalog at +`widget://examples`. Clients discover it with `resources/list` and read it with +`resources/read`. Existing `ui://widget/...html` resources continue to serve HTML. + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "resources/read", + "params": {"uri": "widget://examples"} +} +``` + +Use normal client initialization or modern protocol metadata and HTTP headers, +as for any resource read. The response text has MIME type `application/json`: + +```json +{ + "widgets": [ + { + "route": "calculator-result", + "uri": "ui://widget/calculator-result.html", + "name": "calculate", + "description": "Calculate a result", + "mime_type": "text/html;profile=mcp-app", + "metadata": {}, + "tool_names": ["calculate"], + "examples": [ + { + "tool_name": "calculate", + "input": {"a": 2, "b": 3}, + "output": {"result": 5}, + "description": "Add two numbers" + } + ] + } + ], + "count": 1, + "loaded": true +} +``` + +Widget MIME types follow `NITROSTACK_APP_MODE`: `text/html` for OpenAI mode and +`text/html;profile=mcp-app` for MCP App/universal mode. Metadata uses the existing +mode-aware resource builder, including declared CSP, domain and border settings. +Configure the mode before creating the app; the registry is a startup snapshot. + +## Declaring examples + +Existing `ToolExamples` declarations supply both input and output. The registry +never calls tools to generate data or scans source files. + +```python +from pydantic import BaseModel +from nitrostack import ExecutionContext, ToolExamples, tool, widget + +class AddInput(BaseModel): + a: float + b: float + +class CalculatorTools: + @tool( + "calculate", "Calculate a result", AddInput, + examples=ToolExamples( + input={"a": 2, "b": 3}, + output={"result": 5}, + description="Add two numbers", + ), + ) + @widget("calculator-result") + async def calculate(self, input: AddInput, context: ExecutionContext): + return {"result": input.a + input.b} +``` + +Register the provider/controller in a module as usual. String decorators, option +mappings, `WidgetOptions` and helpers returning options are all supported. Tools +without examples have an empty examples list. Explicit `None`, `False`, zero and +empty collections are retained. Use JSON-compatible values. Examples are public +catalog data: do not declare real credentials or private user data in them. +Widget HTML and injected environment credentials are not included. + +## Python access + +```python +from nitrostack import get_widget_registry + +registry = get_widget_registry(app) # identical to app.widget_registry +widgets = registry.get_all_widgets() # list[WidgetMetadata] +widget = registry.get_widget("ui://widget/calculator-result.html") +manifest = registry.get_manifest() # WidgetManifest +data = manifest.to_dict() +``` + +`WidgetExample`, `WidgetMetadata`, `WidgetManifest`, `WidgetRegistry` and +`get_widget_registry` are public in both `nitrostack` and `nitrostack.widgets`. +Missing URI lookups return `None`. Records are frozen dataclasses; nested lists +and dictionaries returned by reads are detached copies. Changing them cannot +alter later reads. Each application owns an independent registry. + +## Shared routes and empty applications + +Each normalized route appears once, sorted lexicographically. Tool names and +examples are sorted by tool name, retaining every declared example. The first +tool name supplies the display name, description and component metadata. This +selection does not change the existing HTML resource's rendering behavior. + +`count` is the unique widget count. `loaded` means startup discovery completed, +including for an empty registry. Repeated reads are stable; no timestamps are +generated. Applications without widgets have an empty Python registry and no +additional MCP resource. A user-defined `widget://examples` is left alone in +such apps. Widget applications reserve that URI and reject collisions at startup. + +The manifest describes registered application widgets. It introduces no session +filtering or authorization policy and grants no tool execution permissions. The +application's existing resource access controls still apply. + +## Implementation explanation + +The registry module separates data descriptions from HTML rendering: + +- The `TYPE_CHECKING` import supplies the application annotation without a + runtime circular import. `WIDGET_EXAMPLES_URI` names the built-in resource. +- `WidgetExample` retains tool name, input, output and optional description. + It never tests example payloads for truthiness. +- `WidgetMetadata` holds one route's URI, MIME type, metadata and tool examples. + Default factories give each record independent mutable fields. +- `WidgetManifest` contains widgets, count and completion status. `to_dict()` + uses `asdict()` to recursively copy the dataclass records into dictionaries. +- `WidgetRegistry.__init__()` sorts records and deep-copies them into a private + URI-keyed mapping. Later changes to source declarations cannot affect it. +- `_from_tools()` visits final registered tool entries, including only entries + with components. Python has already evaluated all decorator helpers. Grouping + by component ID reuses existing route normalization. Sorting groups and tool + names makes output independent of registration order. +- The example comprehension copies only explicit tool examples. Each group's + first component supplies name, description and URI. Existing helpers supply + mode-correct MIME types and resource metadata. No HTML enters the catalog. +- `get_all_widgets()` and `get_widget()` deep-copy the private records. + `get_manifest()` builds on those detached reads and computes their count. + Registry construction is complete at that point, so `loaded` is true. +- `get_widget_registry(app)` returns the application attribute rather than a + global singleton, preventing cross-application catalog leakage. + +Application bootstrap constructs the registry after registering decorated +methods and before installing MCP handlers. A nonempty registry adds one ordinary +resource entry. Its handler returns the manifest dictionary; the existing +resource pipeline serializes it as JSON. Additional configured MCP servers use +the same resource registrations. Existing widget HTML handlers are unchanged. +Package exports expose the types and accessor through both public entry points. + +Integration tests bootstrap actual templates in subprocesses, blocking socket +networking so startup cannot depend on external providers. HTTP tests exercise +modern/sessionless and legacy/sessionful modes. A separate stdio process answers +real resource requests with JSON-RPC only on stdout. Test tools raise if invoked, +establishing that catalog operations never execute them. diff --git a/nitrostack/__init__.py b/nitrostack/__init__.py index 4274c09..faefa01 100644 --- a/nitrostack/__init__.py +++ b/nitrostack/__init__.py @@ -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, @@ -228,6 +236,11 @@ "TaskAlreadyTerminalError", "InvalidTaskTransitionError", "TaskExpiredError", + "WidgetExample", + "WidgetManifest", + "WidgetMetadata", + "WidgetRegistry", + "get_widget_registry", "widget_resource_uri", "Component", "WidgetCsp", diff --git a/nitrostack/core/app.py b/nitrostack/core/app.py index 255d504..7a802ec 100644 --- a/nitrostack/core/app.py +++ b/nitrostack/core/app.py @@ -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 @@ -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() @@ -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) diff --git a/nitrostack/widgets/__init__.py b/nitrostack/widgets/__init__.py index 7da32c3..d013c2e 100644 --- a/nitrostack/widgets/__init__.py +++ b/nitrostack/widgets/__init__.py @@ -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, @@ -28,6 +35,11 @@ ) __all__ = [ + "WidgetExample", + "WidgetManifest", + "WidgetMetadata", + "WidgetRegistry", + "get_widget_registry", "Component", "WidgetCsp", "WidgetOptions", diff --git a/nitrostack/widgets/registry.py b/nitrostack/widgets/registry.py new file mode 100644 index 0000000..a1d06e8 --- /dev/null +++ b/nitrostack/widgets/registry.py @@ -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 diff --git a/tests/support/widget_registry_probe.py b/tests/support/widget_registry_probe.py new file mode 100644 index 0000000..ab39527 --- /dev/null +++ b/tests/support/widget_registry_probe.py @@ -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="

Example

")) + 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]))) diff --git a/tests/test_widget_registry.py b/tests/test_widget_registry.py new file mode 100644 index 0000000..ae3d010 --- /dev/null +++ b/tests/test_widget_registry.py @@ -0,0 +1,315 @@ +"""Widget manifests from real registrations, templates and MCP transports.""" + +import asyncio +from concurrent.futures import ThreadPoolExecutor +import json +import os +from pathlib import Path +import subprocess +import sys + +import mcp.types as types +from pydantic import BaseModel +import pytest +from starlette.testclient import TestClient + +from nitrostack import ( + DIContainer, ExecutionContext, ToolExamples, WidgetCsp, WidgetExample, + WidgetManifest, WidgetMetadata, WidgetOptions, WidgetRegistry, + get_widget_registry, module, resource, tool, widget, +) +from nitrostack.core.app import McpApplication, ServerConfig, mcp_app +from nitrostack.core.errors import ResourceNotFoundError +from nitrostack.widgets.mcp_meta import resource_read_contents_meta + +ROOT = Path(__file__).resolve().parents[1] +PROBE = ROOT / "tests" / "support" / "widget_registry_probe.py" +URI = "widget://examples" +META = { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": {"name": "registry-test", "version": "1"}, + "io.modelcontextprotocol/clientCapabilities": {}, +} + + +class Empty(BaseModel): + pass + + +@pytest.fixture(autouse=True) +def clean_state(monkeypatch): + DIContainer.reset() + for name in ("NITRO_MCP_PROTOCOL_VERSION", "MCP_STATELESS"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("NITROSTACK_APP_MODE", "universal") + yield + DIContainer.reset() + + +def make_app(specs=(), *, era="auto", collision=False): + methods = {} + for name, spec, example in specs: + async def handler(self, input: Empty, context: ExecutionContext): + raise AssertionError("Registry construction must not execute tools") + methods[name] = tool(name, f"Description of {name}", Empty, examples=example)( + widget(spec)(handler) + ) + if collision: + @resource(URI, "Custom", "Custom resource") + async def custom(self, context: ExecutionContext): + return "custom" + methods["custom"] = custom + provider = type("RegistryTools", (), methods) + + @module(name="registry-tests", providers=[provider]) + class AppModule: + pass + + @mcp_app(module=AppModule, server=ServerConfig(name="registry-test", protocol_era=era)) + class App: + pass + + return McpApplication(App) + + +def test_public_exports_and_empty_app(): + import nitrostack.widgets as widgets + for symbol in (WidgetExample, WidgetManifest, WidgetMetadata, WidgetRegistry): + assert getattr(widgets, symbol.__name__) is symbol + app = make_app() + assert get_widget_registry(app) is app.widget_registry + assert widgets.get_widget_registry(app) is app.widget_registry + assert app.widget_registry.get_manifest().to_dict() == { + "widgets": [], "count": 0, "loaded": True, + } + assert URI not in app._resources + with pytest.raises(ResourceNotFoundError): + asyncio.run(app._read_resource(URI)) + + +@pytest.mark.parametrize("spec", [ + "example", "/widget/example.html", WidgetOptions(route="example", html="

test

"), + {"route": "example", "html": "

test

"}, +]) +def test_decorator_forms_and_resource_consistency(spec): + app = make_app([("example_tool", spec, None)]) + entry = app.widget_registry.get_all_widgets()[0] + assert entry.route == "example" + assert entry.uri == "ui://widget/example.html" + assert entry.mime_type == "text/html;profile=mcp-app" + assert entry.examples == [] + assert entry.tool_names == ["example_tool"] + component = app._tools["example_tool"].component + contents = asyncio.run(app._read_resource(entry.uri))[0] + assert contents.content == component.get_bundle() + assert contents.mime_type == entry.mime_type + assert contents.meta == resource_read_contents_meta(component) + + +@pytest.mark.parametrize("mode,mime", [ + ("openai", "text/html"), ("mcp-app", "text/html;profile=mcp-app"), + ("universal", "text/html;profile=mcp-app"), +]) +def test_helper_metadata_and_app_modes(monkeypatch, mode, mime): + monkeypatch.setenv("NITROSTACK_APP_MODE", mode) + + def options(): + return WidgetOptions( + route="helper", html="

not in manifest

", prefers_border=True, + domain="https://widgets.example.com", can_invoke_tools=True, + csp=WidgetCsp(connect_domains=["https://api.example.com"]), + ) + + app = make_app([("helper_tool", options(), None)]) + entry = app.widget_registry.get_all_widgets()[0] + assert entry.mime_type == mime + assert entry.metadata == resource_read_contents_meta(app._tools["helper_tool"].component) + assert bool(entry.metadata.get("ui")) == (mode != "openai") + assert ("openai/widgetCSP" in entry.metadata) == (mode != "mcp-app") + assert "not in manifest" not in json.dumps(app.widget_registry.get_manifest().to_dict()) + + +def test_duplicates_order_examples_and_copy_isolation(): + original = ToolExamples(input={"nested": [False, None, 0]}, output=[], description="A") + app = make_app([ + ("z_tool", WidgetOptions(route="shared", description="Z", prefers_border=False), ToolExamples({}, False)), + ("a_tool", WidgetOptions(route="shared", description="A", prefers_border=True), original), + ("b_tool", "another", None), + ]) + registry = app.widget_registry + expected = registry.get_manifest().to_dict() + assert [w["route"] for w in expected["widgets"]] == ["another", "shared"] + shared = registry.get_widget("ui://widget/shared.html") + assert shared.name == "a_tool" and shared.description == "A" + assert shared.tool_names == ["a_tool", "z_tool"] + assert shared.metadata["ui"]["prefersBorder"] is True + assert [e.tool_name for e in shared.examples] == ["a_tool", "z_tool"] + assert shared.examples[0].input == {"nested": [False, None, 0]} + assert shared.examples[0].output == [] + assert shared.examples[1].output is False + # Neither source declarations nor nested values returned through any API + # may modify the stored snapshot. + original.input["nested"].append("changed") + shared.examples[0].input.clear() + shared.tool_names.clear() + registry.get_all_widgets()[1].metadata.clear() + registry.get_manifest().widgets.clear() + expected["widgets"][0]["metadata"]["changed"] = True + assert "changed" not in json.dumps(registry.get_manifest().to_dict()) + assert registry.get_widget("missing") is None + + +def test_applications_do_not_share_state(): + first = make_app([("one", "one", None)]) + second = make_app([("two", "two", None)]) + assert [w.route for w in first.widget_registry.get_all_widgets()] == ["one"] + assert [w.route for w in second.widget_registry.get_all_widgets()] == ["two"] + + +def test_manifest_order_does_not_depend_on_registration_order(): + specs = [ + ("z_tool", WidgetOptions(route="shared", description="Z"), ToolExamples(0, None)), + ("a_tool", WidgetOptions(route="shared", description="A"), ToolExamples(False, {})), + ("other", "another", None), + ] + first = make_app(specs).widget_registry.get_manifest().to_dict() + second = make_app(list(reversed(specs))).widget_registry.get_manifest().to_dict() + assert first == second + + +def test_manifest_does_not_include_environment_credentials(monkeypatch): + monkeypatch.setenv("MAPBOX_TOKEN", "widget-registry-secret-sentinel") + app = make_app([("one", WidgetOptions(route="one", html="

html-sentinel

"), None)]) + serialized = json.dumps(app.widget_registry.get_manifest().to_dict()) + assert "widget-registry-secret-sentinel" not in serialized + assert "html-sentinel" not in serialized + + +def test_reserved_uri_collision_only_when_widgets_exist(): + with pytest.raises(ValueError, match="reserved for the widget manifest"): + make_app([("one", "one", None)], collision=True) + app = make_app(collision=True) + assert asyncio.run(app._read_resource(URI))[0].content == "custom" + + +def test_additional_server_has_manifest_handlers(): + app = make_app([("one", "one", None)]) + server = app.create_configured_mcp_server() + listed = asyncio.run(server.request_handlers[types.ListResourcesRequest]( + types.ListResourcesRequest(method="resources/list") + )).root + assert URI in {str(entry.uri) for entry in listed.resources} + read = asyncio.run(server.request_handlers[types.ReadResourceRequest]( + types.ReadResourceRequest(method="resources/read", params=types.ReadResourceRequestParams(uri=URI)) + )).root + assert json.loads(read.contents[0].text) == app.widget_registry.get_manifest().to_dict() + + +@pytest.mark.parametrize("era", ["legacy", "modern"]) +@pytest.mark.parametrize("has_widgets", [False, True]) +def test_http_manifest(era, has_widgets): + app = make_app([("one", "one", None)] if has_widgets else [], era=era) + headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"} + with TestClient(app.get_combined_app(json_response=True)) as client: + def rpc(method, params, request_id): + request_headers = {**headers, "Mcp-Method": method} + if "uri" in params: + request_headers["Mcp-Name"] = params["uri"] + response = client.post("/mcp", headers=request_headers, json={ + "jsonrpc": "2.0", "id": request_id, "method": method, "params": params, + }) + assert response.status_code == 200, response.text + return response.json() + + if era == "legacy": + init = client.post("/mcp", headers=headers, json={ + "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { + "protocolVersion": "2025-06-18", "capabilities": {}, + "clientInfo": {"name": "test", "version": "1"}, + }, + }) + assert init.status_code == 200, init.text + headers["mcp-session-id"] = init.headers["mcp-session-id"] + response = client.post("/mcp", headers=headers, json={ + "jsonrpc": "2.0", "method": "notifications/initialized", + }) + assert response.status_code == 202 + meta = {} + else: + headers["MCP-Protocol-Version"] = "2026-07-28" + meta = {"_meta": META} + listed = rpc("resources/list", meta, 2)["result"]["resources"] + assert (URI in {entry["uri"] for entry in listed}) is has_widgets + result = rpc("resources/read", {"uri": URI, **meta}, 3) + if has_widgets: + contents = result["result"]["contents"][0] + assert contents["mimeType"] == "application/json" + assert json.loads(contents["text"]) == app.widget_registry.get_manifest().to_dict() + else: + assert "error" in result + + +@pytest.mark.parametrize("template,routes", [ + ("starter", ["calculator-result"]), + ("pizzaz", ["pizza-list", "pizza-map", "pizza-shop"]), + ("flight-booking", ["airport-search", "flight-details", "flight-search-results", + "order-cancellation", "order-summary", "seat-selection"]), +]) +def test_real_template_bootstrap(template, routes): + env = {**os.environ, "PYTHONPATH": str(ROOT), "NITROSTACK_APP_MODE": "universal"} + result = subprocess.run( + [sys.executable, str(PROBE), template], cwd=ROOT, env=env, + capture_output=True, text=True, timeout=25, + ) + assert result.returncode == 0, result.stderr + manifest = json.loads(result.stdout) + assert [w["route"] for w in manifest["widgets"]] == routes + assert manifest["count"] == len(routes) and manifest["loaded"] is True + for entry in manifest["widgets"]: + assert entry["uri"] == f"ui://widget/{entry['route']}.html" + assert entry["mime_type"] == "text/html;profile=mcp-app" + if template == "flight-booking": + summary = next(w for w in manifest["widgets"] if w["route"] == "order-summary") + assert len(summary["tool_names"]) == 2 + if template == "pizzaz": + for entry in manifest["widgets"]: + assert entry["metadata"]["ui"]["prefersBorder"] is True + assert "https://api.mapbox.com" in entry["metadata"]["ui"]["csp"]["connectDomains"] + assert entry["examples"] + + +def test_real_stdio_manifest_has_only_protocol_output(): + env = {**os.environ, "PYTHONPATH": str(ROOT), "NITRO_MCP_PROTOCOL_VERSION": "auto"} + process = subprocess.Popen( + [sys.executable, str(PROBE), "stdio"], cwd=ROOT, env=env, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, encoding="utf-8", + ) + with ThreadPoolExecutor(max_workers=1) as reader: + try: + def rpc(method, params, request_id): + process.stdin.write(json.dumps({ + "jsonrpc": "2.0", "id": request_id, "method": method, + "params": {**params, "_meta": META}, + }) + "\n") + process.stdin.flush() + line = reader.submit(process.stdout.readline).result(timeout=10) + payload = json.loads(line) + assert payload["id"] == request_id + return payload["result"] + + listed = rpc("resources/list", {}, 1) + assert URI in {entry["uri"] for entry in listed["resources"]} + contents = rpc("resources/read", {"uri": URI}, 2)["contents"][0] + assert contents["mimeType"] == "application/json" + manifest = json.loads(contents["text"]) + assert manifest["count"] == 1 + assert manifest["widgets"][0]["route"] == "stdio-example" + process.stdin.close() + process.wait(timeout=10) + assert process.returncode == 0, process.stderr.read() + assert process.stdout.read() == "" + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=5) From 9333345d085635d843c86b1d07e1d0983ceec801 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Fri, 30 Oct 2026 17:22:42 +0530 Subject: [PATCH 2/2] docs: consolidate widget registry usage in README --- README.md | 27 +++++- docs/issue73-verification.md | 86 ------------------- docs/widget-registry.md | 157 ----------------------------------- 3 files changed, 24 insertions(+), 246 deletions(-) delete mode 100644 docs/issue73-verification.md delete mode 100644 docs/widget-registry.md diff --git a/README.md b/README.md index c38bef1..b5f710d 100644 --- a/README.md +++ b/README.md @@ -330,9 +330,30 @@ This detects all standard and Windows Store installation directories, sets up vi ### Widgets (UI tools) -Registered widgets are also available as a JSON manifest at `widget://examples`. -See [Widget registry and examples](docs/widget-registry.md) for resource reads, -Python registry access, declared examples and duplicate-route behavior. +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: diff --git a/docs/issue73-verification.md b/docs/issue73-verification.md deleted file mode 100644 index c123f91..0000000 --- a/docs/issue73-verification.md +++ /dev/null @@ -1,86 +0,0 @@ -# Issue #73 implementation verification - -Validated locally on 2026-10-29. Branch: `feature/issue-73-widget-registry`, based -on upstream `develop` at `63ecd9240d98bbe06c879089dd887494c7e99293`. -No PR #94, spillover, OAuth-client or HTTP-factory branch commits are included. - -## Acceptance criteria - -- Actual starter, Pizzaz and OAuth/flight-booking modules bootstrap in isolated - subprocesses with networking blocked. Their manifests contain exactly 1, 3 - and 6 unique routes, respectively, with URI and mode-correct MIME type. -- Pizzaz helper-built options retain their CSP/border metadata and examples. -- Shared `order-summary` appears once with both tool names. Registry unit tests - additionally verify merging all explicit examples and deterministic ordering. -- Empty applications keep their resource catalog unchanged and report an empty, - loaded Python registry. Reserved-URI collisions are rejected only in widget apps. -- Modern and legacy HTTP clients can list/read the manifest. A real stdio - subprocess serves both operations with no non-protocol stdout. Additional MCP - server instances have the same resource handlers. Existing widget HTML reads - and metadata continue to match their components. - -## Results - -All runs use official MCP **2.3.0**. Linux runs use local WSL Ubuntu, not hosted -GitHub Actions. Each complete run includes all tests, without ignores, -deselections, or new skips/xfail. - -| Environment | Complete-suite result | Coverage | -|---|---|---| -| Linux Python 3.10.21 | 847 passed | 86.44% | -| Linux Python 3.11.16 | 847 passed | 86.42% | -| Linux Python 3.12.14 | 847 passed | 86.42% | -| Windows Python 3.10.21 | 847 passed | 86.44% | -| Windows Python 3.11.16 | 847 passed | 86.42% | -| Windows Python 3.12.14 | 846 passed, 1 failed; see below | 86.42% | - -- Complete collection: **847 tests**, no errors. -- New feature tests: **22 passed** on Windows 3.12, also included in all complete - matrix runs. New registry module: **100% statement coverage**. -- Initial combined widget regression selection: **72 passed**, before adding - two additional registry cases; the final complete runs include all 74 cases. -- `git diff --check` passes. No dependency or version changes were made. -- SHA-256 comparisons confirm Linux tested the same production and new test - source bytes as the Windows worktree. - -## Windows 3.12 socket limitation and baseline - -The final full Windows 3.12 run failed the existing -`test_discovery_server_idempotent_start_and_dcr_http` in `tests/test_oauth.py`. -Its localhost HTTP request raised `ConnectionAbortedError: [WinError 10053]`. -An earlier Windows 3.12 run also encountered that error in the existing -`test_discovery_dcr_404_when_disabled_or_missing_client_id`. All widget tests pass. - -For comparison, the unchanged `63ecd924...` baseline was exported into a separate -directory and tested with the same Windows Python 3.12.14/MCP 2.3.0 environment: - -- Full baseline suite: **825 passed**, 86.22% coverage. -- Baseline OAuth module alone: **34 passed**. -- The two affected baseline OAuth tests were then run together three times. - Attempts 1 and 2 each passed both tests. Attempt 3 reproduced **the same - `test_discovery_server_idempotent_start_and_dcr_http` WinError 10053 failure** - (1 passed, 1 failed). - -This establishes that the socket failure also occurs without the feature and is -intermittent on this host. It does not establish its underlying operating-system -or server-lifecycle cause. The Windows 3.12 complete suite is not reported green, -and no OAuth code or tests were changed to hide it. - -## Reproduction commands - -```powershell -python -m pytest tests --collect-only -o addopts= -q -python -m pytest tests/test_widget_registry.py -o addopts=--timeout=60 -q -python -m pytest tests --cov=nitrostack --cov-report=term-missing --timeout=60 --tb=short -o addopts= -q -``` - -Run the same complete-suite command on Linux Python 3.10, 3.11 and 3.12. To probe -the Windows failure on the unchanged baseline: - -```powershell -python -m pytest tests/test_oauth.py::test_discovery_server_idempotent_start_and_dcr_http tests/test_oauth.py::test_discovery_dcr_404_when_disabled_or_missing_client_id -o addopts=--timeout=60 -q --tb=short -``` - -Local logs are retained outside tracked sources in `D:/NitroStack/issue73-*.log` -and the worktree's ignored `.pytest_cache/linux-3.*.log`. Hosted CI has not been -run because this implementation has not been pushed or published. diff --git a/docs/widget-registry.md b/docs/widget-registry.md deleted file mode 100644 index 243676c..0000000 --- a/docs/widget-registry.md +++ /dev/null @@ -1,157 +0,0 @@ -# Widget registry and examples resource - -Applications with registered `@widget` tools expose a JSON catalog at -`widget://examples`. Clients discover it with `resources/list` and read it with -`resources/read`. Existing `ui://widget/...html` resources continue to serve HTML. - -```json -{ - "jsonrpc": "2.0", - "id": 1, - "method": "resources/read", - "params": {"uri": "widget://examples"} -} -``` - -Use normal client initialization or modern protocol metadata and HTTP headers, -as for any resource read. The response text has MIME type `application/json`: - -```json -{ - "widgets": [ - { - "route": "calculator-result", - "uri": "ui://widget/calculator-result.html", - "name": "calculate", - "description": "Calculate a result", - "mime_type": "text/html;profile=mcp-app", - "metadata": {}, - "tool_names": ["calculate"], - "examples": [ - { - "tool_name": "calculate", - "input": {"a": 2, "b": 3}, - "output": {"result": 5}, - "description": "Add two numbers" - } - ] - } - ], - "count": 1, - "loaded": true -} -``` - -Widget MIME types follow `NITROSTACK_APP_MODE`: `text/html` for OpenAI mode and -`text/html;profile=mcp-app` for MCP App/universal mode. Metadata uses the existing -mode-aware resource builder, including declared CSP, domain and border settings. -Configure the mode before creating the app; the registry is a startup snapshot. - -## Declaring examples - -Existing `ToolExamples` declarations supply both input and output. The registry -never calls tools to generate data or scans source files. - -```python -from pydantic import BaseModel -from nitrostack import ExecutionContext, ToolExamples, tool, widget - -class AddInput(BaseModel): - a: float - b: float - -class CalculatorTools: - @tool( - "calculate", "Calculate a result", AddInput, - examples=ToolExamples( - input={"a": 2, "b": 3}, - output={"result": 5}, - description="Add two numbers", - ), - ) - @widget("calculator-result") - async def calculate(self, input: AddInput, context: ExecutionContext): - return {"result": input.a + input.b} -``` - -Register the provider/controller in a module as usual. String decorators, option -mappings, `WidgetOptions` and helpers returning options are all supported. Tools -without examples have an empty examples list. Explicit `None`, `False`, zero and -empty collections are retained. Use JSON-compatible values. Examples are public -catalog data: do not declare real credentials or private user data in them. -Widget HTML and injected environment credentials are not included. - -## Python access - -```python -from nitrostack import get_widget_registry - -registry = get_widget_registry(app) # identical to app.widget_registry -widgets = registry.get_all_widgets() # list[WidgetMetadata] -widget = registry.get_widget("ui://widget/calculator-result.html") -manifest = registry.get_manifest() # WidgetManifest -data = manifest.to_dict() -``` - -`WidgetExample`, `WidgetMetadata`, `WidgetManifest`, `WidgetRegistry` and -`get_widget_registry` are public in both `nitrostack` and `nitrostack.widgets`. -Missing URI lookups return `None`. Records are frozen dataclasses; nested lists -and dictionaries returned by reads are detached copies. Changing them cannot -alter later reads. Each application owns an independent registry. - -## Shared routes and empty applications - -Each normalized route appears once, sorted lexicographically. Tool names and -examples are sorted by tool name, retaining every declared example. The first -tool name supplies the display name, description and component metadata. This -selection does not change the existing HTML resource's rendering behavior. - -`count` is the unique widget count. `loaded` means startup discovery completed, -including for an empty registry. Repeated reads are stable; no timestamps are -generated. Applications without widgets have an empty Python registry and no -additional MCP resource. A user-defined `widget://examples` is left alone in -such apps. Widget applications reserve that URI and reject collisions at startup. - -The manifest describes registered application widgets. It introduces no session -filtering or authorization policy and grants no tool execution permissions. The -application's existing resource access controls still apply. - -## Implementation explanation - -The registry module separates data descriptions from HTML rendering: - -- The `TYPE_CHECKING` import supplies the application annotation without a - runtime circular import. `WIDGET_EXAMPLES_URI` names the built-in resource. -- `WidgetExample` retains tool name, input, output and optional description. - It never tests example payloads for truthiness. -- `WidgetMetadata` holds one route's URI, MIME type, metadata and tool examples. - Default factories give each record independent mutable fields. -- `WidgetManifest` contains widgets, count and completion status. `to_dict()` - uses `asdict()` to recursively copy the dataclass records into dictionaries. -- `WidgetRegistry.__init__()` sorts records and deep-copies them into a private - URI-keyed mapping. Later changes to source declarations cannot affect it. -- `_from_tools()` visits final registered tool entries, including only entries - with components. Python has already evaluated all decorator helpers. Grouping - by component ID reuses existing route normalization. Sorting groups and tool - names makes output independent of registration order. -- The example comprehension copies only explicit tool examples. Each group's - first component supplies name, description and URI. Existing helpers supply - mode-correct MIME types and resource metadata. No HTML enters the catalog. -- `get_all_widgets()` and `get_widget()` deep-copy the private records. - `get_manifest()` builds on those detached reads and computes their count. - Registry construction is complete at that point, so `loaded` is true. -- `get_widget_registry(app)` returns the application attribute rather than a - global singleton, preventing cross-application catalog leakage. - -Application bootstrap constructs the registry after registering decorated -methods and before installing MCP handlers. A nonempty registry adds one ordinary -resource entry. Its handler returns the manifest dictionary; the existing -resource pipeline serializes it as JSON. Additional configured MCP servers use -the same resource registrations. Existing widget HTML handlers are unchanged. -Package exports expose the types and accessor through both public entry points. - -Integration tests bootstrap actual templates in subprocesses, blocking socket -networking so startup cannot depend on external providers. HTTP tests exercise -modern/sessionless and legacy/sessionful modes. A separate stdio process answers -real resource requests with JSON-RPC only on stdout. Test tools raise if invoked, -establishing that catalog operations never execute them.