diff --git a/.gitignore b/.gitignore
index f4d51c8..2cab5e6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -58,6 +58,12 @@ adv-proj/
new-mcp-server/
mcp-app/
abhijit/
+pizza-app/
+pizza-mcp-serevr/
+flight-book-app/
+my-pizza-app/
+my-test-server/
+new-app/
# Node widgets (if installed locally in examples/templates)
node_modules/
diff --git a/README.md b/README.md
index c8e1649..c57ce55 100644
--- a/README.md
+++ b/README.md
@@ -273,6 +273,71 @@ nitrostack-py register --name my-mcp-server --file app.py
This detects all standard and Windows Store installation directories, sets up virtualenv executables, and writes the JSON configuration. Once registered, simply restart Claude Desktop.
+### Widgets (UI tools)
+
+Bind a static HTML template to a tool with `@widget` and return domain JSON from the handler:
+
+```python
+from nitrostack import tool, widget, ExecutionContext
+
+@tool(name="show_card", description="Product card", input_schema=CardInput)
+@widget("card")
+async def show_card(self, input: CardInput, context: ExecutionContext) -> dict:
+ return {"name": "Widget", "price": 9.99}
+```
+
+Place HTML at `widgets/out/{route}.html` (e.g. `widgets/out/card.html`). The SDK registers
+`ui://widget/card.html` as an MCP resource and sets mode-gated `_meta` on `tools/list` and
+`tools/call` results.
+
+**`NITROSTACK_APP_MODE`** (default `universal`):
+
+| Mode | Tool `_meta` | Resource MIME |
+|------|----------------|---------------|
+| `universal` (default) | Both OpenAI and MCP Apps keys | `text/html;profile=mcp-app` |
+| `openai` | `openai/outputTemplate`, `ui/template` | `text/html` |
+| `mcp-app` | `_meta.ui` (`resourceUri`, `visibility`, CSP) | `text/html;profile=mcp-app` |
+
+Object form for CSP and border options:
+
+```python
+from nitrostack import WidgetOptions, WidgetCsp, widget
+
+@widget(WidgetOptions(
+ route="chart",
+ prefers_border=True,
+ csp=WidgetCsp(connect_domains=["https://api.example.com"]),
+))
+```
+
+`nitrostack-py init` copies `widgets/out/{route}.html` for every `@widget`. Widget HTML
+is generated in Python from the tool's `structuredContent` (one iframe, N cards).
+
+MCP Inspector: use **HTTP + stateless**, then the **Apps** tab. `tools/call` also embeds
+the data-filled HTML. Do not use `widgets/preview.html` as the live result — that file
+is a static helper. Live preview: `http://localhost:3000/widgets/preview`.
+
+Turn **Authentication off** in Inspector. Pizzaz/starter have no OAuth. If Auth is on,
+Inspector POSTs `/register` and you will see `Cannot POST /register` / `Unexpected token '<'`.
+Connect Streamable HTTP to `http://localhost:3000/mcp` (no trailing slash).
+
+For **open pizza shops only**, call `show_pizza_list` with `{"openNow": true}` or
+`show_pizza_map` with `{"filter": "open_now"}`. Omitting those fields returns every shop,
+including closed ones (Pizzeria Delfina).
+
+**NitroStudio:** folder-connect looks for a TypeScript project (`package.json` with
+`@nitrostack/core` and `src/index.ts`). A Python server will not detect. Keep using
+MCP Inspector over HTTP, or point Studio at a custom Streamable HTTP URL if the build
+supports it. Do not enable OAuth against this server.
+
+Example server: `examples/widgets_example.py` with templates in `examples/widgets/out/`.
+For MCP Inspector over HTTP, use stateless mode:
+
+```bash
+cd examples
+MCP_TRANSPORT_TYPE=http MCP_STATELESS=true NITROSTACK_APP_MODE=universal python widgets_example.py
+```
+
### Running Tests
To run the automated test suite, execute:
```bash
@@ -280,6 +345,10 @@ python tests/test_basic.py
python tests/test_tasks.py
python tests/test_initial_tool.py
python tests/test_transports.py
+python tests/test_widgets.py
+python tests/test_widget_metadata.py
+python tests/test_pizzaz_widgets.py
+python tests/test_template_widgets.py
python tests/test_cli.py
pytest tests/test_cli.py -v
python tests/test_tool_input_schema.py
diff --git a/examples/widgets/out/card.html b/examples/widgets/out/card.html
new file mode 100644
index 0000000..590a43e
--- /dev/null
+++ b/examples/widgets/out/card.html
@@ -0,0 +1,397 @@
+
+
+
+
+
+
diff --git a/examples/widgets_example.py b/examples/widgets_example.py
new file mode 100644
index 0000000..2bece3e
--- /dev/null
+++ b/examples/widgets_example.py
@@ -0,0 +1,103 @@
+"""
+Widgets example — card, table, and chart tools with static HTML templates.
+
+Run (stdio):
+ cd examples && python widgets_example.py
+
+Run (HTTP, stateless — good for MCP Inspector):
+ MCP_TRANSPORT_TYPE=http MCP_STATELESS=true NITROSTACK_APP_MODE=universal \\
+ python widgets_example.py
+"""
+import asyncio
+import os
+import sys
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from pydantic import BaseModel, Field
+
+from nitrostack import (
+ ExecutionContext,
+ McpApplicationFactory,
+ ServerConfig,
+ WidgetCsp,
+ WidgetOptions,
+ injectable,
+ mcp_app,
+ module,
+ tool,
+ widget,
+)
+
+
+class CardInput(BaseModel):
+ product_id: str = Field(description="Product identifier")
+
+
+class TableInput(BaseModel):
+ limit: int = Field(default=3, description="Number of rows to return")
+
+
+class ChartInput(BaseModel):
+ title: str = Field(default="Sales", description="Chart title")
+
+
+@injectable()
+class WidgetsController:
+ @tool(name="show_card", description="Show a product card widget", input_schema=CardInput)
+ @widget("card")
+ async def show_card(self, input: CardInput, context: ExecutionContext) -> dict:
+ return {
+ "id": input.product_id,
+ "name": f"Product {input.product_id}",
+ "price": 29.99,
+ "description": "A sample product rendered in the card widget.",
+ }
+
+ @tool(name="show_table", description="Show a data table widget", input_schema=TableInput)
+ @widget("table")
+ async def show_table(self, input: TableInput, context: ExecutionContext) -> dict:
+ rows = [
+ {"name": "Alice", "score": 95},
+ {"name": "Bob", "score": 87},
+ {"name": "Carol", "score": 91},
+ ][: max(1, input.limit)]
+ return {"columns": ["name", "score"], "rows": rows}
+
+ @tool(name="show_chart", description="Show a bar chart widget", input_schema=ChartInput)
+ @widget(
+ WidgetOptions(
+ route="chart",
+ prefers_border=True,
+ domain="https://example.com",
+ csp=WidgetCsp(connect_domains=["https://api.example.com"]),
+ )
+ )
+ async def show_chart(self, input: ChartInput, context: ExecutionContext) -> dict:
+ return {
+ "title": input.title,
+ "items": [
+ {"label": "Jan", "value": 40},
+ {"label": "Feb", "value": 65},
+ {"label": "Mar", "value": 52},
+ ],
+ }
+
+
+@module(name="widgets_example", controllers=[WidgetsController])
+class WidgetsExampleModule:
+ pass
+
+
+@mcp_app(module=WidgetsExampleModule, server=ServerConfig(name="widgets-example", version="1.0.0"))
+class WidgetsExampleApp:
+ pass
+
+
+async def main() -> None:
+ app = await McpApplicationFactory.create(WidgetsExampleApp)
+ await app.start()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/nitrostack/__init__.py b/nitrostack/__init__.py
index 12bd5f7..25b4a8a 100644
--- a/nitrostack/__init__.py
+++ b/nitrostack/__init__.py
@@ -12,6 +12,7 @@
PromptMessage,
ToolInvocation,
ToolExamples,
+ widget_resource_uri,
)
from nitrostack.core.context import (
ExecutionContext,
@@ -81,6 +82,22 @@
ConfigModule,
ConfigService,
)
+from nitrostack.widgets import (
+ Component,
+ WidgetCsp,
+ WidgetOptions,
+ create_component,
+ get_app_mode,
+ get_widget_mime_type,
+ is_mcp_app_mode,
+ is_openai_mode,
+ OPENAI_SKYBRIDGE_MIME_TYPE,
+ RESOURCE_MIME_TYPE_MCP_APP,
+ RESOURCE_MIME_TYPE_OPENAI,
+)
+from nitrostack.testing import (
+ NitroTestingModule,
+)
from nitrostack.testing import (
NitroTestingModule,
)
@@ -141,4 +158,17 @@
"TaskAlreadyTerminalError",
"InvalidTaskTransitionError",
"TaskExpiredError",
+ "widget_resource_uri",
+ "Component",
+ "WidgetCsp",
+ "WidgetOptions",
+ "create_component",
+ "get_app_mode",
+ "get_widget_mime_type",
+ "is_mcp_app_mode",
+ "is_openai_mode",
+ "OPENAI_SKYBRIDGE_MIME_TYPE",
+ "RESOURCE_MIME_TYPE_MCP_APP",
+ "RESOURCE_MIME_TYPE_OPENAI",
+ "NitroTestingModule",
]
diff --git a/nitrostack/auth/oauth.py b/nitrostack/auth/oauth.py
index 6b8cbed..4b5f42a 100644
--- a/nitrostack/auth/oauth.py
+++ b/nitrostack/auth/oauth.py
@@ -9,6 +9,32 @@
from nitrostack.core.module import module
from nitrostack.core.di import DIContainer
+
+def is_oauth_required() -> bool:
+ """TS ``OAuthModule.isAuthRequired()`` — Studio/local default is off.
+
+ Set ``OAUTH_REQUIRED=true`` to enforce Bearer tokens. Unset/false lets
+ NitroStudio and Inspector call tools against mock Duffel data.
+ """
+ return (os.environ.get("OAUTH_REQUIRED") or "").strip().lower() == "true"
+
+
+_oauth_fail_open_warned = False
+
+
+def warn_if_oauth_fail_open() -> None:
+ """Loud warning when OAuth is wired but tokens are not enforced."""
+ global _oauth_fail_open_warned
+ if _oauth_fail_open_warned or is_oauth_required():
+ return
+ _oauth_fail_open_warned = True
+ sys.stderr.write(
+ "WARNING: OAuth is configured but OAUTH_REQUIRED is not true. "
+ "Tools protected by OAuthGuard will accept unauthenticated requests. "
+ "Set OAUTH_REQUIRED=true to enforce Bearer tokens.\n"
+ )
+ sys.stderr.flush()
+
class OAuthService:
def __init__(
self,
@@ -205,4 +231,9 @@ def for_root(
issuer=issuer
)
DIContainer.get_instance().register_value(OAuthService, service)
+ warn_if_oauth_fail_open()
return cls
+
+ @staticmethod
+ def is_auth_required() -> bool:
+ return is_oauth_required()
diff --git a/nitrostack/cli/generate.py b/nitrostack/cli/generate.py
index 6ba29fc..e5ddce0 100644
--- a/nitrostack/cli/generate.py
+++ b/nitrostack/cli/generate.py
@@ -67,7 +67,12 @@ def _load_template(filename: str) -> str:
def _validate_generate_name(name: str) -> None:
- if not name or not _NAME_RE.match(name):
+ if (
+ not name
+ or os.path.basename(name) != name
+ or ".." in name
+ or not _NAME_RE.match(name)
+ ):
print("Error: name must be a valid identifier (letters, numbers, '_' or '-').")
sys.exit(1)
diff --git a/nitrostack/cli/main.py b/nitrostack/cli/main.py
index 69be482..1c6f9db 100644
--- a/nitrostack/cli/main.py
+++ b/nitrostack/cli/main.py
@@ -1,9 +1,11 @@
import os
+import re
import sys
import argparse
import shutil
import subprocess
import time
+from pathlib import Path
from nitrostack.cli.generate import generate_component, generate_module as generate_module_from_template
from nitrostack.cli.install import install_dependencies
@@ -819,7 +821,7 @@ async def booking_guide(self, context: ExecutionContext) -> str:
REQUIREMENTS_TEMPLATE = """nitrostack
"""
-TOOL_TEMPLATE = """from nitrostack import tool, ExecutionContext
+TOOL_TEMPLATE = """from nitrostack import tool, widget, ExecutionContext
from pydantic import BaseModel
class {camel_name}Input(BaseModel):
@@ -831,11 +833,194 @@ class {camel_name}Input(BaseModel):
description="Implement your tool description here",
input_schema={camel_name}Input
)
+@widget("{name}")
async def {name}_handler(input: {camel_name}Input, context: ExecutionContext):
context.logger.info("Executing tool {name}")
return {{"status": "success"}}
"""
+WIDGET_PREVIEW_HTML = """
+
+
+
+ Widget preview
+
+
+
+
+ This static file does not follow MCP Inspector tool calls.
+ Inspector JSON is the live result (e.g. all matching pizza shops). Open
+ http://localhost:3000/widgets/preview with the server running to
+ render that same output, or paste structuredContent below and Inject.
+
+
+
+
+
+
+
+
+"""
+
+SAMPLE_PIZZA_PREVIEW_JSON = """{
+ "shops": [
+ {
+ "id": "tonys-pizza",
+ "name": "Tony's New York Pizza",
+ "address": "123 Main St, San Francisco, CA 94102",
+ "rating": 4.5,
+ "priceLevel": 2,
+ "openNow": true,
+ "image": "https://images.unsplash.com/photo-1513104890138-7c749659a591"
+ }
+ ],
+ "totalShops": 1
+}"""
+
+
+_GENERATE_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
+_WIDGET_ROUTE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
+
+
+def _validate_generate_name(name: str) -> str:
+ """Reject path segments so ``generate tool ../../ESCAPED`` cannot write outside cwd."""
+ name = (name or "").strip()
+ if not name or os.path.basename(name) != name or ".." in name or not _GENERATE_NAME_RE.fullmatch(name):
+ raise ValueError(
+ "name must be a Python identifier (letters, digits, underscore) "
+ "and cannot contain path separators"
+ )
+ return name
+
+
+def _sanitize_widget_route(route: str) -> str:
+ route = (route or "").strip()
+ if not route or os.path.basename(route) != route or ".." in route or not _WIDGET_ROUTE_RE.fullmatch(route):
+ raise ValueError("widget route must be a single alphanumeric path segment")
+ return route
+
+
+def _assert_dest_inside_root(dest: str, root: str) -> str:
+ dest_abs = os.path.realpath(dest)
+ root_abs = os.path.realpath(root)
+ try:
+ common = os.path.commonpath([dest_abs, root_abs])
+ except ValueError as exc:
+ raise ValueError("generated path would escape the project directory") from exc
+ if common != root_abs:
+ raise ValueError("generated path would escape the project directory")
+ return dest_abs
+
+
+def write_widget_html(project_dir: str, route: str, *, overwrite: bool = False) -> str:
+ """Write ``widgets/out/{route}.html`` if missing (Python-only static widget)."""
+ from nitrostack.widgets.route_templates import build_widget_html_for_route
+
+ route = _sanitize_widget_route(route)
+ project_dir = os.path.abspath(project_dir or ".")
+ out_dir = os.path.join(project_dir, "widgets", "out")
+ os.makedirs(out_dir, exist_ok=True)
+ dest = os.path.join(out_dir, f"{route}.html")
+ _assert_dest_inside_root(dest, project_dir)
+ if overwrite or not os.path.exists(dest):
+ with open(dest, "w", encoding="utf-8") as f:
+ f.write(build_widget_html_for_route(route))
+ return dest
+
+
+def write_widget_preview(project_dir: str) -> None:
+ from html import escape as html_escape
+
+ from nitrostack.widgets.html_util import json_for_inline_script
+
+ out_dir = os.path.join(project_dir, "widgets", "out")
+ if not os.path.isdir(out_dir):
+ return
+ routes = sorted(p[:-5] for p in os.listdir(out_dir) if p.endswith(".html"))
+ if not routes:
+ return
+ first = "pizza-list" if "pizza-list" in routes else routes[0]
+ default_payload: dict = {"status": "success"}
+ if "pizza-list" in routes or "pizza-map" in routes:
+ import json as json_lib
+
+ default_payload = json_lib.loads(SAMPLE_PIZZA_PREVIEW_JSON)
+ html = (
+ WIDGET_PREVIEW_HTML.replace("__FIRST__", html_escape(first, quote=True))
+ .replace("__ROUTES__", json_for_inline_script(routes))
+ .replace("__DEFAULT_JSON__", html_escape(json_for_inline_script(default_payload), quote=False))
+ )
+ dest = os.path.join(project_dir, "widgets", "preview.html")
+ with open(dest, "w", encoding="utf-8") as f:
+ f.write(html)
+
+
+def ensure_python_widgets(project_dir: str) -> list:
+ """Create missing ``widgets/out/{route}.html`` for every ``@widget`` in the project."""
+ routes = []
+ for root, _dirs, files in os.walk(project_dir):
+ parts = set(root.split(os.sep))
+ if "node_modules" in parts or ".venv" in parts:
+ continue
+ for name in files:
+ if not name.endswith(".py"):
+ continue
+ path = os.path.join(root, name)
+ try:
+ text = Path(path).read_text(encoding="utf-8")
+ except OSError:
+ continue
+ routes.extend(re.findall(r'@widget\(\s*["\']([^"\']+)["\']', text))
+ routes.extend(re.findall(r'WidgetOptions\(\s*route\s*=\s*["\']([^"\']+)["\']', text))
+ # Only routes that were actually scaffolded go into the returned list — callers
+ # print it as "created", so appending before the write would report a widget that
+ # never landed on disk. A rejected route is surfaced rather than skipped silently.
+ unique = []
+ seen = set()
+ for route in routes:
+ if route in seen:
+ continue
+ seen.add(route)
+ try:
+ write_widget_html(project_dir, route, overwrite=False)
+ except ValueError as exc:
+ print(f"Warning: skipped widget route {route!r}: {exc}")
+ continue
+ unique.append(route)
+ write_widget_preview(project_dir)
+ return unique
+
MODULE_TEMPLATE = """from nitrostack import module
@module(
@@ -1075,7 +1260,11 @@ def init_project(name: str = None, template: str = None, skip_install: bool = Fa
sys.exit(1)
shutil.copytree(template_src_dir, name)
+ widget_routes = ensure_python_widgets(name)
print("\n\033[32m✓\033[0m Project created")
+ if widget_routes:
+ print(f"\033[32m✓\033[0m Python widgets: {', '.join(widget_routes)}")
+ print(" HTML in widgets/out/ — preview: widgets/preview.html")
# 7. Update .env file
env_path = os.path.join(name, ".env")
@@ -1098,6 +1287,7 @@ def init_project(name: str = None, template: str = None, skip_install: bool = Fa
new_lines.append(f'SERVER_AUTHOR="{author}"\n')
new_lines = _upsert_env_var(new_lines, "PORT", mcp_port)
new_lines = _upsert_env_var(new_lines, "WIDGETS_DEV_PORT", widgets_port)
+ new_lines = _upsert_env_var(new_lines, "NITROSTACK_APP_MODE", "universal")
with open(env_path, "w", encoding="utf-8") as f:
f.writelines(new_lines)
@@ -1157,6 +1347,14 @@ def init_project(name: str = None, template: str = None, skip_install: bool = Fa
else:
print(" 2. Configure environment variables in \033[34m.env\033[0m")
print(" 3. Start development server: \033[34mnitrostack-py dev\033[0m (or `python -m nitrostack.cli.main dev`)")
+ print(" 4. Preview widgets: \033[34mhttp://localhost:3000/widgets/preview\033[0m (server running)")
+ print(" Static file (manual JSON): \033[34mopen widgets/preview.html\033[0m")
+ print(" 5. Inspector (HTTP): \033[34mMCP_TRANSPORT_TYPE=http MCP_STATELESS=true NITROSTACK_APP_MODE=universal python main.py\033[0m")
+ print(" Connect Streamable HTTP to \033[34mhttp://localhost:3000/mcp\033[0m with \033[34mAuthentication = Off\033[0m")
+ print(" Apps tab renders the widget from live \033[34mstructuredContent\033[0m (not widgets/preview.html)")
+ print(" For open shops only set \033[34mopenNow=true\033[0m (list) or \033[34mfilter=open_now\033[0m (map)")
+ print(" 6. NitroStudio cannot folder-connect a Python project (it looks for @nitrostack/core).")
+ print(" Use Inspector HTTP as above, or Studio's custom MCP URL if it offers Streamable HTTP.")
print("\nHappy coding! 🚀\n")
def run_dev(port=None, widget=None):
@@ -1337,7 +1535,25 @@ def run_start(port=None, widget=None):
pass
def generate_tool(name: str):
+ # Both validators run before anything is written. `_validate_generate_name` and
+ # `_sanitize_widget_route` accept overlapping-but-different character sets (e.g.
+ # `_foo` is a valid identifier but not a valid route; `my-tool` is the reverse),
+ # so validating the route lazily inside `write_widget_html` would leave an orphan
+ # `{name}_tool.py` behind whenever the two disagree — and that orphan then blocks
+ # any retry with "File already exists".
+ try:
+ name = _validate_generate_name(name)
+ _sanitize_widget_route(name)
+ except ValueError as exc:
+ print(f"Error: {exc}")
+ sys.exit(1)
filename = f"{name}_tool.py"
+ dest_py = os.path.abspath(filename)
+ try:
+ _assert_dest_inside_root(dest_py, os.getcwd())
+ except ValueError as exc:
+ print(f"Error: {exc}")
+ sys.exit(1)
if os.path.exists(filename):
print(f"Error: File '{filename}' already exists.")
sys.exit(1)
@@ -1345,7 +1561,14 @@ def generate_tool(name: str):
content = TOOL_TEMPLATE.format(name=name, camel_name=camel_name)
with open(filename, "w", encoding="utf-8") as f:
f.write(content)
+ try:
+ html_path = write_widget_html(".", name, overwrite=False)
+ except ValueError as exc:
+ print(f"Error: {exc}")
+ sys.exit(1)
+ write_widget_preview(".")
print(f"Generated tool boilerplate in '{filename}'")
+ print(f"Generated widget HTML in '{html_path}'")
def generate_module(name: str):
generate_module_from_template(name)
diff --git a/nitrostack/core/app.py b/nitrostack/core/app.py
index 326baf0..53b11d6 100644
--- a/nitrostack/core/app.py
+++ b/nitrostack/core/app.py
@@ -6,7 +6,9 @@
import asyncio
import inspect
import datetime
+import logging
from dataclasses import dataclass, field
+from pathlib import Path
from typing import Any, Callable, Dict, List, Literal, Optional, Pattern, Set, Tuple, Type
import mcp.types as types
@@ -16,7 +18,8 @@
from pydantic import BaseModel, create_model
from nitrostack.core.context import ExecutionContext, TaskContext
-from nitrostack.core.decorators import ToolConfig, ResourceConfig, PromptConfig, _apply_widget_metadata
+from nitrostack.core.decorators import ToolConfig, ResourceConfig, PromptConfig, widget_resource_uri
+from nitrostack.core.app_mode import get_app_mode, get_widget_mime_type, is_mcp_app_mode, is_openai_mode
from nitrostack.core.di import DIContainer
from nitrostack.core.errors import (
PromptNotFoundError,
@@ -30,9 +33,13 @@
from nitrostack.core.additional_decorators import HealthCheckRegistry
from nitrostack.core.task import TaskManager, TaskStatus
from nitrostack.events.event_emitter import EventEmitter
+from nitrostack.widgets.component import Component, find_project_root, load_widget_html, parse_widget_options
+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
DEFAULT_HTTP_PORT = 3000
+logger = logging.getLogger(__name__)
def resolve_http_port() -> int:
@@ -150,11 +157,47 @@ def inspector_friendly_schema(node: Any) -> Any:
return {key: inspector_friendly_schema(value) for key, value in unwrapped.items()}
+def tool_json_schema(schema_spec: Any) -> Optional[Dict[str, Any]]:
+ """Convert a Pydantic model class or JSON-schema dict to Inspector-friendly schema."""
+ if schema_spec is None:
+ return None
+ if isinstance(schema_spec, dict):
+ return inspector_friendly_schema(schema_spec)
+ model = get_pydantic_model(schema_spec)
+ if model is not None:
+ return inspector_friendly_schema(model.model_json_schema())
+ return None
+
+
+def _is_blank_inspector_value(value: Any) -> bool:
+ """Inspector leaves unused form fields as ``""`` (or whitespace)."""
+ return value is None or (isinstance(value, str) and not value.strip())
+
+
+def _omit_blank_optional_fields(payload: Dict[str, Any], input_model: Type[BaseModel]) -> Dict[str, Any]:
+ """Drop blank optional/defaulted fields so Pydantic defaults apply.
+
+ MCP Inspector sends ``filter: ""`` when the enum is left empty. Zod in the
+ TS SDK marks that field ``.optional()`` and then uses ``args.filter || 'all'``.
+ Pydantic ``default='all'`` only runs when the key is missing, not when it is
+ an empty string — so we omit blanks for non-required fields.
+ """
+ fields = getattr(input_model, "model_fields", {}) or {}
+ cleaned: Dict[str, Any] = {}
+ for key, value in payload.items():
+ field = fields.get(key)
+ if field is not None and (not field.is_required()) and _is_blank_inspector_value(value):
+ continue
+ cleaned[key] = value
+ return cleaned
+
+
def parse_tool_input(input_model: Type[BaseModel], arguments: Optional[Dict[str, Any]]) -> BaseModel:
"""Validate tool arguments from Inspector or the older nested wrap.
Inspector sends top-level fields (`{openNow: true}`).
Older Python clients wrap them (`{input: {openNow: true}}`).
+ Empty strings on optional fields are treated as omitted (Inspector dropdowns).
"""
arguments = arguments or {}
inner = arguments.get("input")
@@ -164,7 +207,9 @@ def parse_tool_input(input_model: Type[BaseModel], arguments: Optional[Dict[str,
and "input" not in input_model.model_fields
)
payload = inner if looks_wrapped else arguments
- return input_model.model_validate(payload)
+ if not isinstance(payload, dict):
+ payload = {}
+ return input_model.model_validate(_omit_blank_optional_fields(payload, input_model))
@dataclass
@@ -173,6 +218,7 @@ class _ToolEntry:
input_model: Type[BaseModel]
instance: Any
method: Callable
+ component: Optional[Component] = None
@dataclass
@@ -182,6 +228,7 @@ class _ResourceEntry:
method: Callable
param_names: List[str] = field(default_factory=list)
pattern: Optional[Pattern] = None
+ component: Optional[Component] = None
@dataclass
@@ -297,10 +344,62 @@ def _resolve_modules(self, module_class: Type, resolved_modules: Set[Type]) -> N
def _register_tool(self, instance: Any, method: Callable, tool_config: ToolConfig) -> None:
input_model = get_pydantic_model(tool_config.input_schema)
entry = _ToolEntry(config=tool_config, input_model=input_model, instance=instance, method=method)
+
+ widget_spec = getattr(method, "_mcp_widget", None)
+ if widget_spec is not None:
+ options = parse_widget_options(widget_spec)
+ resource_uri = widget_resource_uri(options.route)
+ route_id = resource_uri.removeprefix("ui://widget/").removesuffix(".html")
+ from_file = None
+ method_module = inspect.getmodule(method)
+ if method_module and getattr(method_module, "__file__", None):
+ from_file = Path(method_module.__file__).resolve()
+ project_root = find_project_root(from_file)
+ html = load_widget_html(
+ route_id,
+ html=options.html,
+ from_file=from_file,
+ project_root=project_root,
+ )
+ if not html:
+ html = build_missing_widget(route_id)
+ component = Component(
+ id=route_id,
+ name=tool_config.title or tool_config.name,
+ html=html,
+ description=options.description or tool_config.description,
+ css=options.css,
+ js=options.js,
+ csp=options.csp,
+ domain=options.domain,
+ prefers_border=options.prefers_border,
+ can_invoke_tools=options.can_invoke_tools,
+ )
+ entry.component = component
+ self._register_widget_resource(component)
+
self._tools[tool_config.name] = entry
if tool_config.is_initial:
self._initial_tools.append((instance, method, tool_config))
+ def _register_widget_resource(self, component: Component) -> None:
+ config = ResourceConfig(
+ uri=component.resource_uri,
+ name=component.name,
+ description=component.description or f"UI widget for {component.name}",
+ mime_type=get_widget_mime_type(),
+ )
+
+ async def widget_resource_handler(context: ExecutionContext) -> str:
+ return component.get_bundle()
+
+ self._resources[config.uri] = _ResourceEntry(
+ config=config,
+ instance=None,
+ method=widget_resource_handler,
+ component=component,
+ )
+
def _register_resource(self, instance: Any, method: Callable, resource_config: ResourceConfig) -> None:
param_names = re.findall(r"\{([^}]+)\}", resource_config.uri)
entry = _ResourceEntry(config=resource_config, instance=instance, method=method, param_names=param_names)
@@ -417,12 +516,18 @@ def _build_tool_definition(self, entry: _ToolEntry) -> types.Tool:
"task_support": cfg.task_support,
**(cfg.metadata or {}),
}
- widget_route = getattr(entry.method, "_mcp_widget", None)
- if widget_route:
- _apply_widget_metadata(meta, widget_route)
- if cfg.invocation:
+
+ if entry.component is not None:
+ widget_meta = build_tool_list_meta(
+ entry.component,
+ cfg.visibility,
+ cfg.invocation,
+ )
+ meta.update(widget_meta)
+ elif cfg.invocation and is_openai_mode():
meta["openai/toolInvocation/invoking"] = cfg.invocation.invoking
meta["openai/toolInvocation/invoked"] = cfg.invocation.invoked
+
if cfg.examples:
meta["examples"] = {
"input": cfg.examples.input,
@@ -430,16 +535,13 @@ def _build_tool_definition(self, entry: _ToolEntry) -> types.Tool:
"description": cfg.examples.description,
}
- app_mode = os.environ.get("NITROSTACK_APP_MODE", "mcp")
- if app_mode == "openai":
+ if is_openai_mode():
meta["openai/type"] = "function"
meta["openai/function"] = {
"name": cfg.name,
"description": cfg.description,
"parameters": input_schema,
}
- elif app_mode == "mcpapps":
- meta["_meta"] = {"ui": {"title": cfg.title or cfg.name, "description": cfg.description}}
annotations = types.ToolAnnotations(
readOnlyHint=cfg.annotations.read_only_hint,
@@ -452,15 +554,23 @@ def _build_tool_definition(self, entry: _ToolEntry) -> types.Tool:
if cfg.task_support and cfg.task_support != "forbidden":
execution = types.ToolExecution(taskSupport=cfg.task_support)
- return types.Tool(
- name=cfg.name,
- title=cfg.title,
- description=cfg.description,
- inputSchema=input_schema,
- annotations=annotations,
- execution=execution,
- **{"_meta": meta},
- )
+ tool_kwargs: Dict[str, Any] = {
+ "name": cfg.name,
+ "title": cfg.title,
+ "description": cfg.description,
+ "inputSchema": input_schema,
+ "annotations": annotations,
+ "execution": execution,
+ "_meta": meta,
+ }
+ if entry.component is not None and is_openai_mode():
+ tool_kwargs["outputTemplate"] = entry.component.resource_uri
+
+ output_schema = tool_json_schema(cfg.output_schema)
+ if output_schema is not None:
+ tool_kwargs["outputSchema"] = output_schema
+
+ return types.Tool(**tool_kwargs)
def _build_resource_definition(self, entry: _ResourceEntry) -> types.Resource:
cfg = entry.config
@@ -504,25 +614,101 @@ def _pipeline_stages(self, method: Callable) -> Tuple[List[Type], List[Type], Li
getattr(method, "_mcp_filters", []),
)
- @staticmethod
- def _to_call_tool_result(result: Any) -> types.CallToolResult:
+ def _to_call_tool_result(
+ self,
+ result: Any,
+ component: Optional[Component] = None,
+ context: Optional[ExecutionContext] = None,
+ ) -> types.CallToolResult:
if isinstance(result, types.CallToolResult):
+ if component is not None:
+ result = result.model_copy(
+ update={"meta": build_call_tool_result_meta(component, self._call_tool_result_meta(result))}
+ )
return result
if isinstance(result, BaseModel):
result = result.model_dump()
- if isinstance(result, dict):
- if "content" in result and "isError" in result:
- return types.CallToolResult(**result)
+
+ structured: Any = result
+ result_meta: Optional[Dict[str, Any]] = None
+
+ if component is not None:
+ if component.transformer is not None:
+ structured = component.transformer(result, context)
+ if component.meta_transformer is not None:
+ result_meta = component.meta_transformer(result, context) or {}
+ if isinstance(structured, BaseModel):
+ structured = structured.model_dump()
+
+ if isinstance(result, dict) and "content" in result and "isError" in result:
+ call_result = types.CallToolResult(**result)
+ if component is not None:
+ call_result = call_result.model_copy(
+ update={"meta": build_call_tool_result_meta(component, self._call_tool_result_meta(call_result))}
+ )
+ return call_result
+
+ if isinstance(structured, dict):
+ widget_meta = build_call_tool_result_meta(component, result_meta) if component else result_meta
return types.CallToolResult(
- content=[types.TextContent(type="text", text=json.dumps(result, indent=2, default=str))],
- structuredContent=result,
+ content=self._widget_result_content(structured, component),
+ structuredContent=structured,
+ **({"_meta": widget_meta} if widget_meta else {}),
isError=False,
)
+
return types.CallToolResult(
- content=[types.TextContent(type="text", text=str(result))],
+ content=[types.TextContent(type="text", text=str(structured))],
isError=False,
)
+ @staticmethod
+ def _call_tool_result_meta(result: types.CallToolResult) -> Optional[Dict[str, Any]]:
+ """Read caller ``_meta`` from the field or pydantic extra (alias vs extra)."""
+ if result.meta:
+ return dict(result.meta)
+ extra = getattr(result, "model_extra", None) or {}
+ raw = extra.get("_meta") or extra.get("meta")
+ return dict(raw) if isinstance(raw, dict) else None
+
+ def _widget_result_content(self, structured: Dict[str, Any], component: Optional[Component]) -> List[Any]:
+ """Text fallback plus data-filled HTML so Inspector/Studio can paint the live result."""
+ content: List[Any] = [
+ types.TextContent(type="text", text=json.dumps(structured, indent=2, default=str)),
+ ]
+ if component is None:
+ return content
+ mime = get_widget_mime_type()
+ try:
+ filled = component.html_with_data(structured)
+ except Exception:
+ logger.exception(
+ "Widget HTML render failed for %s; returning JSON without embedded HTML",
+ component.id,
+ )
+ filled = None
+ if filled is not None:
+ content.append(
+ types.EmbeddedResource(
+ type="resource",
+ resource=types.TextResourceContents(
+ uri=component.resource_uri,
+ mimeType=mime,
+ text=filled,
+ ),
+ )
+ )
+ content.append(
+ types.ResourceLink(
+ type="resource_link",
+ uri=component.resource_uri,
+ name=component.name,
+ description=component.description,
+ mimeType=mime,
+ )
+ )
+ return content
+
async def _call_tool(self, name: str, arguments: Dict[str, Any]):
entry = self._tools.get(name)
if entry is None:
@@ -590,7 +776,7 @@ async def background_execution():
param_type=entry.input_model,
)
self.task_manager.complete_task(
- task_id, self._to_call_tool_result(result)
+ task_id, self._to_call_tool_result(result, entry.component, task_ctx)
)
except Exception as e:
try:
@@ -617,7 +803,7 @@ async def background_execution():
param_name="input",
param_type=entry.input_model,
)
- return self._to_call_tool_result(result)
+ return self._to_call_tool_result(result, entry.component, ctx)
async def _read_resource(self, uri: str) -> List[ReadResourceContents]:
entry = self._resources.get(uri)
@@ -634,6 +820,15 @@ async def _read_resource(self, uri: str) -> List[ReadResourceContents]:
if entry is None:
raise ResourceNotFoundError(uri)
+ if entry.component is not None:
+ return [
+ ReadResourceContents(
+ content=entry.component.get_bundle(),
+ mime_type=get_widget_mime_type(),
+ meta=resource_read_contents_meta(entry.component),
+ )
+ ]
+
cfg = entry.config
ctx = ExecutionContext(request_id=str(uuid.uuid4()), metadata=dict(path_kwargs))
guards, middleware, interceptors, pipes, filters = self._pipeline_stages(entry.method)
@@ -904,13 +1099,15 @@ def _env_bool(name: str) -> Optional[bool]:
async def start(self) -> None:
"""Starts the MCP application based on transport configurations."""
- # Start background OAuth discovery server if OAuthService is resolved
- try:
- from nitrostack.auth.oauth import OAuthService
- oauth_service = DIContainer.get_instance().resolve(OAuthService)
+ # Start background OAuth discovery only when OAuthModule registered a service.
+ # resolve(OAuthService) would otherwise auto-instantiate and fail closed.
+ from nitrostack.auth.oauth import OAuthService, warn_if_oauth_fail_open
+
+ container = DIContainer.get_instance()
+ if container.has_value(OAuthService):
+ oauth_service = container.resolve(OAuthService)
oauth_service.start_discovery_server()
- except Exception:
- pass
+ warn_if_oauth_fail_open()
transport = os.environ.get("MCP_TRANSPORT_TYPE") or self.server_config.transport_type
node_env = os.environ.get("NODE_ENV", "development")
diff --git a/nitrostack/core/app_mode.py b/nitrostack/core/app_mode.py
new file mode 100644
index 0000000..cadb517
--- /dev/null
+++ b/nitrostack/core/app_mode.py
@@ -0,0 +1,41 @@
+"""NitroStack app mode — OpenAI Apps SDK vs MCP Apps wire conventions."""
+
+from __future__ import annotations
+
+import os
+from typing import Literal
+
+AppMode = Literal["openai", "mcp-app", "universal"]
+
+RESOURCE_MIME_TYPE_MCP_APP = "text/html;profile=mcp-app"
+RESOURCE_MIME_TYPE_OPENAI = "text/html"
+OPENAI_SKYBRIDGE_MIME_TYPE = "text/html+skybridge"
+
+
+def get_app_mode() -> AppMode:
+ """Parse ``NITROSTACK_APP_MODE`` with lenient aliases.
+
+ Default is ``universal`` so MCP Inspector (Apps tab) and ChatGPT both receive
+ widget MIME + ``_meta.ui.resourceUri`` without extra env setup.
+ """
+ raw = (os.environ.get("NITROSTACK_APP_MODE") or "universal").lower().strip()
+ if raw in ("mcp-app", "mcpapp", "mcp_app", "mcp app", "mcp", "mcpapps"):
+ return "mcp-app"
+ if raw in ("universal", "all", "both"):
+ return "universal"
+ return "openai"
+
+
+def is_mcp_app_mode() -> bool:
+ mode = get_app_mode()
+ return mode in ("mcp-app", "universal")
+
+
+def is_openai_mode() -> bool:
+ mode = get_app_mode()
+ return mode in ("openai", "universal")
+
+
+def get_widget_mime_type() -> str:
+ """MIME for ``resources/read`` widget HTML (never skybridge)."""
+ return RESOURCE_MIME_TYPE_MCP_APP if is_mcp_app_mode() else RESOURCE_MIME_TYPE_OPENAI
diff --git a/nitrostack/core/decorators.py b/nitrostack/core/decorators.py
index a05f555..60f4323 100644
--- a/nitrostack/core/decorators.py
+++ b/nitrostack/core/decorators.py
@@ -1,7 +1,9 @@
from dataclasses import dataclass, field
-from typing import Any, Callable, List, Dict, Optional, Type, Literal
+from typing import Any, Callable, List, Dict, Optional, Type, Literal, Union
from functools import wraps
+from nitrostack.widgets.component import WidgetOptions, parse_widget_options
+
@dataclass
class ToolAnnotations:
destructive_hint: bool = True
@@ -87,13 +89,6 @@ def widget_resource_uri(route_path: str) -> str:
return f"ui://widget/{name}.html"
-def _apply_widget_metadata(metadata: Dict[str, Any], route_path: str) -> None:
- uri = widget_resource_uri(route_path)
- metadata["ui/template"] = uri
- metadata["ui"] = {"resourceUri": uri}
- metadata["openai/outputTemplate"] = uri
-
-
def tool(
name: str,
description: str,
@@ -131,25 +126,20 @@ def decorator(func: Callable):
metadata=metadata,
is_initial=getattr(func, "_mcp_is_initial", False)
)
- # Check if function already had a widget decorator applied first
- widget_route = getattr(func, "_mcp_widget", None)
- if widget_route:
- _apply_widget_metadata(config.metadata, widget_route)
-
func._mcp_tool_config = config
return func
return decorator
-def widget(route_path: str):
+def widget(route_or_options: Union[str, WidgetOptions, Dict[str, Any]]):
"""
Decorator to associate a UI widget route with a tool.
+
+ Accepts a route string, :class:`WidgetOptions`, or a snake_case dict.
"""
- uri = widget_resource_uri(route_path)
+ parse_widget_options(route_or_options)
def decorator(func: Callable):
- func._mcp_widget = uri
- if hasattr(func, "_mcp_tool_config"):
- _apply_widget_metadata(func._mcp_tool_config.metadata, uri)
+ func._mcp_widget = route_or_options
return func
return decorator
diff --git a/nitrostack/core/di.py b/nitrostack/core/di.py
index 2e086b1..f57d61a 100644
--- a/nitrostack/core/di.py
+++ b/nitrostack/core/di.py
@@ -29,6 +29,13 @@ def register_value(self, token: Any, value: Any) -> None:
if not isinstance(token, str):
self._registry[token] = type(value)
+ def has_value(self, token: Any) -> bool:
+ """True when ``register_value`` stored an instance for this token.
+
+ Unlike ``resolve``, this does not auto-instantiate unregistered classes.
+ """
+ return token in self._instances
+
def resolve(self, token: Any) -> Any:
"""
Resolve a dependency by token (class type or string key).
diff --git a/nitrostack/core/pipeline.py b/nitrostack/core/pipeline.py
index f529910..ae499ae 100644
--- a/nitrostack/core/pipeline.py
+++ b/nitrostack/core/pipeline.py
@@ -120,27 +120,52 @@ async def can_activate(self, context: ExecutionContext) -> bool:
context.logger.error(f"JWT Guard token validation failed: {e}")
return False
+def _extract_oauth_token(context: ExecutionContext) -> Optional[str]:
+ """Studio / MCP hosts send the token in several metadata slots (TS parity)."""
+ headers = context.metadata.get("headers") if isinstance(context.metadata.get("headers"), dict) else {}
+ auth_header = (
+ context.metadata.get("authorization")
+ or headers.get("authorization")
+ or headers.get("Authorization")
+ )
+ if isinstance(auth_header, str) and auth_header.startswith("Bearer "):
+ return auth_header[len("Bearer "):].strip() or None
+ meta_token = context.metadata.get("_oauth") or context.metadata.get("token")
+ if isinstance(meta_token, str) and meta_token.strip():
+ return meta_token.strip()
+ return None
+
+
class OAuthGuard:
"""
Validates OAuth 2.1 access token with audience binding.
+
+ Matches the TS SDK: unless ``OAUTH_REQUIRED=true``, requests without a
+ token are allowed so Studio can exercise mock flight widgets locally.
"""
async def can_activate(self, context: ExecutionContext) -> bool:
- auth_header = context.metadata.get("authorization") or context.metadata.get("headers", {}).get("authorization")
- if not auth_header or not auth_header.startswith("Bearer "):
- return False
-
- token = auth_header[len("Bearer "):]
-
+ from nitrostack.auth.oauth import OAuthService, is_oauth_required
+
+ token = _extract_oauth_token(context)
+ required = is_oauth_required()
+
+ if not token:
+ if not required:
+ return True
+ raise PermissionError(
+ "OAuth token required. Authenticate in Studio (Auth → OAuth) "
+ "or unset OAUTH_REQUIRED to use mock flights locally."
+ )
+
container = DIContainer.get_instance()
try:
- from nitrostack.auth.oauth import OAuthService
oauth_service = container.resolve(OAuthService)
- # Introspect token
token_info = await oauth_service.introspect_token(token)
if not token_info.get("active"):
+ if not required:
+ return True
return False
-
- # Populate AuthContext
+
context.auth = AuthContext(
subject=token_info.get("sub"),
scopes=token_info.get("scope", "").split(" ") if token_info.get("scope") else [],
@@ -152,9 +177,15 @@ async def can_activate(self, context: ExecutionContext) -> bool:
token_payload=token_info
)
return True
+ except PermissionError:
+ raise
except Exception as e:
context.logger.error(f"OAuth Guard validation failed: {e}")
- return False
+ if not required:
+ context.logger.warning(
+ "OAuth validation failed but OAUTH_REQUIRED is off; allowing the request"
+ )
+ return not required
# Pipeline Runner logic
diff --git a/nitrostack/templates/flight-booking/.env b/nitrostack/templates/flight-booking/.env
index 0f038c2..e4a2b46 100644
--- a/nitrostack/templates/flight-booking/.env
+++ b/nitrostack/templates/flight-booking/.env
@@ -5,3 +5,7 @@ TOKEN_ISSUER=https://dev-5dt0utuk315713tjm.us.auth0.com
DUFFEL_API_KEY=your-duffel-api-key
PORT=3000
NODE_ENV=development
+NITROSTACK_APP_MODE=universal
+# Unset by default so Studio can call tools without a token (TS default).
+# Set to true only when testing real OAuth enforcement.
+# OAUTH_REQUIRED=true
diff --git a/nitrostack/templates/flight-booking/.env.example b/nitrostack/templates/flight-booking/.env.example
index 0f038c2..e4a2b46 100644
--- a/nitrostack/templates/flight-booking/.env.example
+++ b/nitrostack/templates/flight-booking/.env.example
@@ -5,3 +5,7 @@ TOKEN_ISSUER=https://dev-5dt0utuk315713tjm.us.auth0.com
DUFFEL_API_KEY=your-duffel-api-key
PORT=3000
NODE_ENV=development
+NITROSTACK_APP_MODE=universal
+# Unset by default so Studio can call tools without a token (TS default).
+# Set to true only when testing real OAuth enforcement.
+# OAUTH_REQUIRED=true
diff --git a/nitrostack/templates/flight-booking/OAUTH_SETUP.md b/nitrostack/templates/flight-booking/OAUTH_SETUP.md
index e1f399c..cbfcf66 100644
--- a/nitrostack/templates/flight-booking/OAUTH_SETUP.md
+++ b/nitrostack/templates/flight-booking/OAUTH_SETUP.md
@@ -1,5 +1,10 @@
# OAuth 2.1 Server Setup Guide
+The TypeScript SDK only enforces OAuth when `OAUTH_REQUIRED=true`. Python now
+matches that: **Studio and Inspector work without a token** against mock Duffel
+flights (`DUFFEL_API_KEY=your-duffel-api-key`). Set `OAUTH_REQUIRED=true` only
+when you want real Bearer-token checks.
+
To run your flight booking MCP server with OAuth 2.1 protection, you need to configure an OAuth authorization server (like Keycloak, Auth0, Hydra, or a local mock OAuth server).
## 1. Local Configuration
diff --git a/nitrostack/templates/flight-booking/guards/oauth_guard.py b/nitrostack/templates/flight-booking/guards/oauth_guard.py
index 03c3a07..3b72f87 100644
--- a/nitrostack/templates/flight-booking/guards/oauth_guard.py
+++ b/nitrostack/templates/flight-booking/guards/oauth_guard.py
@@ -1,9 +1,13 @@
from nitrostack import ExecutionContext
+from nitrostack.auth.oauth import is_oauth_required
def create_scope_guard(required_scopes: list):
class ScopeGuard:
async def can_activate(self, context: ExecutionContext) -> bool:
- user_scopes = getattr(context.auth, "scopes", [])
+ # TS tools only use OAuthGuard; scope checks apply when auth is enforced.
+ if not is_oauth_required():
+ return True
+ user_scopes = getattr(context.auth, "scopes", []) or []
missing_scopes = [s for s in required_scopes if s not in user_scopes]
if missing_scopes:
raise ValueError(
diff --git a/nitrostack/templates/flight-booking/modules/flights/booking_tools.py b/nitrostack/templates/flight-booking/modules/flights/booking_tools.py
index 48104e6..4c81952 100644
--- a/nitrostack/templates/flight-booking/modules/flights/booking_tools.py
+++ b/nitrostack/templates/flight-booking/modules/flights/booking_tools.py
@@ -1,22 +1,59 @@
-from nitrostack import injectable, tool, widget, use_guards, OAuthGuard, ExecutionContext
+from nitrostack import injectable, tool, widget, use_guards, OAuthGuard, ExecutionContext, WidgetOptions
+from nitrostack.widgets.flight_transforms import (
+ transform_cancel_order,
+ transform_create_order,
+ transform_order_details,
+ transform_seat_map,
+)
from services.duffel_service import DuffelService
from guards.oauth_guard import create_scope_guard
from pydantic import BaseModel, Field
import json
+
class CreateOrderInput(BaseModel):
offerId: str = Field(description="The offer ID to book")
passengers: str = Field(description="JSON string containing array of passenger objects. Each passenger must have: title (mr/ms/mrs/miss/dr), givenName (first name), familyName (last name), gender (M/F), bornOn (YYYY-MM-DD), email, phoneNumber.")
+
class OrderDetailsInput(BaseModel):
orderId: str = Field(description="The order ID")
+
class SeatMapInput(BaseModel):
offerId: str = Field(description="The offer ID to get seats for")
+
class CancelOrderInput(BaseModel):
orderId: str = Field(description="The order ID to cancel")
+
+def _parse_passengers(raw) -> list:
+ if isinstance(raw, list):
+ passengers_array = raw
+ elif isinstance(raw, str):
+ passenger_str = raw
+ if passenger_str.startswith('\\"') or '\\"' in passenger_str:
+ passenger_str = passenger_str.replace('\\"', '"').replace('\\\\', '\\')
+ passengers_array = json.loads(passenger_str)
+ else:
+ raise ValueError("Passengers must be a JSON string or array")
+ if not passengers_array:
+ raise ValueError("At least one passenger is required to create an order")
+ passengers = []
+ for pax in passengers_array:
+ passengers.append({
+ "title": pax.get("title", "mr"),
+ "given_name": pax.get("givenName") or pax.get("given_name"),
+ "family_name": pax.get("familyName") or pax.get("family_name"),
+ "gender": pax.get("gender", "M"),
+ "born_on": pax.get("bornOn") or pax.get("born_on"),
+ "email": pax.get("email"),
+ "phone_number": pax.get("phoneNumber") or pax.get("phone_number"),
+ })
+ return passengers
+
+
@injectable(deps=[DuffelService])
class BookingTools:
def __init__(self, service: DuffelService):
@@ -29,32 +66,15 @@ def __init__(self, service: DuffelService):
input_schema=CreateOrderInput
)
@use_guards(OAuthGuard, create_scope_guard(["write"]))
- @widget("order-summary")
+ @widget(WidgetOptions(route="order-summary", prefers_border=True))
async def create_order(self, input: CreateOrderInput, context: ExecutionContext) -> dict:
context.logger.info(f"Creating flight order for offer {input.offerId}")
- try:
- passengers_array = json.loads(input.passengers)
- except Exception:
- raise ValueError("Invalid passengers JSON format")
-
- passengers = []
- for p in passengers_array:
- passengers.append({
- "title": p.get("title", "mr"),
- "given_name": p.get("givenName"),
- "family_name": p.get("familyName"),
- "gender": p.get("gender", "M"),
- "born_on": p.get("bornOn"),
- "email": p.get("email"),
- "phone_number": p.get("phoneNumber")
- })
-
- order_params = {
+ passengers = _parse_passengers(input.passengers)
+ res = await self.service.create_order({
"selectedOffers": [input.offerId],
- "passengers": passengers
- }
- res = await self.service.create_order(order_params)
- return res
+ "passengers": passengers,
+ })
+ return transform_create_order(res)
@tool(
name="get_order_details",
@@ -63,11 +83,11 @@ async def create_order(self, input: CreateOrderInput, context: ExecutionContext)
input_schema=OrderDetailsInput
)
@use_guards(OAuthGuard, create_scope_guard(["read"]))
- @widget("order-summary")
+ @widget(WidgetOptions(route="order-summary", prefers_border=True))
async def get_order_details(self, input: OrderDetailsInput, context: ExecutionContext) -> dict:
context.logger.info(f"Fetching details for order {input.orderId}")
res = await self.service.get_order(input.orderId)
- return res
+ return transform_order_details(res)
@tool(
name="get_seat_map",
@@ -76,11 +96,11 @@ async def get_order_details(self, input: OrderDetailsInput, context: ExecutionCo
input_schema=SeatMapInput
)
@use_guards(OAuthGuard, create_scope_guard(["read"]))
- @widget("seat-selection")
+ @widget(WidgetOptions(route="seat-selection", prefers_border=True))
async def get_seat_map(self, input: SeatMapInput, context: ExecutionContext) -> dict:
context.logger.info(f"Fetching seat map for offer {input.offerId}")
res = await self.service.get_seats_for_offer(input.offerId)
- return {"offerId": input.offerId, "cabins": res}
+ return transform_seat_map(input.offerId, res)
@tool(
name="cancel_order",
@@ -89,8 +109,8 @@ async def get_seat_map(self, input: SeatMapInput, context: ExecutionContext) ->
input_schema=CancelOrderInput
)
@use_guards(OAuthGuard, create_scope_guard(["write"]))
- @widget("order-cancellation")
+ @widget(WidgetOptions(route="order-cancellation", prefers_border=True))
async def cancel_order(self, input: CancelOrderInput, context: ExecutionContext) -> dict:
context.logger.info(f"Cancelling order {input.orderId}")
res = await self.service.cancel_order(input.orderId)
- return res
+ return transform_cancel_order(input.orderId, res)
diff --git a/nitrostack/templates/flight-booking/modules/flights/flights_tools.py b/nitrostack/templates/flight-booking/modules/flights/flights_tools.py
index 3d1904c..6ef7ffc 100644
--- a/nitrostack/templates/flight-booking/modules/flights/flights_tools.py
+++ b/nitrostack/templates/flight-booking/modules/flights/flights_tools.py
@@ -1,26 +1,56 @@
-from nitrostack import injectable, tool, widget, use_guards, OAuthGuard, ExecutionContext
+from nitrostack import (
+ injectable,
+ tool,
+ widget,
+ use_guards,
+ OAuthGuard,
+ ExecutionContext,
+ WidgetOptions,
+ ToolExamples,
+)
+from nitrostack.widgets.flight_transforms import (
+ build_passengers,
+ transform_airport_results,
+ transform_flight_details,
+ transform_flight_search,
+)
from services.duffel_service import DuffelService
from guards.oauth_guard import create_scope_guard
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, field_validator
from typing import Optional
+
class SearchFlightsInput(BaseModel):
- origin: str = Field(description="Origin airport IATA code (e.g., 'JFK', 'LHR')")
- destination: str = Field(description="Destination airport IATA code (e.g., 'LAX', 'CDG')")
+ origin: str = Field(description="Origin airport IATA code (e.g., 'JFK', 'DEL', 'LHR')")
+ destination: str = Field(description="Destination airport IATA code (e.g., 'LAX', 'LHR', 'CDG')")
departureDate: str = Field(description="Departure date in YYYY-MM-DD format")
returnDate: Optional[str] = Field(default=None, description="Return date in YYYY-MM-DD format for round trip")
adults: int = Field(default=1, description="Number of adult passengers (18+)")
+ children: int = Field(default=0, description="Number of child passengers (2-17)")
+ infants: int = Field(default=0, description="Number of infant passengers (under 2)")
cabinClass: str = Field(default="economy", description="Preferred cabin class (economy, premium_economy, business, first)")
+ maxConnections: Optional[int] = Field(default=None, description="Maximum number of connections (0 for direct flights only)")
+ departureTimeFrom: Optional[str] = Field(default=None, description="Earliest departure time in HH:MM format")
+ departureTimeTo: Optional[str] = Field(default=None, description="Latest departure time in HH:MM format")
+
+ @field_validator("origin", "destination")
+ @classmethod
+ def upper_iata(cls, value: str) -> str:
+ return (value or "").strip().upper()
+
class FlightDetailsInput(BaseModel):
offerId: str = Field(description="The flight offer ID from search results")
+
class AirportSearchInput(BaseModel):
- query: str = Field(description="The search query for airports (e.g., 'London', 'New York')")
+ query: str = Field(min_length=2, description="City name or airport code to search for (e.g., 'Delhi', 'DEL', 'London')")
+
class GetAirlinesInput(BaseModel):
pass
+
@injectable(deps=[DuffelService])
class FlightTools:
def __init__(self, service: DuffelService):
@@ -30,43 +60,70 @@ def __init__(self, service: DuffelService):
name="search_flights",
title="Search Flights",
description="Search for flight offers based on origin, destination, dates, and preferences.",
- input_schema=SearchFlightsInput
+ input_schema=SearchFlightsInput,
+ examples=ToolExamples(
+ input={"origin": "DEL", "destination": "LHR", "departureDate": "2026-08-19", "adults": 2, "cabinClass": "economy"},
+ output={
+ "requestId": "orq_mock123456",
+ "searchParams": {"origin": "DEL", "destination": "LHR", "departureDate": "2026-08-19", "passengers": {"adults": 2, "children": 0, "infants": 0}, "cabinClass": "economy"},
+ "totalOffers": 1,
+ "offers": [{"id": "off_mock123456", "totalAmount": "450.00", "totalCurrency": "USD"}],
+ },
+ description="DEL → LHR mock search used by Studio / Inspector",
+ ),
)
@use_guards(OAuthGuard, create_scope_guard(["read"]))
- @widget("flight-search-results")
+ @widget(WidgetOptions(route="flight-search-results", prefers_border=True))
async def search_flights(self, input: SearchFlightsInput, context: ExecutionContext) -> dict:
context.logger.info(f"Searching flights from {input.origin} to {input.destination}")
- res = await self.service.search_flights(input.model_dump())
- return res
+ passengers = build_passengers(input.adults, input.children, input.infants)
+ departure_time = None
+ if input.departureTimeFrom and input.departureTimeTo:
+ departure_time = {"from": input.departureTimeFrom, "to": input.departureTimeTo}
+ res = await self.service.search_flights(
+ {
+ **input.model_dump(),
+ "passengers": passengers,
+ "departureTime": departure_time,
+ }
+ )
+ return transform_flight_search(input.model_dump(), res)
@tool(
name="get_flight_details",
title="Get Flight Details",
description="Get detailed information about a specific flight offer including baggage allowance, conditions.",
- input_schema=FlightDetailsInput
+ input_schema=FlightDetailsInput,
+ examples=ToolExamples(
+ input={"offerId": "off_mock123456"},
+ output={"id": "off_mock123456", "totalAmount": "450.00", "totalCurrency": "USD"},
+ description="Details for the mock offer from the last search",
+ ),
)
@use_guards(OAuthGuard, create_scope_guard(["read"]))
- @widget("flight-details")
+ @widget(WidgetOptions(route="flight-details", prefers_border=True))
async def get_flight_details(self, input: FlightDetailsInput, context: ExecutionContext) -> dict:
context.logger.info(f"Fetching flight details for offer {input.offerId}")
res = await self.service.get_offer(input.offerId)
- return res
+ return transform_flight_details(res)
@tool(
name="search_airports",
title="Search Airports",
- description="Search for airports by query string.",
- input_schema=AirportSearchInput
+ description="Search for airports by city name or airport code. Useful for finding IATA codes.",
+ input_schema=AirportSearchInput,
+ examples=ToolExamples(
+ input={"query": "Delhi"},
+ output={"query": "Delhi", "results": [{"iataCode": "DEL", "name": "Indira Gandhi International Airport", "cityName": "Delhi"}]},
+ description="Airport lookup used by Studio / Inspector",
+ ),
)
@use_guards(OAuthGuard, create_scope_guard(["read"]))
- @widget("airport-search")
+ @widget(WidgetOptions(route="airport-search", prefers_border=True))
async def search_airports(self, input: AirportSearchInput, context: ExecutionContext) -> dict:
context.logger.info(f"Searching airports for query: {input.query}")
places = await self.service.search_airports(input.query)
- return {
- "query": input.query,
- "results": places
- }
+ return transform_airport_results(input.query, places)
@tool(
name="get_airlines",
diff --git a/nitrostack/templates/flight-booking/services/duffel_service.py b/nitrostack/templates/flight-booking/services/duffel_service.py
index 0fe6e85..b2ce491 100644
--- a/nitrostack/templates/flight-booking/services/duffel_service.py
+++ b/nitrostack/templates/flight-booking/services/duffel_service.py
@@ -5,28 +5,55 @@
import urllib.parse
from typing import List, Dict, Any, Optional
from nitrostack import injectable
+from nitrostack.widgets.flight_catalog import lookup_airport, search_mock_airports
+from nitrostack.widgets.flight_transforms import build_passengers
+
+
+def _is_mock_key(api_key: Optional[str]) -> bool:
+ if not api_key:
+ return True
+ key = api_key.strip()
+ if len(key) < 5:
+ return True
+ lowered = key.lower()
+ return lowered.startswith("your-") or "dummy" in lowered or lowered in {"test", "changeme", "placeholder"}
+
+
+def _place(code: str) -> Dict[str, Any]:
+ airport = lookup_airport(code)
+ iata = (code or "").upper()
+ if airport:
+ return {
+ "iata_code": airport["iata_code"],
+ "name": airport["name"],
+ "city_name": airport["city_name"],
+ }
+ return {"iata_code": iata, "name": f"{iata} Airport", "city_name": iata}
+
@injectable()
class DuffelService:
def __init__(self):
self.api_key = os.environ.get("DUFFEL_API_KEY")
- self.is_mock = not self.api_key or self.api_key.startswith("your-") or len(self.api_key) < 5
+ self.is_mock = _is_mock_key(self.api_key)
+ self._last_search: Dict[str, Any] = {}
- def _request(self, method: str, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
+ def _request(self, method: str, path: str, data: Optional[Dict[str, Any]] = None) -> Any:
if self.is_mock:
raise ValueError("Duffel API key is not configured. Running in mock mode.")
-
+
headers = {
"Authorization": f"Bearer {self.api_key}",
"Duffel-Version": "v1",
- "Content-Type": "application/json"
+ "Content-Type": "application/json",
+ "Accept": "application/json",
}
url = f"https://api.duffel.com{path}"
req_data = json.dumps({"data": data}).encode("utf-8") if data else None
-
+
req = urllib.request.Request(url, data=req_data, headers=headers, method=method)
try:
- with urllib.request.urlopen(req, timeout=10) as response:
+ with urllib.request.urlopen(req, timeout=15) as response:
res_payload = json.loads(response.read().decode("utf-8"))
return res_payload.get("data", {})
except Exception as e:
@@ -34,94 +61,107 @@ def _request(self, method: str, path: str, data: Optional[Dict[str, Any]] = None
sys.stderr.flush()
raise e
- async def search_flights(self, params: Dict[str, Any]) -> Dict[str, Any]:
- if self.is_mock:
+ def _mock_offer(self, offer_id: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
+ search = params or self._last_search
+ origin = str(search.get("origin") or "JFK").upper()
+ destination = str(search.get("destination") or "LAX").upper()
+ departure = search.get("departureDate") or "2026-09-15"
+ return_date = search.get("returnDate")
+ origin_place = _place(origin)
+ dest_place = _place(destination)
+
+ def slice_for(src: Dict[str, Any], dst: Dict[str, Any], date: str, seg_id: str) -> Dict[str, Any]:
return {
- "id": "orq_mock123456",
- "offers": [
+ "origin": src,
+ "destination": dst,
+ "duration": "PT6H30M",
+ "segments": [
{
- "id": "off_mock123456",
- "total_amount": "450.00",
- "total_currency": "USD",
- "expires_at": "2026-12-31T12:00:00Z",
- "slices": [
- {
- "origin": {"iata_code": params["origin"], "name": "Origin Airport", "city_name": "Origin City"},
- "destination": {"iata_code": params["destination"], "name": "Dest Airport", "city_name": "Dest City"},
- "duration": "PT6H30M",
- "segments": [
- {
- "id": "seg_outbound",
- "origin": {"iata_code": params["origin"]},
- "destination": {"iata_code": params["destination"]},
- "departing_at": f"{params['departureDate']}T08:00:00Z",
- "arriving_at": f"{params['departureDate']}T14:30:00Z",
- "marketing_carrier": {"name": "Mock Airlines"},
- "marketing_carrier_flight_number": "MK123",
- "aircraft": {"name": "Boeing 787"}
- }
- ]
- }
- ]
+ "id": seg_id,
+ "origin": {"iata_code": src["iata_code"]},
+ "destination": {"iata_code": dst["iata_code"]},
+ "departing_at": f"{date}T08:00:00Z",
+ "arriving_at": f"{date}T14:30:00Z",
+ "duration": "PT6H30M",
+ "marketing_carrier": {"name": "Mock Airlines", "iata_code": "MK"},
+ "marketing_carrier_flight_number": "MK123",
+ "aircraft": {"name": "Boeing 787"},
}
- ]
+ ],
}
-
- slices = [
+
+ slices = [slice_for(origin_place, dest_place, departure, "seg_outbound")]
+ if return_date:
+ slices.append(slice_for(dest_place, origin_place, str(return_date), "seg_return"))
+ return {
+ "id": offer_id,
+ "total_amount": "450.00",
+ "total_currency": "USD",
+ "expires_at": "2026-12-31T12:00:00Z",
+ "passenger_identity_documents_required": origin[:1] != destination[:1],
+ "conditions": {
+ "refund_before_departure": {"allowed": False},
+ "change_before_departure": {"allowed": True, "penalty_amount": "75.00", "penalty_currency": "USD"},
+ },
+ "payment_requirements": {
+ "requires_instant_payment": False,
+ "price_guarantee_expires_at": "2026-12-31T12:00:00Z",
+ },
+ "passengers": [{"id": "pas_mock", "type": "adult", "fare_type": "economy", "baggages": [{"type": "checked", "quantity": 1}]}],
+ "slices": slices,
+ }
+
+ async def search_flights(self, params: Dict[str, Any]) -> Dict[str, Any]:
+ origin = str(params.get("origin") or "").upper()
+ destination = str(params.get("destination") or "").upper()
+ params = {**params, "origin": origin, "destination": destination}
+ self._last_search = params
+ if self.is_mock:
+ offer = self._mock_offer("off_mock123456", params)
+ return {"id": "orq_mock123456", "offers": [offer], "passengers": offer.get("passengers", []), "slices": offer.get("slices", [])}
+
+ passengers = params.get("passengers") or build_passengers(
+ int(params.get("adults") or 1),
+ int(params.get("children") or 0),
+ int(params.get("infants") or 0),
+ )
+ slices: List[Dict[str, Any]] = [
{
- "origin": params["origin"],
- "destination": params["destination"],
- "departure_date": params["departureDate"]
+ "origin": origin,
+ "destination": destination,
+ "departure_date": params["departureDate"],
}
]
+ if params.get("departureTime"):
+ slices[0]["departure_time"] = params["departureTime"]
if params.get("returnDate"):
- slices.append({
- "origin": params["destination"],
- "destination": params["origin"],
- "departure_date": params["returnDate"]
- })
-
- duffel_params = {
+ slices.append(
+ {
+ "origin": destination,
+ "destination": origin,
+ "departure_date": params["returnDate"],
+ }
+ )
+
+ duffel_params: Dict[str, Any] = {
"slices": slices,
- "passengers": [{"type": "adult"} for _ in range(params.get("adults", 1))],
+ "passengers": passengers,
"cabin_class": params.get("cabinClass", "economy"),
- "return_offers": True
+ "return_offers": True,
}
+ if params.get("maxConnections") is not None:
+ duffel_params["max_connections"] = params["maxConnections"]
res = self._request("POST", "/offer_requests", duffel_params)
return {
"id": res.get("id"),
"offers": res.get("offers", []),
"passengers": res.get("passengers", []),
- "slices": res.get("slices", [])
+ "slices": res.get("slices", []),
}
async def get_offer(self, offer_id: str) -> Dict[str, Any]:
if self.is_mock:
- return {
- "id": offer_id,
- "total_amount": "450.00",
- "total_currency": "USD",
- "expires_at": "2026-12-31T12:00:00Z",
- "slices": [
- {
- "origin": {"iata_code": "JFK", "name": "John F. Kennedy Airport", "city_name": "New York"},
- "destination": {"iata_code": "LAX", "name": "Los Angeles Airport", "city_name": "Los Angeles"},
- "duration": "PT6H30M",
- "segments": [
- {
- "id": "seg_mock",
- "origin": {"iata_code": "JFK"},
- "destination": {"iata_code": "LAX"},
- "departing_at": "2026-07-15T08:00:00Z",
- "arriving_at": "2026-07-15T14:30:00Z",
- "marketing_carrier": {"name": "Mock Airlines"},
- "marketing_carrier_flight_number": "MK123",
- "aircraft": {"name": "Boeing 787"}
- }
- ]
- }
- ]
- }
+ return self._mock_offer(offer_id)
return self._request("GET", f"/offers/{offer_id}")
async def get_seats_for_offer(self, offer_id: str) -> List[Dict[str, Any]]:
@@ -140,27 +180,28 @@ async def get_seats_for_offer(self, offer_id: str) -> List[Dict[str, Any]]:
"id": "seat_10a",
"designator": "10A",
"available_services": [{"total_amount": "25.00", "total_currency": "USD"}],
- "disclosures": ["window"]
+ "disclosures": ["window"],
},
{
"type": "seat",
"id": "seat_10b",
"designator": "10B",
"available_services": [{"total_amount": "0.00", "total_currency": "USD"}],
- "disclosures": ["middle"]
- }
+ "disclosures": ["middle"],
+ },
]
}
- ]
+ ],
}
- ]
+ ],
}
]
- res = self._request("GET", f"/seat_maps?offer_id={offer_id}")
+ res = self._request("GET", f"/seat_maps?offer_id={urllib.parse.quote(offer_id)}")
return res if isinstance(res, list) else []
async def create_order(self, params: Dict[str, Any]) -> Dict[str, Any]:
if self.is_mock:
+ offer = self._mock_offer(params.get("selectedOffers", ["off_mock123456"])[0])
return {
"id": "ord_mock123456",
"status": "held",
@@ -171,35 +212,27 @@ async def create_order(self, params: Dict[str, Any]) -> Dict[str, Any]:
"passengers": [
{
"id": f"pax_{idx}",
- "given_name": p["given_name"],
- "family_name": p["family_name"],
- "type": "adult"
- } for idx, p in enumerate(params["passengers"])
- ],
- "slices": [
- {
- "origin": {"iata_code": "JFK"},
- "destination": {"iata_code": "LAX"},
- "duration": "PT6H30M",
- "segments": [
- {
- "departing_at": "2026-07-15T08:00:00Z",
- "arriving_at": "2026-07-15T14:30:00Z"
- }
- ]
+ "given_name": p.get("given_name"),
+ "family_name": p.get("family_name"),
+ "type": "adult",
+ "email": p.get("email"),
+ "phone_number": p.get("phone_number"),
}
- ]
+ for idx, p in enumerate(params.get("passengers") or [])
+ ],
+ "slices": offer["slices"],
}
-
+
order_payload = {
"selected_offers": params["selectedOffers"],
"passengers": params["passengers"],
- "type": "hold"
+ "type": "hold",
}
return self._request("POST", "/orders", order_payload)
async def get_order(self, order_id: str) -> Dict[str, Any]:
if self.is_mock:
+ offer = self._mock_offer("off_mock123456")
return {
"id": order_id,
"status": "held",
@@ -215,29 +248,10 @@ async def get_order(self, order_id: str) -> Dict[str, Any]:
"family_name": "Doe",
"type": "adult",
"email": "john@example.com",
- "phone_number": "+1234567890"
+ "phone_number": "+1234567890",
}
],
- "slices": [
- {
- "id": "sli_mock",
- "origin": {"iata_code": "JFK", "name": "John F. Kennedy Airport", "city_name": "New York"},
- "destination": {"iata_code": "LAX", "name": "Los Angeles Airport", "city_name": "Los Angeles"},
- "duration": "PT6H30M",
- "segments": [
- {
- "id": "seg_mock",
- "origin": {"iata_code": "JFK"},
- "destination": {"iata_code": "LAX"},
- "departing_at": "2026-07-15T08:00:00Z",
- "arriving_at": "2026-07-15T14:30:00Z",
- "marketing_carrier": {"name": "Mock Airlines"},
- "marketing_carrier_flight_number": "MK123",
- "aircraft": {"name": "Boeing 787"}
- }
- ]
- }
- ]
+ "slices": offer["slices"],
}
return self._request("GET", f"/orders/{order_id}")
@@ -247,10 +261,9 @@ async def cancel_order(self, order_id: str) -> Dict[str, Any]:
"id": "ocr_mock123456",
"refund_amount": "450.00",
"refund_currency": "USD",
- "confirmed_at": "2026-06-25T12:30:00Z"
+ "confirmed_at": "2026-06-25T12:30:00Z",
}
- cancel_payload = {"order_id": order_id}
- return self._request("POST", "/order_cancellations", cancel_payload)
+ return self._request("POST", "/order_cancellations", {"order_id": order_id})
async def get_airlines(self) -> List[Dict[str, Any]]:
if self.is_mock:
@@ -258,25 +271,20 @@ async def get_airlines(self) -> List[Dict[str, Any]]:
{"iata_code": "AA", "name": "American Airlines"},
{"iata_code": "DL", "name": "Delta Air Lines"},
{"iata_code": "UA", "name": "United Airlines"},
- {"iata_code": "BA", "name": "British Airways"}
+ {"iata_code": "BA", "name": "British Airways"},
+ {"iata_code": "AI", "name": "Air India"},
]
res = self._request("GET", "/airlines")
return res if isinstance(res, list) else []
async def search_airports(self, query: str) -> List[Dict[str, Any]]:
+ """TS: ``duffel.suggestions.list({ query })``. Mock uses the same catalog shape."""
if self.is_mock:
- return [
- {
- "id": "arp_lhr_gb",
- "name": "London Heathrow Airport",
- "iata_code": "LHR",
- "icao_code": "EGLL",
- "city_name": "London",
- "type": "airport",
- "latitude": 51.4700,
- "longitude": -0.4543,
- "time_zone": "Europe/London"
- }
- ]
- res = self._request("GET", f"/places?type=airport&query={urllib.parse.quote(query)}")
+ return search_mock_airports(query)
+
+ quoted = urllib.parse.quote(query or "")
+ try:
+ res = self._request("GET", f"/air/suggestions?query={quoted}")
+ except Exception:
+ res = self._request("GET", f"/places?type=airport&query={quoted}")
return res if isinstance(res, list) else []
diff --git a/nitrostack/templates/flight-booking/src/widgets/app/airport-search/page.tsx b/nitrostack/templates/flight-booking/src/widgets/app/airport-search/page.tsx
deleted file mode 100644
index 8e72661..0000000
--- a/nitrostack/templates/flight-booking/src/widgets/app/airport-search/page.tsx
+++ /dev/null
@@ -1,270 +0,0 @@
-'use client';
-
-import { useWidgetSDK, useTheme } from '@nitrostack/widgets';
-
-/**
- * Airport Search Widget
- *
- * Compact display of airport search results with IATA codes and locations.
- */
-
-interface AirportResult {
- id: string;
- name: string;
- iataCode: string;
- icaoCode?: string;
- cityName?: string;
- type: string;
- latitude?: number;
- longitude?: number;
- timeZone?: string;
-}
-
-interface AirportSearchData {
- query: string;
- results: AirportResult[];
-}
-
-export default function AirportSearch() {
- const { getToolOutput } = useWidgetSDK();
- const theme = useTheme();
- const data = getToolOutput();
-
- const isDark = theme === 'dark';
-
- const getTypeIcon = (type: string) => {
- const icons: Record = {
- 'airport': '✈️',
- 'city': '🏙️',
- 'station': '🚉',
- 'bus_station': '🚌',
- 'heliport': '🚁'
- };
- return icons[type] || '📍';
- };
-
- if (!data) {
- return (
-
+ This static file does not follow MCP Inspector tool calls.
+ Inspector JSON is the live result (e.g. all matching pizza shops). Open
+ http://localhost:3000/widgets/preview with the server running to
+ render that same output, or paste structuredContent below and Inject.
+
+
+
+
+
+
+
+
diff --git a/nitrostack/templates/pizzaz/.env b/nitrostack/templates/pizzaz/.env
index 766252b..6d92de1 100644
--- a/nitrostack/templates/pizzaz/.env
+++ b/nitrostack/templates/pizzaz/.env
@@ -1,2 +1,6 @@
PORT=3000
NODE_ENV=development
+NITROSTACK_APP_MODE=universal
+
+# Set a Mapbox public token locally (never commit a real pk.eyJ key).
+MAPBOX_TOKEN=pk.your_mapbox_token_here
diff --git a/nitrostack/templates/pizzaz/.env.example b/nitrostack/templates/pizzaz/.env.example
new file mode 100644
index 0000000..b922b01
--- /dev/null
+++ b/nitrostack/templates/pizzaz/.env.example
@@ -0,0 +1,6 @@
+PORT=3000
+NITROSTACK_APP_MODE=universal
+
+# Mapbox public token for the pizza-map widget (same as TS NEXT_PUBLIC_MAPBOX_TOKEN).
+# Get a free key at https://www.mapbox.com/ — or leave unset to use the TS demo token.
+MAPBOX_TOKEN=pk.your_mapbox_token_here
diff --git a/nitrostack/templates/pizzaz/modules/pizzaz/pizzaz_service.py b/nitrostack/templates/pizzaz/modules/pizzaz/pizzaz_service.py
index bb4323f..97d433d 100644
--- a/nitrostack/templates/pizzaz/modules/pizzaz/pizzaz_service.py
+++ b/nitrostack/templates/pizzaz/modules/pizzaz/pizzaz_service.py
@@ -1,6 +1,20 @@
from nitrostack import injectable
from modules.pizzaz.pizzaz_data import PIZZA_SHOPS
+
+def _as_bool(value):
+ """Inspector checkboxes sometimes send \"true\"/\"false\" strings."""
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ lowered = value.strip().lower()
+ if lowered in ("true", "1", "yes"):
+ return True
+ if lowered in ("false", "0", "no", ""):
+ return False
+ return value
+
+
@injectable()
class PizzazService:
def get_all_shops(self):
@@ -13,15 +27,21 @@ def get_shop_by_id(self, shop_id: str):
return None
def get_shops_filtered(self, filters: dict):
- shops = PIZZA_SHOPS
- if filters.get("openNow"):
- shops = [shop for shop in shops if shop["openNow"]]
+ # Match the TypeScript SDK: copy then apply each filter.
+ shops = list(PIZZA_SHOPS)
+ if not filters:
+ return shops
+ if "openNow" in filters and filters.get("openNow") is not None:
+ # True → open shops only. False / empty / "false" → no filter (all shops).
+ # A model passing openNow=false to mean "I don't care" must not get closed-only.
+ if _as_bool(filters.get("openNow")) is True:
+ shops = [shop for shop in shops if shop["openNow"]]
if filters.get("minRating") is not None:
shops = [shop for shop in shops if shop["rating"] >= filters["minRating"]]
if filters.get("maxPrice") is not None:
shops = [shop for shop in shops if shop["priceLevel"] <= filters["maxPrice"]]
if filters.get("cuisine"):
- cuisine_lower = filters["cuisine"].lower()
+ cuisine_lower = str(filters["cuisine"]).lower()
shops = [
shop for shop in shops
if any(cuisine_lower in c.lower() for c in shop["cuisine"])
diff --git a/nitrostack/templates/pizzaz/modules/pizzaz/pizzaz_tools.py b/nitrostack/templates/pizzaz/modules/pizzaz/pizzaz_tools.py
index d4f4520..795528d 100644
--- a/nitrostack/templates/pizzaz/modules/pizzaz/pizzaz_tools.py
+++ b/nitrostack/templates/pizzaz/modules/pizzaz/pizzaz_tools.py
@@ -1,18 +1,80 @@
-from nitrostack import injectable, tool, widget, ExecutionContext
-from pydantic import BaseModel, Field
-from typing import Literal, Optional
+from nitrostack import (
+ injectable,
+ tool,
+ widget,
+ ExecutionContext,
+ WidgetOptions,
+ WidgetCsp,
+ ToolExamples,
+)
+from pydantic import BaseModel, Field, field_validator
+from typing import Any, Literal, Optional
from modules.pizzaz.pizzaz_service import PizzazService
+
+def pizzaz_widget(route: str) -> WidgetOptions:
+ return WidgetOptions(
+ route=route,
+ prefers_border=True,
+ csp=WidgetCsp(
+ resource_domains=[
+ "https://images.unsplash.com",
+ "https://api.mapbox.com",
+ "https://events.mapbox.com",
+ "https://docs.mapbox.com",
+ ],
+ connect_domains=["https://api.mapbox.com", "https://events.mapbox.com"],
+ ),
+ )
+
+
class ShowMapInput(BaseModel):
- filter: Literal["open_now", "top_rated", "all"] = Field(default="all", description="Filter to apply")
+ filter: Literal["open_now", "top_rated", "all"] = Field(
+ default="all",
+ description="Filter to apply. Use open_now to hide closed shops. Leave empty for all shops.",
+ )
+
+ @field_validator("filter", mode="before")
+ @classmethod
+ def _blank_filter_is_all(cls, value: Any) -> Any:
+ # Inspector sends "" when the dropdown is left empty (TS: args.filter || "all").
+ if value is None or (isinstance(value, str) and not value.strip()):
+ return "all"
+ if isinstance(value, str):
+ return value.strip()
+ return value
class ShowListInput(BaseModel):
- openNow: Optional[bool] = Field(default=None, description="Show only shops that are currently open")
+ openNow: Optional[bool] = Field(
+ default=None,
+ description="Set true to list only shops that are currently open. Omit to include closed shops.",
+ )
minRating: Optional[float] = Field(default=None, description="Minimum rating (1-5)")
maxPrice: Optional[float] = Field(default=None, description="Maximum price level (1-3)")
class ShowShopInput(BaseModel):
- shopId: str = Field(description="ID of the pizza shop to display")
+ shopId: str = Field(description="ID of the pizza shop to display, e.g. tonys-pizza")
+
+
+class PizzaListOutput(BaseModel):
+ shops: list[dict[str, Any]]
+ totalShops: int
+ filters: Optional[dict[str, Any]] = None
+ filter: Optional[str] = None
+
+
+class PizzaShopOutput(BaseModel):
+ shop: dict[str, Any]
+ relatedShops: Optional[list[dict[str, Any]]] = None
+
+_OPEN_SHOP_EXAMPLE = {
+ "id": "tonys-pizza",
+ "name": "Tony's New York Pizza",
+ "address": "123 Main St, San Francisco, CA 94102",
+ "rating": 4.5,
+ "openNow": True,
+}
+
@injectable(deps=[PizzazService])
class PizzazTools:
@@ -21,10 +83,19 @@ def __init__(self, service: PizzazService):
@tool(
name="show_pizza_map",
- description="Display an interactive map of pizza shops in San Francisco",
- input_schema=ShowMapInput
+ description=(
+ "Display an interactive map of pizza shops in San Francisco. "
+ "Set filter=open_now when the user asks for shops that are open now."
+ ),
+ input_schema=ShowMapInput,
+ output_schema=PizzaListOutput,
+ examples=ToolExamples(
+ input={"filter": "open_now"},
+ output={"shops": [_OPEN_SHOP_EXAMPLE], "filter": "open_now", "totalShops": 1},
+ description="Map of shops that are currently open",
+ ),
)
- @widget("pizza-map")
+ @widget(pizzaz_widget("pizza-map"))
async def show_pizza_map(self, input: ShowMapInput, context: ExecutionContext) -> dict:
context.logger.info(f"Showing pizza map with filter: {input.filter}")
if input.filter == "open_now":
@@ -33,7 +104,7 @@ async def show_pizza_map(self, input: ShowMapInput, context: ExecutionContext) -
shops = self.service.get_top_rated_shops()
else:
shops = self.service.get_all_shops()
-
+
return {
"shops": shops,
"filter": input.filter,
@@ -42,12 +113,26 @@ async def show_pizza_map(self, input: ShowMapInput, context: ExecutionContext) -
@tool(
name="show_pizza_list",
- description="Display a list of pizza shops with details, ratings, and filters",
- input_schema=ShowListInput
+ description=(
+ "Display a list of pizza shops with details, ratings, and filters. "
+ "When the user asks for open shops only, you MUST pass openNow=true; "
+ "omitting it returns closed shops as well."
+ ),
+ input_schema=ShowListInput,
+ output_schema=PizzaListOutput,
+ examples=ToolExamples(
+ input={"openNow": True},
+ output={
+ "shops": [_OPEN_SHOP_EXAMPLE],
+ "filters": {"openNow": True},
+ "totalShops": 1,
+ },
+ description="List only shops that are currently open",
+ ),
)
- @widget("pizza-list")
+ @widget(pizzaz_widget("pizza-list"))
async def show_pizza_list(self, input: ShowListInput, context: ExecutionContext) -> dict:
- context.logger.info("Showing pizza list")
+ context.logger.info(f"Showing pizza list openNow={input.openNow}")
filters = {}
if input.openNow is not None:
filters["openNow"] = input.openNow
@@ -55,7 +140,7 @@ async def show_pizza_list(self, input: ShowListInput, context: ExecutionContext)
filters["minRating"] = input.minRating
if input.maxPrice is not None:
filters["maxPrice"] = input.maxPrice
-
+
shops = self.service.get_shops_filtered(filters)
return {
"shops": shops,
@@ -70,14 +155,23 @@ async def show_pizza_list(self, input: ShowListInput, context: ExecutionContext)
@tool(
name="show_pizza_shop",
description="Display detailed page for a single pizza shop, including menu, ratings, hours, and photos",
- input_schema=ShowShopInput
+ input_schema=ShowShopInput,
+ output_schema=PizzaShopOutput,
+ examples=ToolExamples(
+ input={"shopId": "tonys-pizza"},
+ output={"shop": _OPEN_SHOP_EXAMPLE, "relatedShops": []},
+ description="Detail page for Tony's New York Pizza",
+ ),
)
- @widget("pizza-shop")
+ @widget(pizzaz_widget("pizza-shop"))
async def show_pizza_shop(self, input: ShowShopInput, context: ExecutionContext) -> dict:
context.logger.info(f"Showing pizza shop: {input.shopId}")
shop = self.service.get_shop_by_id(input.shopId)
if not shop:
raise ValueError(f"Pizza shop not found: {input.shopId}")
return {
- "shop": shop
+ "shop": shop,
+ "relatedShops": [
+ s for s in self.service.get_top_rated_shops(3) if s["id"] != shop["id"]
+ ],
}
diff --git a/nitrostack/templates/pizzaz/src/widgets/app/layout.tsx b/nitrostack/templates/pizzaz/src/widgets/app/layout.tsx
deleted file mode 100644
index 100a41a..0000000
--- a/nitrostack/templates/pizzaz/src/widgets/app/layout.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-'use client';
-
-import { WidgetLayout } from '@nitrostack/widgets';
-import 'mapbox-gl/dist/mapbox-gl.css';
-
-export default function RootLayout({
- children,
-}: {
- children: React.ReactNode;
-}) {
- return (
-
-
- {children}
-
-
- );
-}
diff --git a/nitrostack/templates/pizzaz/src/widgets/app/pizza-list/page.tsx b/nitrostack/templates/pizzaz/src/widgets/app/pizza-list/page.tsx
deleted file mode 100644
index fbabe86..0000000
--- a/nitrostack/templates/pizzaz/src/widgets/app/pizza-list/page.tsx
+++ /dev/null
@@ -1,272 +0,0 @@
-'use client';
-
-import { useTheme, useWidgetState, useMaxHeight, useWidgetSDK } from '@nitrostack/widgets';
-
-// Disable static generation - this is a dynamic widget
-export const dynamic = 'force-dynamic';
-import { PizzaCard } from '../../components/PizzaCard';
-import { SlidersHorizontal } from 'lucide-react';
-import { useState } from 'react';
-
-interface PizzaShop {
- id: string;
- name: string;
- description: string;
- address: string;
- coords: [number, number];
- rating: number;
- reviews: number;
- priceLevel: 1 | 2 | 3;
- cuisine: string[];
- hours: { open: string; close: string };
- phone: string;
- website?: string;
- image: string;
- specialties: string[];
- openNow: boolean;
-}
-
-interface WidgetData {
- shops: PizzaShop[];
- filters: any;
- totalShops: number;
-}
-
-export default function PizzaListWidget() {
- const theme = useTheme();
- const maxHeight = useMaxHeight();
- const isDark = theme === 'dark';
-
- const { isReady, getToolOutput, callTool } = useWidgetSDK();
-
- // Access tool output
- const data = getToolOutput();
-
- console.log('🍕 PizzaListWidget render:', { isReady, hasData: !!data, data });
-
- // Persistent state for view mode and favorites
- const [state, setState] = useWidgetState<{
- viewMode: 'grid' | 'list';
- favorites: string[];
- sortBy: 'rating' | 'name' | 'price';
- }>(() => ({
- viewMode: 'grid',
- favorites: [],
- sortBy: 'rating',
- }));
-
- const [showFilters, setShowFilters] = useState(false);
-
- if (!data) {
- return (
-
- Loading pizza shops... {isReady ? '(SDK ready but no data)' : '(waiting for SDK)'}
-
- );
- }
-
- // Check if shops array exists
- if (!data.shops || !Array.isArray(data.shops)) {
- console.error('❌ Invalid data structure:', data);
- return (
-
- Error: Invalid data structure. Expected shops array.
-
+ This static file does not follow MCP Inspector tool calls.
+ Inspector JSON is the live result (e.g. all matching pizza shops). Open
+ http://localhost:3000/widgets/preview with the server running to
+ render that same output, or paste structuredContent below and Inject.
+
+ This static file does not follow MCP Inspector tool calls.
+ Inspector JSON is the live result (e.g. all matching pizza shops). Open
+ http://localhost:3000/widgets/preview with the server running to
+ render that same output, or paste structuredContent below and Inject.
+
+
+
+
+
+
+
+
diff --git a/nitrostack/transports/http.py b/nitrostack/transports/http.py
index 9ba5ca6..ad73564 100644
--- a/nitrostack/transports/http.py
+++ b/nitrostack/transports/http.py
@@ -17,6 +17,8 @@
- A root documentation page at `GET /` (browsers opening the HTTP port)
- Chrome DevTools discovery stubs at `GET /json` and `GET /json/version` so
inspector probes do not 404
+- JSON 404s for OAuth discovery / DCR (`/register`) so MCP Inspector does not
+ treat the HTML landing page as an OAuth error
- Legacy SSE (`/sse` + `/mcp/messages/`) for older HTTP+SSE-only clients
See `dev-plan/PHASE-3-http-transport.md` for the full scope.
@@ -38,6 +40,9 @@
from starlette.routing import Mount, Route
from starlette.types import ASGIApp, Receive, Scope, Send
+from nitrostack.widgets.preview_page import render_preview_page
+from nitrostack.core.di import DIContainer
+from pydantic_core import PydanticUndefined
from mcp.server.sse import SseServerTransport
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.server.transport_security import TransportSecuritySettings
@@ -114,6 +119,7 @@ def _landing_html(name: str, version: str, endpoint: str) -> str:
@@ -218,6 +224,44 @@ async def traced_send(message: Any) -> None:
await self.app(scope, traced_receive, traced_send)
+def _preview_default_arguments(entry: Any) -> Dict[str, Any]:
+ """Prefill live preview with tool examples (shopId, openNow, …)."""
+ examples = getattr(getattr(entry, "config", None), "examples", None)
+ raw = getattr(examples, "input", None) if examples is not None else None
+ if isinstance(raw, dict):
+ return dict(raw)
+ model = getattr(entry, "input_model", None)
+ if model is None:
+ return {}
+ data: Dict[str, Any] = {}
+ for fname, field in getattr(model, "model_fields", {}).items():
+ default = getattr(field, "default", PydanticUndefined)
+ if default is not PydanticUndefined and default is not None:
+ data[fname] = default
+ continue
+ if fname in ("shopId", "shop_id"):
+ data[fname] = "tonys-pizza"
+ elif fname == "product_id":
+ data[fname] = "sku-1"
+ elif fname == "filter":
+ data[fname] = "all"
+ elif fname == "openNow":
+ data[fname] = True
+ elif fname == "origin":
+ data[fname] = "JFK"
+ elif fname == "destination":
+ data[fname] = "LAX"
+ elif fname in ("departureDate", "departure_date"):
+ data[fname] = "2026-09-15"
+ elif fname in ("offerId", "offer_id"):
+ data[fname] = "off_mock123456"
+ elif fname in ("orderId", "order_id"):
+ data[fname] = "ord_mock123456"
+ elif fname == "query":
+ data[fname] = "London"
+ return data
+
+
class ExactEndpointSlashMiddleware:
"""
Internally rewrite `/mcp` → `/mcp/` so Starlette does not 307.
@@ -406,6 +450,16 @@ def active_session_count(self) -> int:
return len(self._sessions)
+def _oauth_is_configured() -> bool:
+ """True when ``OAuthModule.for_root`` registered an ``OAuthService`` instance."""
+ try:
+ from nitrostack.auth.oauth import OAuthService
+
+ return DIContainer.get_instance().has_value(OAuthService)
+ except Exception:
+ return False
+
+
def build_http_app(
mcp_app: "McpApplication",
*,
@@ -504,6 +558,82 @@ async def root_page(request):
meta = _server_meta(mcp_app)
return HTMLResponse(_landing_html(meta["name"], meta["version"], endpoint))
+ async def oauth_not_supported(request):
+ """Inspector DCR posts `/register` when Authentication is on.
+
+ Return JSON (not the HTML 404 page) so the client shows a clear
+ OAuth-off message instead of `Unexpected token '<'`.
+ """
+ return JSONResponse(
+ {
+ "error": "invalid_request",
+ "error_description": (
+ "This MCP server does not use OAuth. In MCP Inspector turn "
+ "Authentication off, then connect with Streamable HTTP to "
+ f"http://localhost:{os.environ.get('PORT') or os.environ.get('MCP_SERVER_PORT') or '3000'}{endpoint}."
+ ),
+ },
+ status_code=404,
+ )
+
+ async def widgets_preview(request):
+ catalog = []
+ for name, entry in getattr(mcp_app, "_tools", {}).items():
+ component = getattr(entry, "component", None)
+ if component is None:
+ continue
+ catalog.append(
+ {
+ "name": name,
+ "resourceUri": component.resource_uri,
+ "arguments": _preview_default_arguments(entry),
+ }
+ )
+ return HTMLResponse(render_preview_page(catalog))
+
+ async def widgets_preview_call(request):
+ try:
+ body = await request.json()
+ except Exception:
+ return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
+ if not isinstance(body, dict):
+ return JSONResponse({"error": "JSON body must be an object"}, status_code=400)
+ name = body.get("tool") or ""
+ arguments = body.get("arguments") or {}
+ if not isinstance(arguments, dict):
+ return JSONResponse({"error": "arguments must be an object"}, status_code=400)
+ entry = getattr(mcp_app, "_tools", {}).get(name)
+ if entry is None or getattr(entry, "component", None) is None:
+ return JSONResponse({"error": f"No widget tool named {name!r}"}, status_code=404)
+ try:
+ result = await mcp_app._call_tool(name, arguments)
+ except Exception as exc:
+ logger.exception("Widget preview tool call failed for %s", name)
+ return JSONResponse({"error": str(exc)}, status_code=400)
+ structured = getattr(result, "structuredContent", None)
+ try:
+ html = entry.component.html_with_data(structured)
+ except Exception as exc:
+ logger.exception("Widget preview render failed for %s", name)
+ return JSONResponse(
+ {
+ "error": f"Widget render failed: {exc}",
+ "structuredContent": structured,
+ "html": None,
+ "resourceUri": entry.component.resource_uri,
+ "isError": True,
+ },
+ status_code=400,
+ )
+ return JSONResponse(
+ {
+ "structuredContent": structured,
+ "html": html,
+ "resourceUri": entry.component.resource_uri,
+ "isError": bool(getattr(result, "isError", False)),
+ }
+ )
+
async def json_version(request):
"""Chrome/Cursor DevTools probe `/json/version` when a tab opens localhost."""
meta = _server_meta(mcp_app)
@@ -542,12 +672,26 @@ async def lifespan(app):
)
yield
+ oauth_stub_routes: List[Route] = []
+ if not _oauth_is_configured():
+ # Inspector DCR / discovery stubs. Omit when OAuthModule is registered so
+ # a real protected-resource document is not replaced with "no OAuth".
+ oauth_stub_routes = [
+ Route("/.well-known/oauth-authorization-server", endpoint=oauth_not_supported, methods=["GET", "POST"]),
+ Route("/.well-known/oauth-protected-resource", endpoint=oauth_not_supported, methods=["GET", "POST"]),
+ Route("/register", endpoint=oauth_not_supported, methods=["GET", "POST"]),
+ Route("/oauth/v2/register", endpoint=oauth_not_supported, methods=["GET", "POST"]),
+ ]
+
routes = [
# More specific paths MUST come before the catch-all `Mount(endpoint, ...)`
# below — Starlette matches routes in order, and a `Mount` matches any
# path under its prefix, so `/mcp/health` would otherwise be swallowed
# by the `/mcp` mount before ever reaching the health route.
Route("/", endpoint=root_page, methods=["GET"]),
+ Route("/widgets/preview", endpoint=widgets_preview, methods=["GET"]),
+ Route("/widgets/preview/call", endpoint=widgets_preview_call, methods=["POST"]),
+ *oauth_stub_routes,
Route("/json/version", endpoint=json_version, methods=["GET"]),
Route("/json/list", endpoint=json_list, methods=["GET"]),
Route("/json", endpoint=json_list, methods=["GET"]),
diff --git a/nitrostack/widgets/__init__.py b/nitrostack/widgets/__init__.py
new file mode 100644
index 0000000..7da32c3
--- /dev/null
+++ b/nitrostack/widgets/__init__.py
@@ -0,0 +1,55 @@
+from nitrostack.core.app_mode import (
+ OPENAI_SKYBRIDGE_MIME_TYPE,
+ RESOURCE_MIME_TYPE_MCP_APP,
+ RESOURCE_MIME_TYPE_OPENAI,
+ get_app_mode,
+ get_widget_mime_type,
+ is_mcp_app_mode,
+ is_openai_mode,
+)
+from nitrostack.widgets.component import (
+ Component,
+ WidgetCsp,
+ WidgetOptions,
+ create_component,
+ find_project_root,
+ load_widget_html,
+ parse_widget_options,
+)
+from nitrostack.widgets.host_bridge import HOST_BRIDGE_JS
+from nitrostack.widgets.route_templates import build_widget_html_for_route, get_builtin_route_html, render_widget_html
+from nitrostack.widgets.mcp_meta import (
+ build_call_tool_result_meta,
+ build_tool_list_meta,
+ merge_tool_ui_meta,
+ openai_widget_csp,
+ resource_read_contents_meta,
+ widget_csp_to_ui_csp,
+)
+
+__all__ = [
+ "Component",
+ "WidgetCsp",
+ "WidgetOptions",
+ "create_component",
+ "find_project_root",
+ "load_widget_html",
+ "parse_widget_options",
+ "HOST_BRIDGE_JS",
+ "build_widget_html_for_route",
+ "get_builtin_route_html",
+ "render_widget_html",
+ "get_app_mode",
+ "get_widget_mime_type",
+ "is_mcp_app_mode",
+ "is_openai_mode",
+ "RESOURCE_MIME_TYPE_MCP_APP",
+ "RESOURCE_MIME_TYPE_OPENAI",
+ "OPENAI_SKYBRIDGE_MIME_TYPE",
+ "widget_csp_to_ui_csp",
+ "openai_widget_csp",
+ "merge_tool_ui_meta",
+ "build_tool_list_meta",
+ "build_call_tool_result_meta",
+ "resource_read_contents_meta",
+]
diff --git a/nitrostack/widgets/component.py b/nitrostack/widgets/component.py
new file mode 100644
index 0000000..7d49bd1
--- /dev/null
+++ b/nitrostack/widgets/component.py
@@ -0,0 +1,226 @@
+"""Python ``Component`` for static HTML widgets served as MCP resources."""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Callable, Dict, List, Optional
+
+logger = logging.getLogger(__name__)
+
+Transformer = Callable[[Any, Any], Any]
+MetaTransformer = Callable[[Any, Any], Dict[str, Any]]
+
+
+@dataclass
+class WidgetCsp:
+ connect_domains: List[str] = field(default_factory=list)
+ resource_domains: List[str] = field(default_factory=list)
+ frame_domains: List[str] = field(default_factory=list)
+
+
+@dataclass
+class WidgetOptions:
+ route: str
+ description: Optional[str] = None
+ html: Optional[str] = None
+ css: Optional[str] = None
+ js: Optional[str] = None
+ csp: Optional[WidgetCsp] = None
+ domain: Optional[str] = None
+ prefers_border: bool = False
+ can_invoke_tools: bool = False
+
+
+@dataclass
+class Component:
+ id: str
+ name: str
+ html: str
+ description: Optional[str] = None
+ css: Optional[str] = None
+ js: Optional[str] = None
+ csp: Optional[WidgetCsp] = None
+ domain: Optional[str] = None
+ prefers_border: bool = False
+ can_invoke_tools: bool = False
+ transformer: Optional[Transformer] = None
+ meta_transformer: Optional[MetaTransformer] = None
+
+ def __post_init__(self) -> None:
+ if not (self.id or "").strip():
+ raise ValueError("Component id is required")
+ if not (self.name or "").strip():
+ raise ValueError("Component name is required")
+
+ @property
+ def resource_uri(self) -> str:
+ return f"ui://widget/{self.id}.html"
+
+ def get_bundle(self) -> str:
+ from nitrostack.widgets.html_util import inject_mapbox_token
+
+ css_tag = f"" if self.css else ""
+ js_tag = f'' if self.js else ""
+ return inject_mapbox_token(f"{self.html}\n{css_tag}\n{js_tag}".strip())
+
+ def html_with_data(self, data: Any) -> str:
+ """Python-render this widget with live tool output for Inspector / preview."""
+ from nitrostack.widgets.html_util import inject_mapbox_token, inject_tool_data
+ from nitrostack.widgets.route_templates import render_widget_html
+
+ rendered = render_widget_html(self.id, data)
+ if rendered:
+ bundle_extra = ""
+ if self.css:
+ bundle_extra += f""
+ if self.js:
+ bundle_extra += f''
+ return inject_mapbox_token(f"{rendered}\n{bundle_extra}".strip())
+ return inject_tool_data(self.get_bundle(), data)
+
+ def get_openai_resource_metadata(self) -> Dict[str, Any]:
+ """OpenAI widget keys used for tool list and resource-read ``meta``."""
+ meta: Dict[str, Any] = {}
+ if self.can_invoke_tools:
+ meta["openai/widgetAccessible"] = True
+ if self.description:
+ meta["openai/widgetDescription"] = self.description
+ if self.prefers_border:
+ meta["openai/widgetPrefersBorder"] = True
+ if self.domain:
+ meta["openai/widgetDomain"] = self.domain
+ if self.csp:
+ csp_out: Dict[str, List[str]] = {}
+ if self.csp.connect_domains:
+ csp_out["connect_domains"] = list(self.csp.connect_domains)
+ if self.csp.resource_domains:
+ csp_out["resource_domains"] = list(self.csp.resource_domains)
+ if self.csp.frame_domains:
+ csp_out["frame_domains"] = list(self.csp.frame_domains)
+ if csp_out:
+ meta["openai/widgetCSP"] = csp_out
+ return meta
+
+
+def create_component(**kwargs: Any) -> Component:
+ return Component(**kwargs)
+
+
+def _route_id_from_spec(route: str) -> str:
+ route = (route or "").strip()
+ if not route:
+ raise ValueError("widget route must not be empty")
+ if route.startswith("ui://"):
+ name = route.strip("/").removeprefix("widget/").removesuffix(".html").strip("/")
+ if not name:
+ raise ValueError("widget route must not be empty")
+ return name
+ return route.strip("/").removeprefix("widget/").removesuffix(".html").strip("/")
+
+
+def parse_widget_options(spec: str | WidgetOptions | Dict[str, Any]) -> WidgetOptions:
+ if isinstance(spec, WidgetOptions):
+ if not (spec.route or "").strip():
+ raise ValueError("widget route must not be empty")
+ return spec
+ if isinstance(spec, dict):
+ opts = WidgetOptions(**spec)
+ if not (opts.route or "").strip():
+ raise ValueError("widget route must not be empty")
+ return opts
+ if isinstance(spec, str):
+ route_id = _route_id_from_spec(spec)
+ return WidgetOptions(route=route_id)
+ raise TypeError(f"widget spec must be str, WidgetOptions, or dict, got {type(spec)}")
+
+
+def _ancestor_widget_paths(start: Path, route_id: str) -> List[Path]:
+ """Walk parents of a Python file looking for ``widgets/out/{route}.html``."""
+ found: List[Path] = []
+ current = start.parent if start.is_file() else start
+ for parent in [current, *current.parents]:
+ found.append(parent / "widgets" / "out" / f"{route_id}.html")
+ if (parent / "main.py").is_file() or (parent / "app_module.py").is_file():
+ break
+ return found
+
+
+def _project_root_paths(project_root: Path, route_id: str) -> List[Path]:
+ """TS-parity search paths: ``widgets/out``, ``src/widgets/out``, ``dist/widgets/out``."""
+ root = project_root.resolve()
+ return [
+ root / "widgets" / "out" / f"{route_id}.html",
+ root / "src" / "widgets" / "out" / f"{route_id}.html",
+ root / "dist" / "widgets" / "out" / f"{route_id}.html",
+ ]
+
+
+def find_project_root(start: Optional[Path] = None) -> Optional[Path]:
+ """Find directory containing ``main.py`` or ``app_module.py``."""
+ current = (start or Path.cwd()).resolve()
+ if current.is_file():
+ current = current.parent
+ for parent in [current, *current.parents]:
+ if (parent / "main.py").is_file() or (parent / "app_module.py").is_file():
+ return parent
+ return None
+
+
+def load_widget_html(
+ route: str,
+ *,
+ html: Optional[str] = None,
+ search_paths: Optional[List[Path]] = None,
+ from_file: Optional[Path] = None,
+ project_root: Optional[Path] = None,
+ allow_builtin: bool = True,
+) -> Optional[str]:
+ """Resolve widget HTML: explicit string, then disk paths, then built-in route templates."""
+ if html:
+ return html
+
+ route_id = _route_id_from_spec(route)
+ candidates: List[Path] = []
+ if search_paths:
+ for base in search_paths:
+ if base.is_dir():
+ candidates.append(base / f"{route_id}.html")
+ candidates.append(base / "widgets" / "out" / f"{route_id}.html")
+ else:
+ candidates.append(base)
+ if from_file is not None:
+ candidates.extend(_ancestor_widget_paths(Path(from_file), route_id))
+ root = project_root or find_project_root(from_file)
+ if root is not None:
+ candidates.extend(_project_root_paths(root, route_id))
+ candidates.append(Path.cwd() / "widgets" / "out" / f"{route_id}.html")
+ candidates.append(Path.cwd() / "src" / "widgets" / "out" / f"{route_id}.html")
+ candidates.append(Path.cwd() / "dist" / "widgets" / "out" / f"{route_id}.html")
+
+ seen: set[str] = set()
+ for path in candidates:
+ key = str(path.resolve()) if path.is_absolute() or path.exists() else str(path)
+ if key in seen:
+ continue
+ seen.add(key)
+ if path.is_file():
+ try:
+ return path.read_text(encoding="utf-8")
+ except OSError as exc:
+ logger.warning("Failed to read widget HTML at %s: %s", path, exc)
+
+ if allow_builtin:
+ from nitrostack.widgets.route_templates import get_builtin_route_html
+
+ builtin = get_builtin_route_html(route_id)
+ if builtin:
+ return builtin
+
+ logger.error(
+ "No widget HTML found for route %r (searched: %s)",
+ route_id,
+ ", ".join(str(p) for p in candidates),
+ )
+ return None
diff --git a/nitrostack/widgets/flight_catalog.py b/nitrostack/widgets/flight_catalog.py
new file mode 100644
index 0000000..6167421
--- /dev/null
+++ b/nitrostack/widgets/flight_catalog.py
@@ -0,0 +1,145 @@
+"""Offline airport catalog used when Duffel is not configured.
+
+The TypeScript template always calls ``duffel.suggestions.list``. Python keeps
+the same result shape, and this catalog stands in for that API in Studio/mock
+mode so queries like ``Delhi`` / ``DEL`` resolve instead of falling back to
+JFK/LAX/LHR/SFO.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Dict, List, Optional
+
+
+def _arp(
+ iata: str,
+ icao: str,
+ name: str,
+ city: str,
+ country: str,
+ lat: float,
+ lon: float,
+ tz: str,
+ *keywords: str,
+) -> Dict[str, Any]:
+ return {
+ "id": f"arp_{iata.lower()}_{country.lower()}",
+ "name": name,
+ "iata_code": iata,
+ "icao_code": icao,
+ "city_name": city,
+ "type": "airport",
+ "latitude": lat,
+ "longitude": lon,
+ "time_zone": tz,
+ "keywords": list(keywords),
+ }
+
+
+MOCK_AIRPORTS: List[Dict[str, Any]] = [
+ _arp("DEL", "VIDP", "Indira Gandhi International Airport", "Delhi", "in", 28.5562, 77.1000, "Asia/Kolkata", "new delhi", "delhi ncr", "indira gandhi"),
+ _arp("BOM", "VABB", "Chhatrapati Shivaji Maharaj International Airport", "Mumbai", "in", 19.0896, 72.8656, "Asia/Kolkata", "bombay"),
+ _arp("BLR", "VOBL", "Kempegowda International Airport", "Bengaluru", "in", 13.1986, 77.7066, "Asia/Kolkata", "bangalore"),
+ _arp("MAA", "VOMM", "Chennai International Airport", "Chennai", "in", 12.9941, 80.1709, "Asia/Kolkata", "madras"),
+ _arp("HYD", "VOHS", "Rajiv Gandhi International Airport", "Hyderabad", "in", 17.2403, 78.4294, "Asia/Kolkata"),
+ _arp("CCU", "VECC", "Netaji Subhas Chandra Bose International Airport", "Kolkata", "in", 22.6547, 88.4467, "Asia/Kolkata", "calcutta"),
+ _arp("AMD", "VAAH", "Sardar Vallabhbhai Patel International Airport", "Ahmedabad", "in", 23.0772, 72.6347, "Asia/Kolkata"),
+ _arp("GOI", "VOGO", "Manohar International Airport", "Goa", "in", 15.3808, 73.8314, "Asia/Kolkata", "dabolim", "mopa"),
+ _arp("LHR", "EGLL", "London Heathrow Airport", "London", "gb", 51.4700, -0.4543, "Europe/London", "heathrow"),
+ _arp("LGW", "EGKK", "London Gatwick Airport", "London", "gb", 51.1537, -0.1821, "Europe/London", "gatwick"),
+ _arp("STN", "EGSS", "London Stansted Airport", "London", "gb", 51.8860, 0.2389, "Europe/London", "stansted"),
+ _arp("LCY", "EGLC", "London City Airport", "London", "gb", 51.5053, 0.0553, "Europe/London"),
+ _arp("MAN", "EGCC", "Manchester Airport", "Manchester", "gb", 53.3537, -2.2750, "Europe/London"),
+ _arp("EDI", "EGPH", "Edinburgh Airport", "Edinburgh", "gb", 55.9500, -3.3725, "Europe/London"),
+ _arp("JFK", "KJFK", "John F. Kennedy International Airport", "New York", "us", 40.6413, -73.7781, "America/New_York", "nyc", "new york city"),
+ _arp("EWR", "KEWR", "Newark Liberty International Airport", "Newark", "us", 40.6895, -74.1745, "America/New_York", "new york", "nyc"),
+ _arp("LGA", "KLGA", "LaGuardia Airport", "New York", "us", 40.7769, -73.8740, "America/New_York", "nyc"),
+ _arp("LAX", "KLAX", "Los Angeles International Airport", "Los Angeles", "us", 33.9416, -118.4085, "America/Los_Angeles"),
+ _arp("SFO", "KSFO", "San Francisco International Airport", "San Francisco", "us", 37.6213, -122.3790, "America/Los_Angeles"),
+ _arp("ORD", "KORD", "O'Hare International Airport", "Chicago", "us", 41.9742, -87.9073, "America/Chicago"),
+ _arp("ATL", "KATL", "Hartsfield-Jackson Atlanta International Airport", "Atlanta", "us", 33.6407, -84.4277, "America/New_York"),
+ _arp("MIA", "KMIA", "Miami International Airport", "Miami", "us", 25.7959, -80.2870, "America/New_York"),
+ _arp("SEA", "KSEA", "Seattle-Tacoma International Airport", "Seattle", "us", 47.4502, -122.3088, "America/Los_Angeles"),
+ _arp("BOS", "KBOS", "Boston Logan International Airport", "Boston", "us", 42.3656, -71.0096, "America/New_York"),
+ _arp("IAD", "KIAD", "Washington Dulles International Airport", "Washington", "us", 38.9531, -77.4565, "America/New_York", "dc", "dulles"),
+ _arp("DFW", "KDFW", "Dallas/Fort Worth International Airport", "Dallas", "us", 32.8998, -97.0403, "America/Chicago"),
+ _arp("CDG", "LFPG", "Charles de Gaulle Airport", "Paris", "fr", 49.0097, 2.5479, "Europe/Paris"),
+ _arp("AMS", "EHAM", "Amsterdam Airport Schiphol", "Amsterdam", "nl", 52.3105, 4.7683, "Europe/Amsterdam"),
+ _arp("FRA", "EDDF", "Frankfurt Airport", "Frankfurt", "de", 50.0379, 8.5622, "Europe/Berlin"),
+ _arp("MUC", "EDDM", "Munich Airport", "Munich", "de", 48.3537, 11.7750, "Europe/Berlin"),
+ _arp("MAD", "LEMD", "Adolfo Suárez Madrid–Barajas Airport", "Madrid", "es", 40.4983, -3.5676, "Europe/Madrid"),
+ _arp("BCN", "LEBL", "Josep Tarradellas Barcelona–El Prat Airport", "Barcelona", "es", 41.2974, 2.0833, "Europe/Madrid"),
+ _arp("FCO", "LIRF", "Leonardo da Vinci–Fiumicino Airport", "Rome", "it", 41.8003, 12.2389, "Europe/Rome"),
+ _arp("ZRH", "LSZH", "Zurich Airport", "Zurich", "ch", 47.4647, 8.5492, "Europe/Zurich"),
+ _arp("DUB", "EIDW", "Dublin Airport", "Dublin", "ie", 53.4264, -6.2499, "Europe/Dublin"),
+ _arp("DXB", "OMDB", "Dubai International Airport", "Dubai", "ae", 25.2532, 55.3657, "Asia/Dubai"),
+ _arp("AUH", "OMAA", "Zayed International Airport", "Abu Dhabi", "ae", 24.4330, 54.6511, "Asia/Dubai"),
+ _arp("DOH", "OTHH", "Hamad International Airport", "Doha", "qa", 25.2731, 51.6081, "Asia/Qatar"),
+ _arp("IST", "LTFM", "Istanbul Airport", "Istanbul", "tr", 41.2753, 28.7519, "Europe/Istanbul"),
+ _arp("HND", "RJTT", "Tokyo Haneda Airport", "Tokyo", "jp", 35.5494, 139.7798, "Asia/Tokyo", "haneda"),
+ _arp("NRT", "RJAA", "Narita International Airport", "Tokyo", "jp", 35.7720, 140.3929, "Asia/Tokyo", "narita"),
+ _arp("ICN", "RKSI", "Incheon International Airport", "Seoul", "kr", 37.4602, 126.4407, "Asia/Seoul"),
+ _arp("SIN", "WSSS", "Singapore Changi Airport", "Singapore", "sg", 1.3644, 103.9915, "Asia/Singapore", "changi"),
+ _arp("HKG", "VHHH", "Hong Kong International Airport", "Hong Kong", "hk", 22.3080, 113.9185, "Asia/Hong_Kong"),
+ _arp("BKK", "VTBS", "Suvarnabhumi Airport", "Bangkok", "th", 13.6900, 100.7501, "Asia/Bangkok"),
+ _arp("KUL", "WMKK", "Kuala Lumpur International Airport", "Kuala Lumpur", "my", 2.7456, 101.7099, "Asia/Kuala_Lumpur"),
+ _arp("PEK", "ZBAA", "Beijing Capital International Airport", "Beijing", "cn", 40.0799, 116.6031, "Asia/Shanghai"),
+ _arp("PVG", "ZSPD", "Shanghai Pudong International Airport", "Shanghai", "cn", 31.1443, 121.8083, "Asia/Shanghai"),
+ _arp("SYD", "YSSY", "Sydney Kingsford Smith Airport", "Sydney", "au", -33.9399, 151.1753, "Australia/Sydney"),
+ _arp("MEL", "YMML", "Melbourne Airport", "Melbourne", "au", -37.6690, 144.8410, "Australia/Melbourne"),
+ _arp("YYZ", "CYYZ", "Toronto Pearson International Airport", "Toronto", "ca", 43.6777, -79.6248, "America/Toronto"),
+ _arp("MEX", "MMMX", "Mexico City International Airport", "Mexico City", "mx", 19.4363, -99.0721, "America/Mexico_City"),
+ _arp("GRU", "SBGR", "São Paulo/Guarulhos International Airport", "São Paulo", "br", -23.4356, -46.4731, "America/Sao_Paulo"),
+]
+
+
+def lookup_airport(code: str) -> Optional[Dict[str, Any]]:
+ iata = (code or "").strip().upper()
+ if not iata:
+ return None
+ for airport in MOCK_AIRPORTS:
+ if airport["iata_code"] == iata:
+ return dict(airport)
+ return None
+
+
+def _haystack(airport: Dict[str, Any]) -> str:
+ parts = [
+ airport.get("iata_code", ""),
+ airport.get("icao_code", ""),
+ airport.get("name", ""),
+ airport.get("city_name", ""),
+ *airport.get("keywords", []),
+ ]
+ return " ".join(str(p) for p in parts if p).lower()
+
+
+def search_mock_airports(query: str, limit: int = 10) -> List[Dict[str, Any]]:
+ """Ranked substring search. Empty / unmatched queries return [] (TS parity)."""
+ q = (query or "").strip().lower()
+ if len(q) < 2:
+ return []
+
+ scored: List[tuple[int, Dict[str, Any]]] = []
+ for airport in MOCK_AIRPORTS:
+ iata = str(airport.get("iata_code", "")).lower()
+ icao = str(airport.get("icao_code", "")).lower()
+ city = str(airport.get("city_name", "")).lower()
+ name = str(airport.get("name", "")).lower()
+ keywords = [str(k).lower() for k in airport.get("keywords", [])]
+ if q == iata:
+ score = 0
+ elif q == city or q in keywords:
+ score = 1
+ elif iata.startswith(q) or icao.startswith(q):
+ score = 2
+ elif city.startswith(q):
+ score = 3
+ elif q in _haystack(airport) or any(q in k for k in (name, city, *keywords)):
+ score = 4
+ else:
+ continue
+ scored.append((score, airport))
+
+ scored.sort(key=lambda item: (item[0], item[1]["iata_code"]))
+ return [dict(item[1]) for item in scored[:limit]]
diff --git a/nitrostack/widgets/flight_transforms.py b/nitrostack/widgets/flight_transforms.py
new file mode 100644
index 0000000..2af1cb7
--- /dev/null
+++ b/nitrostack/widgets/flight_transforms.py
@@ -0,0 +1,356 @@
+"""TypeScript flight-tool output transforms, in Python.
+
+Mirrors ``typescript-oauth`` ``flights.tools.ts`` / ``booking.tools.ts`` so
+Python widgets receive the same camelCase ``structuredContent``.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Dict, List, Optional
+
+
+def _as_dict(value: Any) -> dict:
+ return value if isinstance(value, dict) else {}
+
+
+def _as_list(value: Any) -> list:
+ return value if isinstance(value, list) else []
+
+
+def _place_code(place: Any) -> str:
+ if isinstance(place, str):
+ return place
+ place = _as_dict(place)
+ return str(place.get("iata_code") or place.get("iataCode") or place.get("code") or "")
+
+
+def _carrier_name(seg: dict) -> str:
+ carrier = seg.get("marketing_carrier") or seg.get("airline")
+ if isinstance(carrier, str):
+ return carrier
+ carrier = _as_dict(carrier)
+ return str(carrier.get("name") or "")
+
+
+def _carrier_code(seg: dict) -> str:
+ carrier = _as_dict(seg.get("marketing_carrier") or seg.get("airline"))
+ return str(carrier.get("iata_code") or carrier.get("iataCode") or carrier.get("code") or "")
+
+
+def _flight_number(seg: dict) -> str:
+ return str(
+ seg.get("marketing_carrier_flight_number")
+ or seg.get("flightNumber")
+ or _as_dict(seg.get("airline")).get("flightNumber")
+ or ""
+ )
+
+
+def transform_airport_results(query: str, places: Any, limit: int = 10) -> Dict[str, Any]:
+ results = []
+ for place in _as_list(places)[:limit]:
+ place = _as_dict(place)
+ results.append(
+ {
+ "id": place.get("id"),
+ "name": place.get("name"),
+ "iataCode": place.get("iata_code") or place.get("iataCode"),
+ "icaoCode": place.get("icao_code") or place.get("icaoCode"),
+ "cityName": place.get("city_name") or place.get("cityName"),
+ "type": place.get("type") or "airport",
+ "latitude": place.get("latitude"),
+ "longitude": place.get("longitude"),
+ "timeZone": place.get("time_zone") or place.get("timeZone"),
+ }
+ )
+ return {"query": query, "results": results}
+
+
+def _slice_leg(slice_data: Any) -> Optional[Dict[str, Any]]:
+ sl = _as_dict(slice_data)
+ if not sl:
+ return None
+ segs = _as_list(sl.get("segments"))
+ first = _as_dict(segs[0] if segs else {})
+ last = _as_dict(segs[-1] if segs else first)
+ return {
+ "origin": _place_code(sl.get("origin") or first.get("origin")),
+ "destination": _place_code(sl.get("destination") or last.get("destination")),
+ "departureTime": first.get("departing_at") or first.get("departingAt") or sl.get("departureTime"),
+ "arrivalTime": last.get("arriving_at") or last.get("arrivingAt") or sl.get("arrivalTime"),
+ "duration": sl.get("duration"),
+ "stops": max(len(segs) - 1, 0) if segs else 0,
+ "airline": _carrier_name(first) or sl.get("airline"),
+ "flightNumber": _flight_number(first) or sl.get("flightNumber"),
+ "segments": [
+ {
+ "origin": _place_code(seg.get("origin")),
+ "destination": _place_code(seg.get("destination")),
+ "departingAt": seg.get("departing_at") or seg.get("departingAt"),
+ "arrivingAt": seg.get("arriving_at") or seg.get("arrivingAt"),
+ "airline": _carrier_name(seg),
+ "flightNumber": _flight_number(seg),
+ "aircraft": _as_dict(seg.get("aircraft")).get("name") if isinstance(seg.get("aircraft"), dict) else seg.get("aircraft"),
+ }
+ for seg in (_as_dict(s) for s in segs)
+ ],
+ }
+
+
+def transform_flight_search(params: Dict[str, Any], result: Dict[str, Any]) -> Dict[str, Any]:
+ origin = str(params.get("origin") or "").upper()
+ destination = str(params.get("destination") or "").upper()
+ adults = int(params.get("adults") or 1)
+ children = int(params.get("children") or 0)
+ infants = int(params.get("infants") or 0)
+ cabin = params.get("cabinClass") or "economy"
+
+ offers: List[Dict[str, Any]] = []
+ for offer in _as_list(_as_dict(result).get("offers")):
+ offer = _as_dict(offer)
+ slices = _as_list(offer.get("slices"))
+ outbound = _slice_leg(slices[0] if slices else offer.get("outbound"))
+ ret = _slice_leg(slices[1] if len(slices) > 1 else offer.get("return"))
+ conditions = _as_dict(offer.get("conditions"))
+ item: Dict[str, Any] = {
+ "id": offer.get("id"),
+ "totalAmount": offer.get("total_amount") or offer.get("totalAmount"),
+ "totalCurrency": offer.get("total_currency") or offer.get("totalCurrency"),
+ "expiresAt": offer.get("expires_at") or offer.get("expiresAt"),
+ "outbound": outbound,
+ "fareType": "International" if offer.get("passenger_identity_documents_required") else "Domestic",
+ "refundable": bool(_as_dict(conditions.get("refund_before_departure")).get("allowed")),
+ "changeable": bool(_as_dict(conditions.get("change_before_departure")).get("allowed")),
+ }
+ if ret:
+ item["return"] = ret
+ offers.append(item)
+
+ return {
+ "requestId": _as_dict(result).get("id") or _as_dict(result).get("requestId"),
+ "searchParams": {
+ "origin": origin,
+ "destination": destination,
+ "departureDate": params.get("departureDate"),
+ "returnDate": params.get("returnDate"),
+ "passengers": {"adults": adults, "children": children, "infants": infants},
+ "cabinClass": cabin,
+ },
+ "totalOffers": len(offers),
+ "offers": offers[:10],
+ "message": f"Found {len(offers)} flight options. Showing top 10 results.",
+ }
+
+
+def transform_flight_details(offer: Dict[str, Any]) -> Dict[str, Any]:
+ offer = _as_dict(offer)
+ conditions = _as_dict(offer.get("conditions"))
+ refund = _as_dict(conditions.get("refund_before_departure"))
+ change = _as_dict(conditions.get("change_before_departure"))
+ payment = _as_dict(offer.get("payment_requirements"))
+ slices = []
+ for sl in _as_list(offer.get("slices")):
+ sl = _as_dict(sl)
+ slices.append(
+ {
+ "origin": {
+ "code": _place_code(sl.get("origin")),
+ "name": _as_dict(sl.get("origin")).get("name"),
+ "city": _as_dict(sl.get("origin")).get("city_name") or _as_dict(sl.get("origin")).get("city"),
+ },
+ "destination": {
+ "code": _place_code(sl.get("destination")),
+ "name": _as_dict(sl.get("destination")).get("name"),
+ "city": _as_dict(sl.get("destination")).get("city_name") or _as_dict(sl.get("destination")).get("city"),
+ },
+ "duration": sl.get("duration"),
+ "segments": [
+ {
+ "id": seg.get("id"),
+ "origin": _place_code(seg.get("origin")),
+ "destination": _place_code(seg.get("destination")),
+ "departingAt": seg.get("departing_at") or seg.get("departingAt"),
+ "arrivingAt": seg.get("arriving_at") or seg.get("arrivingAt"),
+ "duration": seg.get("duration"),
+ "airline": {
+ "name": _carrier_name(seg),
+ "code": _carrier_code(seg),
+ "flightNumber": _flight_number(seg),
+ },
+ "aircraft": _as_dict(seg.get("aircraft")).get("name") if isinstance(seg.get("aircraft"), dict) else seg.get("aircraft"),
+ "operatingCarrier": _as_dict(seg.get("operating_carrier")).get("name"),
+ "distance": seg.get("distance"),
+ }
+ for seg in (_as_dict(s) for s in _as_list(sl.get("segments")))
+ ],
+ }
+ )
+ return {
+ "id": offer.get("id"),
+ "totalAmount": offer.get("total_amount") or offer.get("totalAmount"),
+ "totalCurrency": offer.get("total_currency") or offer.get("totalCurrency"),
+ "expiresAt": offer.get("expires_at") or offer.get("expiresAt"),
+ "slices": slices,
+ "passengers": [
+ {
+ "id": pax.get("id"),
+ "type": pax.get("type"),
+ "fareType": pax.get("fare_type") or pax.get("fareType"),
+ "baggageAllowance": [
+ {"type": bag.get("type"), "quantity": bag.get("quantity")}
+ for bag in _as_list(pax.get("baggages") or pax.get("baggageAllowance"))
+ ],
+ }
+ for pax in (_as_dict(p) for p in _as_list(offer.get("passengers")))
+ ],
+ "conditions": {
+ "refundBeforeDeparture": {
+ "allowed": bool(refund.get("allowed")),
+ "penaltyAmount": refund.get("penalty_amount") or refund.get("penaltyAmount"),
+ "penaltyCurrency": refund.get("penalty_currency") or refund.get("penaltyCurrency"),
+ },
+ "changeBeforeDeparture": {
+ "allowed": bool(change.get("allowed")),
+ "penaltyAmount": change.get("penalty_amount") or change.get("penaltyAmount"),
+ "penaltyCurrency": change.get("penalty_currency") or change.get("penaltyCurrency"),
+ },
+ },
+ "paymentRequirements": {
+ "requiresInstantPayment": payment.get("requires_instant_payment"),
+ "priceGuaranteeExpiresAt": payment.get("price_guarantee_expires_at"),
+ "paymentRequiredBy": payment.get("payment_required_by"),
+ },
+ }
+
+
+def transform_create_order(order: Dict[str, Any]) -> Dict[str, Any]:
+ order = _as_dict(order)
+ slices = []
+ for sl in _as_list(order.get("slices")):
+ sl = _as_dict(sl)
+ segs = _as_list(sl.get("segments"))
+ first = _as_dict(segs[0] if segs else {})
+ last = _as_dict(segs[-1] if segs else first)
+ slices.append(
+ {
+ "origin": _place_code(sl.get("origin")),
+ "destination": _place_code(sl.get("destination")),
+ "departureTime": first.get("departing_at") or first.get("departingAt") or sl.get("departureTime"),
+ "arrivalTime": last.get("arriving_at") or last.get("arrivingAt") or sl.get("arrivalTime"),
+ }
+ )
+ return {
+ "orderId": order.get("id") or order.get("orderId"),
+ "status": order.get("status") or "held",
+ "totalAmount": order.get("total_amount") or order.get("totalAmount"),
+ "totalCurrency": order.get("total_currency") or order.get("totalCurrency"),
+ "expiresAt": order.get("expires_at") or order.get("expiresAt"),
+ "bookingReference": order.get("booking_reference") or order.get("bookingReference"),
+ "passengers": [
+ {
+ "id": pax.get("id"),
+ "name": pax.get("name")
+ or " ".join(x for x in (pax.get("given_name") or pax.get("givenName"), pax.get("family_name") or pax.get("familyName")) if x),
+ "type": pax.get("type"),
+ }
+ for pax in (_as_dict(p) for p in _as_list(order.get("passengers")))
+ ],
+ "slices": slices,
+ "message": "Order created and held successfully.",
+ }
+
+
+def transform_order_details(order: Dict[str, Any]) -> Dict[str, Any]:
+ order = _as_dict(order)
+ details = transform_flight_details(order)
+ details.pop("conditions", None)
+ details.pop("paymentRequirements", None)
+ return {
+ "orderId": order.get("id") or order.get("orderId"),
+ "status": order.get("status") or "confirmed",
+ "bookingReference": order.get("booking_reference") or order.get("bookingReference"),
+ "totalAmount": details.get("totalAmount"),
+ "totalCurrency": details.get("totalCurrency"),
+ "createdAt": order.get("created_at") or order.get("createdAt"),
+ "expiresAt": order.get("expires_at") or order.get("expiresAt"),
+ "passengers": [
+ {
+ "id": pax.get("id"),
+ "name": pax.get("name")
+ or " ".join(x for x in (pax.get("given_name") or pax.get("givenName"), pax.get("family_name") or pax.get("familyName")) if x),
+ "type": pax.get("type"),
+ "email": pax.get("email"),
+ "phoneNumber": pax.get("phone_number") or pax.get("phoneNumber"),
+ }
+ for pax in (_as_dict(p) for p in _as_list(order.get("passengers")))
+ ],
+ "slices": details.get("slices") or [],
+ }
+
+
+def transform_seat_map(offer_id: str, seat_maps: Any) -> Dict[str, Any]:
+ cabins = []
+ for cabin in _as_list(seat_maps):
+ cabin = _as_dict(cabin)
+ rows = []
+ for row in _as_list(cabin.get("rows")):
+ row = _as_dict(row)
+ seats = []
+ raw_seats = _as_list(row.get("seats"))
+ if not raw_seats:
+ for section in _as_list(row.get("sections")):
+ raw_seats.extend(_as_list(_as_dict(section).get("elements")))
+ for seat in raw_seats:
+ seat = _as_dict(seat)
+ kind = seat.get("type")
+ if kind and kind not in ("seat", "window", "middle", "aisle", "standard"):
+ if kind != "seat" and seat.get("designator") is None and seat.get("column") is None:
+ continue
+ services = _as_list(seat.get("available_services"))
+ first = _as_dict(services[0] if services else {})
+ available = seat.get("available")
+ if available is None:
+ available = bool(services)
+ seats.append(
+ {
+ "id": seat.get("id"),
+ "column": seat.get("designator") or seat.get("column"),
+ "available": bool(available),
+ "price": first.get("total_amount") or seat.get("price"),
+ "currency": first.get("total_currency") or seat.get("currency"),
+ "type": ", ".join(_as_list(seat.get("disclosures"))) or seat.get("type") or "standard",
+ }
+ )
+ rows.append({"rowNumber": row.get("row_number") or row.get("rowNumber"), "seats": seats})
+ cabins.append({"cabinClass": cabin.get("cabin_class") or cabin.get("cabinClass"), "rows": rows})
+ return {
+ "offerId": offer_id,
+ "cabins": cabins,
+ "message": "Select your preferred seats from the available options",
+ }
+
+
+def transform_cancel_order(order_id: str, cancellation: Dict[str, Any]) -> Dict[str, Any]:
+ cancellation = _as_dict(cancellation)
+ refund = cancellation.get("refund_amount") or cancellation.get("refundAmount")
+ currency = cancellation.get("refund_currency") or cancellation.get("refundCurrency")
+ return {
+ "orderId": order_id,
+ "cancellationId": cancellation.get("id") or cancellation.get("cancellationId"),
+ "status": "cancelled",
+ "refundAmount": refund,
+ "refundCurrency": currency,
+ "confirmedAt": cancellation.get("confirmed_at") or cancellation.get("confirmedAt"),
+ "message": (
+ f"Order cancelled. Refund of {currency} {refund} will be processed."
+ if refund
+ else "Order cancelled. No refund available for this booking."
+ ),
+ }
+
+
+def build_passengers(adults: int = 1, children: int = 0, infants: int = 0) -> List[Dict[str, Any]]:
+ passengers: List[Dict[str, Any]] = [{"type": "adult"} for _ in range(max(adults, 1))]
+ passengers.extend({"type": "child", "age": 12} for _ in range(max(children, 0)))
+ passengers.extend({"type": "infant_without_seat"} for _ in range(max(infants, 0)))
+ return passengers
diff --git a/nitrostack/widgets/host_bridge.py b/nitrostack/widgets/host_bridge.py
new file mode 100644
index 0000000..200306b
--- /dev/null
+++ b/nitrostack/widgets/host_bridge.py
@@ -0,0 +1,330 @@
+"""Shared MCP Apps / OpenAI host bridge for static widget HTML (Python-owned)."""
+
+from __future__ import annotations
+
+from typing import Any
+
+# Injected into every widget page before route-specific render logic.
+HOST_BRIDGE_JS = """
+window.openai = window.openai || {};
+var __nitrostack_rpcId = 1;
+var __nitrostack_initId = null;
+var __nitrostack_pending = {};
+function __nitrostack_postToHost(msg) {
+ if (window.parent && window.parent !== window) {
+ window.parent.postMessage(msg, "*");
+ }
+}
+function __nitrostack_rpc(method, params) {
+ var id = __nitrostack_rpcId++;
+ return new Promise(function(resolve, reject) {
+ __nitrostack_pending[id] = { resolve: resolve, reject: reject, method: method };
+ __nitrostack_postToHost({ jsonrpc: "2.0", id: id, method: method, params: params || {} });
+ setTimeout(function() {
+ if (__nitrostack_pending[id]) {
+ delete __nitrostack_pending[id];
+ reject(new Error(method + " timed out"));
+ }
+ }, 20000);
+ });
+}
+function __nitrostack_applyTheme(ctx) {
+ ctx = ctx || {};
+ var theme = ctx.theme || (window.openai && window.openai.theme) || "";
+ if (theme) {
+ document.documentElement.setAttribute("data-theme", theme);
+ document.documentElement.style.colorScheme = theme;
+ if (document.body) document.body.setAttribute("data-theme", theme);
+ }
+ var styles = ctx.styles || {};
+ var vars = styles.variables || styles;
+ if (vars && typeof vars === "object") {
+ Object.keys(vars).forEach(function(key) {
+ if (key.indexOf("--") === 0 && vars[key] != null) {
+ document.documentElement.style.setProperty(key, String(vars[key]));
+ }
+ });
+ }
+}
+function __nitrostack_callTool(name, args) {
+ args = args || {};
+ if (window.openai && typeof window.openai.callTool === "function") {
+ return window.openai.callTool(name, args);
+ }
+ return __nitrostack_rpc("tools/call", { name: name, arguments: args }).then(function(result) {
+ if (result) __nitrostack_acceptToolResult(result);
+ return result;
+ });
+}
+function __nitrostack_isSafeUrl(url) {
+ if (!url || typeof url !== "string") return false;
+ return /^(https?|mailto|tel):/i.test(url.trim());
+}
+function __nitrostack_openLink(url) {
+ if (!__nitrostack_isSafeUrl(url)) return;
+ if (window.openai && typeof window.openai.openExternal === "function") {
+ window.openai.openExternal({ href: url });
+ return;
+ }
+ __nitrostack_rpc("ui/open-link", { url: url }).catch(function() {
+ try { window.open(url, "_blank", "noopener"); } catch (e) {}
+ });
+}
+function __nitrostack_requestDisplayMode(mode) {
+ mode = mode || "fullscreen";
+ if (window.openai && typeof window.openai.requestDisplayMode === "function") {
+ return window.openai.requestDisplayMode({ mode: mode });
+ }
+ return __nitrostack_rpc("ui/request-display-mode", { mode: mode });
+}
+function __nitrostack_setWidgetState(state) {
+ window.openai = window.openai || {};
+ window.openai.widgetState = state;
+ if (typeof window.openai.setWidgetState === "function") {
+ window.openai.setWidgetState(state);
+ return;
+ }
+ __nitrostack_rpc("ui/update-model-context", { structuredContent: { widgetState: state } }).catch(function() {});
+}
+function __nitrostack_readEmbeddedData() {
+ const el = document.getElementById("nitrostack-tool-data");
+ if (!el) return null;
+ const raw = (el.textContent || "").trim();
+ if (!raw || raw === "null") return null;
+ try { return JSON.parse(raw); } catch (e) { return null; }
+}
+function __nitrostack_readHostData() {
+ const o = window.openai || {};
+ const out = o.toolOutput || o.toolResult || null;
+ if (!out) return __nitrostack_readEmbeddedData();
+ if (out.structuredContent && typeof out.structuredContent === "object") {
+ return out.structuredContent;
+ }
+ const contents = out.content || out.contents;
+ if (Array.isArray(contents)) {
+ const jsonBlock = contents.find(
+ (c) => c && typeof c === "object" && (c.mimeType === "application/json" || c.type === "json")
+ );
+ if (jsonBlock && jsonBlock.text != null) {
+ try {
+ return typeof jsonBlock.text === "string" ? JSON.parse(jsonBlock.text) : jsonBlock.text;
+ } catch (e) {}
+ }
+ const textBlock = contents.find(
+ (c) => c && typeof c === "object" && (c.mimeType === "text/plain" || c.type === "text")
+ );
+ if (textBlock && typeof textBlock.text === "string") {
+ const t = textBlock.text.trim();
+ if (t.startsWith("{") || t.startsWith("[")) {
+ try { return JSON.parse(t); } catch (e) {}
+ }
+ }
+ }
+ if (out && typeof out === "object" && !Array.isArray(out)) return out;
+ return __nitrostack_readEmbeddedData();
+}
+function __nitrostack_applyWidget(force) {
+ if (typeof window.__nitroWidgetRender !== "function") return;
+ const data = __nitrostack_readHostData();
+ if (data == null) return;
+ const ssr = document.body && document.body.getAttribute("data-nitro-ssr") === "1";
+ const needsClient = document.querySelector("[data-nitro-needs-client]");
+ if (ssr && !force && !needsClient) return;
+ if (!force && needsClient && window.__nitroClientReady) return;
+ window.__nitroWidgetRender(data);
+ if (needsClient) window.__nitroClientReady = true;
+}
+function __nitrostack_acceptToolResult(params) {
+ window.openai = window.openai || {};
+ if (params && params.structuredContent != null) {
+ window.openai.toolOutput = { structuredContent: params.structuredContent, content: params.content };
+ } else if (params && params.result && params.result.structuredContent != null) {
+ window.openai.toolOutput = params.result;
+ } else {
+ window.openai.toolOutput = params;
+ }
+ __nitrostack_applyWidget(true);
+}
+function __nitrostack_startMcpAppsHandshake() {
+ __nitrostack_initId = __nitrostack_rpcId++;
+ __nitrostack_postToHost({
+ jsonrpc: "2.0",
+ id: __nitrostack_initId,
+ method: "ui/initialize",
+ params: {
+ protocolVersion: "2026-01-26",
+ appInfo: { name: "nitrostack-widget", version: "1.0.0" },
+ appCapabilities: {
+ availableDisplayModes: ["inline", "fullscreen", "pip"]
+ }
+ }
+ });
+}
+function __nitrostack_bindChrome() {
+ document.addEventListener("click", function(ev) {
+ var callEl = ev.target.closest("[data-call-tool]");
+ if (callEl) {
+ ev.preventDefault();
+ var name = callEl.getAttribute("data-call-tool");
+ var args = {};
+ try { args = JSON.parse(callEl.getAttribute("data-args") || "{}"); } catch (e) {}
+ callEl.classList.add("is-busy");
+ Promise.resolve(__nitrostack_callTool(name, args)).catch(function() {}).finally(function() {
+ callEl.classList.remove("is-busy");
+ });
+ return;
+ }
+ var linkEl = ev.target.closest("[data-open-link]");
+ if (linkEl) {
+ ev.preventDefault();
+ __nitrostack_openLink(linkEl.getAttribute("data-open-link") || linkEl.getAttribute("href"));
+ return;
+ }
+ var modeEl = ev.target.closest("[data-display-mode]");
+ if (modeEl) {
+ ev.preventDefault();
+ __nitrostack_requestDisplayMode(modeEl.getAttribute("data-display-mode") || "fullscreen");
+ }
+ });
+ document.addEventListener("keydown", function(ev) {
+ if (ev.key !== "Enter" && ev.key !== " ") return;
+ var el = ev.target.closest("[data-call-tool]");
+ if (!el) return;
+ ev.preventDefault();
+ el.click();
+ });
+}
+window.nitrostack = {
+ callTool: __nitrostack_callTool,
+ openLink: __nitrostack_openLink,
+ requestDisplayMode: __nitrostack_requestDisplayMode,
+ setWidgetState: __nitrostack_setWidgetState,
+ readHostData: __nitrostack_readHostData
+};
+function __nitrostack_installHostBridge() {
+ window.addEventListener("openai:set_globals", function(event) {
+ const globals = (event && event.detail && event.detail.globals) || {};
+ window.openai = Object.assign(window.openai || {}, globals);
+ __nitrostack_applyTheme(globals);
+ __nitrostack_applyWidget(true);
+ });
+ window.addEventListener("openai:ready", function() { __nitrostack_applyWidget(true); });
+ window.addEventListener("message", (event) => {
+ const msg = event.data;
+ if (!msg || typeof msg !== "object") return;
+ if (msg.jsonrpc === "2.0" && msg.id != null && __nitrostack_pending[msg.id]) {
+ const pending = __nitrostack_pending[msg.id];
+ delete __nitrostack_pending[msg.id];
+ if (msg.error) pending.reject(msg.error);
+ else pending.resolve(msg.result);
+ }
+ if (msg.jsonrpc === "2.0" && msg.id === __nitrostack_initId && msg.result) {
+ const ctx = msg.result.hostContext || {};
+ window.openai = Object.assign(window.openai || {}, {
+ theme: ctx.theme || window.openai.theme,
+ displayMode: ctx.displayMode || window.openai.displayMode
+ });
+ __nitrostack_applyTheme(ctx);
+ __nitrostack_postToHost({ jsonrpc: "2.0", method: "ui/notifications/initialized" });
+ return;
+ }
+ if (msg.jsonrpc === "2.0" && msg.method === "ui/notifications/host-context-changed") {
+ const ctx = msg.params || {};
+ if (ctx.theme) {
+ window.openai = Object.assign(window.openai || {}, { theme: ctx.theme });
+ }
+ __nitrostack_applyTheme(ctx);
+ return;
+ }
+ if (msg.type === "setGlobals" && msg.globals) {
+ window.openai = Object.assign(window.openai || {}, msg.globals);
+ __nitrostack_applyTheme(msg.globals);
+ __nitrostack_applyWidget(true);
+ return;
+ }
+ if (msg.type === "NITRO_INJECT_OPENAI" && msg.openai) {
+ window.openai = Object.assign(window.openai || {}, msg.openai);
+ __nitrostack_applyTheme(msg.openai);
+ __nitrostack_applyWidget(true);
+ return;
+ }
+ if ((msg.type === "toolOutput" || msg.type === "TOOL_OUTPUT") && msg.data) {
+ window.openai = window.openai || {};
+ window.openai.toolOutput = msg.data;
+ __nitrostack_applyWidget(true);
+ return;
+ }
+ if (msg.jsonrpc === "2.0" && (
+ msg.method === "ui/notifications/tool-result" ||
+ msg.method === "notifications/tool-result"
+ )) {
+ __nitrostack_acceptToolResult(msg.params || {});
+ }
+ if (msg.jsonrpc === "2.0" && msg.method === "ui/notifications/tool-input") {
+ window.openai = window.openai || {};
+ window.openai.toolInput = msg.params;
+ }
+ });
+ __nitrostack_bindChrome();
+ __nitrostack_startMcpAppsHandshake();
+ if (window.openai && window.openai.theme) __nitrostack_applyTheme(window.openai);
+ __nitrostack_applyWidget(false);
+ let n = 0;
+ const poll = setInterval(() => {
+ __nitrostack_applyWidget(false);
+ if (++n > 40) clearInterval(poll);
+ }, 250);
+}
+""".strip()
+
+
+def wrap_widget_page(
+ *,
+ title: str,
+ styles: str,
+ body: str,
+ render_js: str,
+ data: Any | None = None,
+ extra_head: str = "",
+ flush: bool = False,
+ chrome: bool = True,
+) -> str:
+ """Build a self-contained widget HTML document with the shared host bridge."""
+ from nitrostack.widgets.html_util import esc, json_script
+ from nitrostack.widgets.ui import SHARED_CSS
+
+ ssr_attr = ' data-nitro-ssr="1"' if data is not None else ""
+ body_class = ' class="ns-flush"' if flush else ""
+ head_extra = f"\n{extra_head}" if extra_head else ""
+ chrome_html = ""
+ if chrome:
+ chrome_html = (
+ '
'
+ ''
+ "
"
+ )
+ return f"""
+
+
+
+
+
+ {esc(title)}
+ {head_extra}
+
+
+ {json_script(data)}
+ {chrome_html}
+{body}
+
+
+
+"""
diff --git a/nitrostack/widgets/html_util.py b/nitrostack/widgets/html_util.py
new file mode 100644
index 0000000..e3ad48b
--- /dev/null
+++ b/nitrostack/widgets/html_util.py
@@ -0,0 +1,171 @@
+"""HTML helpers for Python-rendered widgets (no React/TS)."""
+
+from __future__ import annotations
+
+import html
+import json
+import os
+from typing import Any, Optional
+
+def get_mapbox_token() -> str:
+ """Mapbox public token from ``MAPBOX_TOKEN`` or ``NEXT_PUBLIC_MAPBOX_TOKEN``.
+
+ Never ships a baked-in key — GitHub push protection treats ``pk.eyJ…`` as a secret.
+ """
+ raw = (
+ os.environ.get("MAPBOX_TOKEN")
+ or os.environ.get("NEXT_PUBLIC_MAPBOX_TOKEN")
+ or ""
+ ).strip()
+ if not raw or raw.startswith("pk.your_") or raw in {"YOUR_MAPBOX_TOKEN", "changeme", "placeholder"}:
+ return ""
+ return raw
+
+
+def inject_mapbox_token(html_doc: str) -> str:
+ """Fill ``window.__NITRO_MAPBOX_TOKEN`` from env at serve time.
+
+ ``widgets/out/pizza-map.html`` is committed with an empty token so git does
+ not store a ``pk.eyJ`` secret. Studio ``resources/read`` uses that file via
+ ``get_bundle()``; without this rewrite the live map stays blank.
+ """
+ if "window.__NITRO_MAPBOX_TOKEN" not in html_doc:
+ return html_doc
+ assignment = f"window.__NITRO_MAPBOX_TOKEN = {json.dumps(get_mapbox_token())};"
+ marker = "window.__NITRO_MAPBOX_TOKEN ="
+ start = html_doc.find(marker)
+ if start < 0:
+ return html_doc
+ end = html_doc.find(";", start)
+ if end < 0:
+ return html_doc
+ return html_doc[:start] + assignment + html_doc[end + 1 :]
+
+
+def mapbox_static_url(shops: Any, width: int = 800, height: int = 520) -> str:
+ """Python first-paint map (no React). Pins match filtered shops."""
+ from urllib.parse import quote
+
+ token = get_mapbox_token()
+ if not token:
+ return ""
+
+ pins = []
+ for shop in shops or []:
+ if not isinstance(shop, dict):
+ continue
+ coords = shop.get("coords")
+ if not (isinstance(coords, (list, tuple)) and len(coords) == 2):
+ continue
+ try:
+ lon = float(coords[0])
+ lat = float(coords[1])
+ except (TypeError, ValueError):
+ continue
+ pins.append(f"pin-s+ea580c({lon},{lat})")
+ if len(pins) >= 50:
+ break
+ overlay = ",".join(pins) if pins else "pin-s+ea580c(-122.4194,37.7749)"
+ token = quote(token, safe="")
+ return (
+ f"https://api.mapbox.com/styles/v1/mapbox/streets-v12/static/{overlay}/auto/"
+ f"{width}x{height}@2x?access_token={token}"
+ )
+
+
+def esc(value: Any) -> str:
+ if value is None:
+ return ""
+ return html.escape(str(value), quote=True)
+
+
+def json_for_inline_script(data: Any) -> str:
+ """JSON safe to embed in a ``"
+ )
+
+
+def inject_tool_data(html_doc: str, data: Any) -> str:
+ """Embed tool JSON in an existing widget document so first paint matches tools/call."""
+ marker = json_script(data)
+ if 'id="nitrostack-tool-data"' in html_doc:
+ start = html_doc.find('", start)
+ if end >= 0:
+ return html_doc[:start] + marker + html_doc[end + len("") :]
+ if "" in html_doc:
+ return html_doc.replace("", "\n " + marker, 1)
+ return marker + html_doc
+
+
+def as_dict(value: Any) -> dict:
+ return value if isinstance(value, dict) else {}
+
+
+def as_list(value: Any) -> list:
+ return value if isinstance(value, list) else []
+
+
+def pick(obj: Any, *keys: str, default: Any = None) -> Any:
+ if not isinstance(obj, dict):
+ return default
+ for key in keys:
+ if obj.get(key) not in (None, ""):
+ return obj[key]
+ return default
+
+
+def place_code(place: Any) -> str:
+ if isinstance(place, str):
+ return place
+ return str(pick(place, "iata_code", "iataCode", "code", "id", default="") or "")
+
+
+def place_name(place: Any) -> str:
+ if isinstance(place, str):
+ return place
+ return str(
+ pick(place, "name", "city_name", "cityName", "city", "iata_code", "iataCode", default="") or ""
+ )
+
+
+def money(amount: Any, currency: Any = None) -> str:
+ if amount in (None, ""):
+ return ""
+ cur = f" {currency}" if currency else ""
+ return f"{amount}{cur}"
+
+
+def format_duration(value: Any) -> str:
+ text = str(value or "")
+ if not text.startswith("PT"):
+ return text
+ hours = minutes = 0
+ rest = text[2:]
+ if "H" in rest:
+ hours_s, rest = rest.split("H", 1)
+ try:
+ hours = int(hours_s or 0)
+ except ValueError:
+ hours = 0
+ if "M" in rest:
+ minutes_s = rest.split("M", 1)[0]
+ try:
+ minutes = int(minutes_s or 0)
+ except ValueError:
+ minutes = 0
+ if hours and minutes:
+ return f"{hours}h {minutes}m"
+ if hours:
+ return f"{hours}h"
+ if minutes:
+ return f"{minutes}m"
+ return text
diff --git a/nitrostack/widgets/mcp_meta.py b/nitrostack/widgets/mcp_meta.py
new file mode 100644
index 0000000..72e94cd
--- /dev/null
+++ b/nitrostack/widgets/mcp_meta.py
@@ -0,0 +1,136 @@
+"""Build mode-gated widget ``_meta`` for tools and resources."""
+
+from __future__ import annotations
+
+from typing import Any, Dict, Literal, Optional
+
+from nitrostack.core.app_mode import is_mcp_app_mode, is_openai_mode
+from nitrostack.widgets.component import Component, WidgetCsp
+
+Visibility = Literal["visible", "hidden"]
+
+
+def widget_csp_to_ui_csp(csp: Optional[WidgetCsp]) -> Optional[Dict[str, Any]]:
+ if csp is None:
+ return None
+ out: Dict[str, Any] = {}
+ if csp.connect_domains:
+ out["connectDomains"] = list(csp.connect_domains)
+ if csp.resource_domains:
+ out["resourceDomains"] = list(csp.resource_domains)
+ if csp.frame_domains:
+ out["frameDomains"] = list(csp.frame_domains)
+ return out or None
+
+
+def openai_widget_csp(csp: Optional[WidgetCsp]) -> Optional[Dict[str, Any]]:
+ if csp is None:
+ return None
+ out: Dict[str, Any] = {}
+ if csp.connect_domains:
+ out["connect_domains"] = list(csp.connect_domains)
+ if csp.resource_domains:
+ out["resource_domains"] = list(csp.resource_domains)
+ if csp.frame_domains:
+ out["frame_domains"] = list(csp.frame_domains)
+ return out or None
+
+
+def _ui_block_from_component(component: Component) -> Dict[str, Any]:
+ ui: Dict[str, Any] = {}
+ csp = widget_csp_to_ui_csp(component.csp)
+ if csp:
+ ui["csp"] = csp
+ if component.prefers_border:
+ ui["prefersBorder"] = True
+ if component.domain:
+ ui["domain"] = component.domain
+ return ui
+
+
+def merge_tool_ui_meta(
+ resource_uri: str,
+ component: Component,
+ visibility: Optional[Visibility] = None,
+) -> Dict[str, Any]:
+ ui: Dict[str, Any] = {"resourceUri": resource_uri}
+ ui.update(_ui_block_from_component(component))
+ if visibility:
+ ui["visibility"] = visibility
+ return ui
+
+
+def build_tool_list_meta(
+ component: Component,
+ visibility: Visibility,
+ invocation: Optional[Any] = None,
+) -> Dict[str, Any]:
+ """Full ``_meta`` for ``tools/list``, gated by ``NITROSTACK_APP_MODE``."""
+ resource_uri = component.resource_uri
+ openai_meta = component.get_openai_resource_metadata()
+ meta: Dict[str, Any] = {}
+
+ meta["ui/template"] = resource_uri
+
+ if is_openai_mode():
+ meta["openai/outputTemplate"] = resource_uri
+ meta.update(openai_meta)
+ if component.can_invoke_tools and "openai/widgetAccessible" not in meta:
+ meta["openai/widgetAccessible"] = True
+ if invocation:
+ if getattr(invocation, "invoking", None):
+ meta["openai/toolInvocation/invoking"] = invocation.invoking
+ if getattr(invocation, "invoked", None):
+ meta["openai/toolInvocation/invoked"] = invocation.invoked
+
+ if is_mcp_app_mode():
+ meta["ui"] = merge_tool_ui_meta(resource_uri, component, visibility)
+
+ return meta
+
+
+def build_call_tool_result_meta(
+ component: Component,
+ extra: Optional[Dict[str, Any]] = None,
+) -> Dict[str, Any]:
+ """Result ``_meta`` for widget ``tools/call`` (direct and task paths)."""
+ meta: Dict[str, Any] = dict(extra or {})
+ resource_uri = component.resource_uri
+
+ if is_mcp_app_mode():
+ ui = meta.get("ui")
+ if not isinstance(ui, dict):
+ ui = {}
+ if "resourceUri" not in ui:
+ ui["resourceUri"] = resource_uri
+ meta["ui"] = ui
+
+ if is_openai_mode():
+ if "openai/outputTemplate" not in meta:
+ meta["openai/outputTemplate"] = resource_uri
+
+ return meta
+
+
+def resource_read_contents_meta(component: Component) -> Optional[Dict[str, Any]]:
+ """``contents[]._meta`` for ``resources/read`` of a widget."""
+ openai_meta = component.get_openai_resource_metadata()
+ out: Dict[str, Any] = {}
+
+ if is_mcp_app_mode():
+ ui_block = _ui_block_from_component(component)
+ if ui_block:
+ out["ui"] = ui_block
+
+ if is_openai_mode():
+ for key in (
+ "openai/widgetAccessible",
+ "openai/widgetCSP",
+ "openai/widgetDescription",
+ "openai/widgetPrefersBorder",
+ "openai/widgetDomain",
+ ):
+ if key in openai_meta:
+ out[key] = openai_meta[key]
+
+ return out or None
diff --git a/nitrostack/widgets/preview_page.py b/nitrostack/widgets/preview_page.py
new file mode 100644
index 0000000..007f428
--- /dev/null
+++ b/nitrostack/widgets/preview_page.py
@@ -0,0 +1,174 @@
+"""Live widget preview served by the MCP HTTP transport (Inspector cannot inject data)."""
+
+from __future__ import annotations
+
+PREVIEW_PAGE_HTML = """
+
+
+
+
+ Widget preview
+
+
+
+
Widget preview
+
+ MCP Inspector Tools tab shows JSON. Open the Apps tab (or this
+ page) to render the widget from the livetools/call result —
+ not sample data. Use NITROSTACK_APP_MODE=universal (the SDK default).
+ For open shops only, keep openNow: true (or map filter: open_now).
+ This page is /widgets/preview — not the static widgets/preview.html file.
+
'
+ ''
+ )
+
+
+def _pizza_map_card(shop: dict) -> str:
+ name = shop.get("name") or shop.get("id") or "Shop"
+ shop_id = shop.get("id") or ""
+ rating = f"★ {esc(shop.get('rating'))}" if shop.get("rating") is not None else ""
+ return (
+ f''
+ f'
{esc(name)}
'
+ f'
{esc(shop.get("address"))}
'
+ f'
{rating}
'
+ )
+
+
+def pizza_map_body(data: Any | None) -> str:
+ payload = as_dict(data)
+ shops = as_list(payload.get("shops"))
+ static = ""
+ if data is None or not shops:
+ meta = "Waiting for show_pizza_map result."
+ cards = ""
+ else:
+ filt = payload.get("filter") or "all"
+ total = payload.get("totalShops")
+ if total is None:
+ total = len(shops)
+ meta = f"{total} shops · filter: {filt}"
+ cards = "".join(_pizza_map_card(as_dict(shop)) for shop in shops)
+ static_src = mapbox_static_url(shops)
+ static = (
+ f''
+ if static_src
+ else ""
+ )
+ return (
+ f'
{esc(meta)}
'
+ f'
'
+ f'{static}
'
+ f'
{cards}
'
+ ''
+ )
+
+
+def pizza_shop_body(data: Any | None) -> str:
+ payload = as_dict(data)
+ shop = as_dict(payload.get("shop") or payload if data is not None else {})
+ if data is None or not shop.get("name"):
+ return (
+ '
'
+ ''
+ '
Pizza shop
'
+ ''
+ '
Waiting for show_pizza_shop result.
'
+ ''
+ ''
+ ''
+ ''
+ ''
+ "
"
+ ''
+ )
+ img = ""
+ if shop.get("image"):
+ img = f''
+ else:
+ img = ''
+ rating = ""
+ if shop.get("rating") is not None:
+ rating = f"★ {esc(shop.get('rating'))} ({esc(shop.get('reviews') or 0)} reviews)"
+ hours = as_dict(shop.get("hours"))
+ hours_text = ""
+ if hours.get("open") or hours.get("close"):
+ hours_text = f"Hours: {esc(hours.get('open'))} – {esc(hours.get('close'))}"
+ chips = "".join(
+ f'{esc(item)}' for item in as_list(shop.get("specialties"))
+ )
+ actions = action_row(maps=maps_url(shop), phone=str(shop.get("phone") or ""), website=str(shop.get("website") or ""))
+ return (
+ '
'
+ f"{img}"
+ f'
{esc(shop.get("name"))}
'
+ f'
{rating}
'
+ f'
{esc(shop.get("description"))}
'
+ f'
{esc(shop.get("address"))}
'
+ f'
{hours_text}
'
+ f'
{esc(shop.get("phone"))}
'
+ f'
{chips}
'
+ f"{actions}"
+ "
"
+ ''
+ )
+
+
+def calculator_result_body(data: Any | None) -> str:
+ payload = as_dict(data)
+ expr = payload.get("expression") or "—"
+ result = payload.get("result")
+ if result is None:
+ result = payload.get("value")
+ result_text = "—" if result is None else str(result)
+ return (
+ '
'
+ f'
{esc(expr)}
'
+ f'
{esc(result_text)}
'
+ "
"
+ ''
+ ''
+ )
+
+
+def card_body(data: Any | None) -> str:
+ payload = as_dict(data)
+ name = payload.get("name") or "—"
+ price = payload.get("price")
+ price_text = f"${float(price):.2f}" if isinstance(price, (int, float)) else (str(price) if price else "—")
+ desc = payload.get("description") or ""
+ return (
+ '
'
+ f'
{esc(name)}
'
+ f'
{esc(price_text)}
'
+ f'
{esc(desc)}
'
+ "
"
+ )
+
+
+def table_body(data: Any | None) -> str:
+ payload = as_dict(data)
+ rows = as_list(payload.get("rows"))
+ cols = as_list(payload.get("columns"))
+ if not cols and rows:
+ cols = list(as_dict(rows[0]).keys())
+ header = "".join(f"
{esc(c)}
" for c in cols)
+ body_rows = []
+ for row in rows:
+ row = as_dict(row)
+ cells = "".join(f"
{esc(row.get(c, ''))}
" for c in cols)
+ body_rows.append(f"
{cells}
")
+ return (
+ "
"
+ f'
{header}
'
+ f'{"".join(body_rows)}'
+ "
"
+ )
+
+
+def chart_body(data: Any | None) -> str:
+ payload = as_dict(data)
+ title = payload.get("title") or "Chart"
+ items = as_list(payload.get("items"))
+ values = [_safe_float(as_dict(i).get("value"), 0.0) for i in items]
+ max_v = max(values or [1], default=1) or 1
+ bars = []
+ for item, value in zip(items, values):
+ item = as_dict(item)
+ height = (value / max_v) * 140
+ bars.append(
+ '
'
+ f''
+ f'
{esc(item.get("label"))}
'
+ "
"
+ )
+ return f'
{esc(title)}
{"".join(bars)}
'
+
+
+def _offer_slice_html(sl: dict) -> str:
+ origin = place_code(sl.get("origin"))
+ dest = place_code(sl.get("destination"))
+ duration = format_duration(sl.get("duration"))
+ segs = as_list(sl.get("segments"))
+ first = as_dict(segs[0] if segs else {})
+ last = as_dict(segs[-1] if segs else first)
+ dep = pick(first, "departing_at", "departingAt", "departureTime", default="") or pick(
+ sl, "departing_at", "departingAt", "departureTime", default=""
+ )
+ arr = pick(last, "arriving_at", "arrivingAt", "arrivalTime", default="") or pick(
+ sl, "arriving_at", "arrivingAt", "arrivalTime", default=""
+ )
+ carrier = first.get("marketing_carrier") or first.get("airline") or sl.get("airline")
+ if isinstance(carrier, str):
+ airline = carrier
+ flight_no = pick(first, "marketing_carrier_flight_number", "flightNumber", default="") or pick(
+ sl, "flightNumber", "marketing_carrier_flight_number", default=""
+ )
+ else:
+ carrier = as_dict(carrier)
+ airline = pick(carrier, "name", default="") or pick(first, "airline", default="") or pick(sl, "airline", default="")
+ flight_no = pick(carrier, "flight_number", "flightNumber", default="") or pick(
+ first, "marketing_carrier_flight_number", "flightNumber", default=""
+ ) or pick(sl, "flightNumber", default="")
+ return (
+ f'
{esc(origin)} → {esc(dest)}'
+ f'
{esc(dep)} – {esc(arr)} · {esc(duration)}
'
+ f'
{esc(airline)} {esc(flight_no)}
'
+ )
+
+
+def _offer_itinerary_html(offer: dict) -> str:
+ slices = as_list(offer.get("slices"))
+ if slices:
+ return "".join(_offer_slice_html(as_dict(s)) for s in slices)
+ parts = []
+ outbound = offer.get("outbound")
+ if outbound:
+ parts.append(_offer_slice_html(as_dict(outbound)))
+ ret = offer.get("return")
+ if ret:
+ parts.append(_offer_slice_html(as_dict(ret)))
+ return "".join(parts) or _offer_slice_html(offer)
+
+
+def flight_search_results_body(data: Any | None) -> str:
+ payload = as_dict(data)
+ offers = as_list(payload.get("offers") or payload.get("results"))
+ if data is None:
+ return (
+ "
Flight search
"
+ '
Waiting for search_flights result.
'
+ ''
+ )
+ params = as_dict(payload.get("searchParams"))
+ total = payload.get("totalOffers")
+ if total is None:
+ total = len(offers)
+ if params.get("origin") and params.get("destination"):
+ meta = f"{params.get('origin')} → {params.get('destination')} · {total} offer" + ("" if total == 1 else "s")
+ else:
+ meta = f"{total} offer" + ("" if total == 1 else "s")
+ cards = []
+ for offer in offers:
+ offer = as_dict(offer)
+ amount = pick(offer, "total_amount", "totalAmount", default="")
+ currency = pick(offer, "total_currency", "totalCurrency", default="")
+ slice_html = _offer_itinerary_html(offer)
+ offer_id = offer.get("id") or ""
+ cards.append(
+ f''
+ f'
'
+ )
+
+
+def airport_search_body(data: Any | None) -> str:
+ payload = as_dict(data)
+ results = as_list(payload.get("results") or payload.get("airports") or payload.get("places"))
+ query = payload.get("query") or ""
+ if data is None:
+ return (
+ "
Airport search
"
+ '
Waiting for search_airports result.
'
+ ''
+ )
+ rows = []
+ for item in results:
+ item = as_dict(item)
+ code = pick(item, "iata_code", "iataCode", default="")
+ name = pick(item, "name", default="")
+ city = pick(item, "city_name", "cityName", "city", default="")
+ kind = pick(item, "type", default="airport")
+ rows.append(
+ f'
{esc(code)}'
+ f'
{esc(name)}
'
+ f'
{esc(city)} · {esc(kind)}
'
+ )
+ if not rows:
+ rows.append('
No airports found.
')
+ return (
+ "
Airport search
"
+ f'
Query: {esc(query)} · {len(results)} result(s)
'
+ f'
{"".join(rows)}
'
+ )
+
+
+def order_summary_body(data: Any | None) -> str:
+ payload = as_dict(data)
+ if data is None or not payload:
+ return (
+ "
Order summary
"
+ '
Waiting for order result.
'
+ ''
+ )
+ status = str(pick(payload, "status", default="held"))
+ ref = pick(payload, "booking_reference", "bookingReference", default="")
+ amount = pick(payload, "total_amount", "totalAmount", default="")
+ currency = pick(payload, "total_currency", "totalCurrency", default="")
+ passengers = as_list(payload.get("passengers"))
+ pax = []
+ for p in passengers:
+ p = as_dict(p)
+ name = pick(p, "name", default="") or " ".join(
+ str(x) for x in (p.get("given_name") or p.get("givenName"), p.get("family_name") or p.get("familyName")) if x
+ )
+ pax.append(f'
{esc(name or p.get("id"))}
')
+ slices = "".join(_offer_slice_html(as_dict(s)) for s in as_list(payload.get("slices")))
+ order_id = payload.get("id") or payload.get("orderId") or ""
+ cancel = ""
+ if order_id and status.lower() not in {"cancelled", "canceled"}:
+ cancel = (
+ f''
+ )
+ return (
+ '
'
+ f'
Order {esc(status)}
'
+ f'
Ref {esc(ref)} · {esc(order_id)}
'
+ f'
{esc(money(amount, currency))}
'
+ f'
Passengers
{"".join(pax) or "
None listed
"}'
+ f'
Itinerary
{slices}'
+ f"{cancel}"
+ "
"
+ ''
+ )
+
+
+def _seat_cells(cabin: dict) -> str:
+ rows_html = []
+ rows = as_list(cabin.get("rows"))
+ if rows:
+ for row in rows:
+ row = as_dict(row)
+ seats = as_list(row.get("seats") or row.get("elements"))
+ if not seats:
+ for section in as_list(row.get("sections")):
+ seats.extend(as_list(as_dict(section).get("elements")))
+ cells = []
+ for seat in seats:
+ seat = as_dict(seat)
+ if seat.get("type") and seat.get("type") != "seat":
+ continue
+ designator = pick(seat, "designator", "id", "column", default="")
+ available = seat.get("available")
+ if available is None:
+ available = bool(seat.get("available_services") is not None)
+ cls = "seat ok" if available else "seat no"
+ cells.append(f'{esc(designator)}')
+ rows_html.append(
+ f'
{esc(row.get("row_number") or row.get("rowNumber") or "")}'
+ f"{''.join(cells)}
"
+ )
+ return "".join(rows_html)
+
+
+def seat_selection_body(data: Any | None) -> str:
+ payload = as_dict(data)
+ cabins = as_list(payload.get("cabins"))
+ if data is None:
+ return (
+ "
Seat map
"
+ '
Waiting for get_seat_map result.
'
+ ''
+ )
+ blocks = []
+ for cabin in cabins:
+ cabin = as_dict(cabin)
+ klass = pick(cabin, "cabin_class", "cabinClass", default="cabin")
+ blocks.append(f'
{esc(klass)}
{_seat_cells(cabin)}')
+ if not blocks:
+ blocks.append('
No seat map data.
')
+ return (
+ "
Seat map
"
+ f'
Offer {esc(payload.get("offerId") or payload.get("offer_id"))}
'
+ f'
{"".join(blocks)}
'
+ )
+
+
+def order_cancellation_body(data: Any | None) -> str:
+ payload = as_dict(data)
+ if data is None or not payload:
+ return (
+ "