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 @@ + + + + + + + Product Card + + + + +
+

+ + + diff --git a/examples/widgets/out/chart.html b/examples/widgets/out/chart.html new file mode 100644 index 0000000..46fccaf --- /dev/null +++ b/examples/widgets/out/chart.html @@ -0,0 +1,411 @@ + + + + + + + Bar Chart + + + + +
+

Chart

+ + + diff --git a/examples/widgets/out/table.html b/examples/widgets/out/table.html new file mode 100644 index 0000000..21eea6d --- /dev/null +++ b/examples/widgets/out/table.html @@ -0,0 +1,410 @@ + + + + + + + Data Table + + + + +
+
+ + + 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 ( -
- Loading... -
- ); - } - - return ( -
- {/* Header */} -
-
- 🔍 -

- Airport Search -

-
-

- Searching: "{data.query}" -

-
- {data.results.length} result{data.results.length !== 1 ? 's' : ''} -
-
- - {/* Results */} - {data.results.length > 0 ? ( -
- {data.results.map((airport) => ( -
{ - e.currentTarget.style.transform = 'translateY(-2px)'; - e.currentTarget.style.boxShadow = isDark - ? '0 4px 12px rgba(59, 159, 255, 0.2)' - : '0 4px 12px rgba(59, 159, 255, 0.15)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.transform = 'translateY(0)'; - e.currentTarget.style.boxShadow = isDark - ? '0 2px 8px rgba(0,0,0,0.3)' - : '0 4px 12px rgba(0,0,0,0.1)'; - }}> -
- {/* Left side */} -
-
- - {getTypeIcon(airport.type)} - -
-

- {airport.name} -

- {airport.cityName && ( -

- 📍 {airport.cityName} -

- )} -
-
- - {/* Additional details */} -
- {airport.timeZone && ( -
- 🕐 - {airport.timeZone} -
- )} - {airport.latitude && airport.longitude && ( -
- 🌍 - {airport.latitude.toFixed(2)}, {airport.longitude.toFixed(2)} -
- )} -
-
- - {/* Right side - Codes */} -
- {/* IATA Code */} -
- {airport.iataCode || 'N/A'} -
- - {/* ICAO Code */} - {airport.icaoCode && ( -
- ICAO: {airport.icaoCode} -
- )} - - {/* Type badge */} -
- {airport.type.replace('_', ' ')} -
-
-
-
- ))} -
- ) : ( -
-
🔍
-
- No airports found -
-
- Try a different city or airport code -
-
- )} - - {/* Help text */} -
- 💡 Tip: Use the IATA code (3-letter) for flight searches -
-
- ); -} diff --git a/nitrostack/templates/flight-booking/src/widgets/app/flight-details/page.tsx b/nitrostack/templates/flight-booking/src/widgets/app/flight-details/page.tsx deleted file mode 100644 index d30f730..0000000 --- a/nitrostack/templates/flight-booking/src/widgets/app/flight-details/page.tsx +++ /dev/null @@ -1,261 +0,0 @@ -'use client'; - -import { useWidgetSDK, useTheme } from '@nitrostack/widgets'; - -/** - * Flight Details Widget - Compact view with segments, baggage, and fare conditions - */ - -interface Segment { - id: string; - origin: string; - destination: string; - departingAt: string; - arrivingAt: string; - duration: string; - airline: { name: string; code: string; flightNumber: string }; - aircraft?: string; -} - -interface Slice { - origin: { code: string; name: string; city: string }; - destination: { code: string; name: string; city: string }; - duration: string; - segments: Segment[]; -} - -interface FlightDetailsData { - id: string; - totalAmount: string; - totalCurrency: string; - slices: Slice[]; - passengers: Array<{ id: string; type: string; baggageAllowance?: Array<{ type: string; quantity: number }> }>; - conditions: { - refundBeforeDeparture: { allowed: boolean; penaltyAmount?: string; penaltyCurrency?: string }; - changeBeforeDeparture: { allowed: boolean; penaltyAmount?: string; penaltyCurrency?: string }; - }; -} - -export default function FlightDetails() { - const { getToolOutput } = useWidgetSDK(); - const theme = useTheme(); - const data = getToolOutput(); - - const isDark = theme === 'dark'; - - const formatDuration = (duration: string) => { - const match = duration.match(/PT(\d+H)?(\d+M)?/); - if (!match) return duration; - const hours = match[1] ? parseInt(match[1]) : 0; - const minutes = match[2] ? parseInt(match[2]) : 0; - return `${hours}h ${minutes}m`; - }; - - const formatTime = (isoString: string) => { - const date = new Date(isoString); - return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); - }; - - const formatDate = (isoString: string) => { - const date = new Date(isoString); - return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); - }; - - if (!data) { - return
Loading...
; - } - - return ( -
- {/* Header */} -
-
-
-

Flight Details

-

- Offer ID: {data.id} -

-
-
-
- {data.totalCurrency} {parseFloat(data.totalAmount).toFixed(2)} -
-
Total Price
-
-
-
- - {/* Flight Itinerary */} - {data.slices.map((slice, sliceIndex) => ( -
-

- ✈️ - {slice.origin.city} → {slice.destination.city} -

- -
- Duration: {formatDuration(slice.duration)} -
- - {/* Segments */} - {slice.segments.map((segment) => ( -
-
-
- {segment.airline.name} {segment.airline.flightNumber} -
-
- {formatDuration(segment.duration)} -
-
- -
-
-
{formatTime(segment.departingAt)}
-
- {segment.origin} -
-
- {formatDate(segment.departingAt)} -
-
- -
-
-
- -
-
{formatTime(segment.arrivingAt)}
-
- {segment.destination} -
-
- {formatDate(segment.arrivingAt)} -
-
-
- - {segment.aircraft && ( -
- ✈️ {segment.aircraft} -
- )} -
- ))} -
- ))} - - {/* Baggage */} -
-

- 🧳 - Baggage Allowance -

- {data.passengers.map((passenger, index) => ( -
-
- Passenger {index + 1} ({passenger.type}) -
- {passenger.baggageAllowance && passenger.baggageAllowance.length > 0 ? ( -
- {passenger.baggageAllowance.map((bag, bagIndex) => ( - - {bag.quantity}x {bag.type.replace('_', ' ')} - - ))} -
- ) : ( -
- No baggage info -
- )} -
- ))} -
- - {/* Fare Conditions */} - {data.conditions && ( -
-

- 📋 - Fare Conditions -

-
-
-
- {data.conditions.refundBeforeDeparture?.allowed ? '✓' : '✗'} Refund Before Departure -
- {data.conditions.refundBeforeDeparture?.penaltyAmount && ( -
- Penalty: {data.conditions.refundBeforeDeparture.penaltyCurrency} {data.conditions.refundBeforeDeparture.penaltyAmount} -
- )} -
- -
-
- {data.conditions.changeBeforeDeparture?.allowed ? '✓' : '✗'} Changes Before Departure -
- {data.conditions.changeBeforeDeparture?.penaltyAmount && ( -
- Penalty: {data.conditions.changeBeforeDeparture.penaltyCurrency} {data.conditions.changeBeforeDeparture.penaltyAmount} -
- )} -
-
-
- )} -
- ); -} diff --git a/nitrostack/templates/flight-booking/src/widgets/app/flight-search-results/page.tsx b/nitrostack/templates/flight-booking/src/widgets/app/flight-search-results/page.tsx deleted file mode 100644 index 60fd80a..0000000 --- a/nitrostack/templates/flight-booking/src/widgets/app/flight-search-results/page.tsx +++ /dev/null @@ -1,378 +0,0 @@ -'use client'; - -import { useWidgetSDK, useTheme } from '@nitrostack/widgets'; - -/** - * Flight Search Results Widget - * - * Modern, compact display of flight search results with Nitrocloud branding. - */ - -interface FlightSegment { - origin: string; - destination: string; - departureTime: string; - arrivalTime: string; - duration: string; - stops: number; - airline: string; - flightNumber: string; -} - -interface FlightOffer { - id: string; - totalAmount: string; - totalCurrency: string; - outbound: FlightSegment; - return?: FlightSegment; - fareType: string; - refundable: boolean; - changeable: boolean; -} - -interface FlightSearchData { - searchParams: { - origin: string; - destination: string; - departureDate: string; - returnDate?: string; - passengers: { - adults: number; - children: number; - infants: number; - }; - cabinClass: string; - }; - totalOffers: number; - offers: FlightOffer[]; -} - -export default function FlightSearchResults() { - const { getToolOutput, callTool } = useWidgetSDK(); - const theme = useTheme(); - const data = getToolOutput(); - const isDark = theme === 'dark'; - - const handleFlightClick = async (offerId: string) => { - try { - await callTool('get_flight_details', { offerId }); - } catch (error) { - console.error('Failed to get flight details:', error); - } - }; - - const formatDuration = (duration: string) => { - const match = duration.match(/PT(\d+H)?(\d+M)?/); - if (!match) return duration; - const hours = match[1] ? parseInt(match[1]) : 0; - const minutes = match[2] ? parseInt(match[2]) : 0; - return `${hours}h ${minutes}m`; - }; - - const formatTime = (isoString: string) => { - return new Date(isoString).toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit', - hour12: false - }); - }; - - const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric' - }); - }; - - const getAirlineInitials = (name: string) => { - return name.split(' ').map(w => w[0]).join('').substring(0, 2).toUpperCase(); - }; - - if (!data?.searchParams) { - return ( -
-
⚠️
-
- Invalid Data -
-
- Flight search data is missing -
-
- ); - } - - const totalPassengers = (data.searchParams.passengers?.adults || 0) + - (data.searchParams.passengers?.children || 0) + - (data.searchParams.passengers?.infants || 0); - - const FlightSegment = ({ segment, label }: { segment: FlightSegment; label: string }) => ( -
-
- {label === 'Outbound' ? '✈️' : '🔄'} - {label} -
- -
-
-
- {formatTime(segment.departureTime)} -
-
- {segment.origin} -
-
- -
-
- {formatDuration(segment.duration)} -
-
- {segment.stops > 0 && ( -
- )} -
-
- {segment.stops === 0 ? 'Direct' : `${segment.stops} stop${segment.stops > 1 ? 's' : ''}`} -
-
- -
-
- {formatTime(segment.arrivalTime)} -
-
- {segment.destination} -
-
-
-
- ); - - return ( -
- {/* Header */} -
-
-
- {data.searchParams.origin} - ✈️ - {data.searchParams.destination} -
- -
- {data.totalOffers} flights -
-
- -
-
- 📅 - - {formatDate(data.searchParams.departureDate)} - {data.searchParams.returnDate && ` - ${formatDate(data.searchParams.returnDate)}`} - -
-
- 👥 - {totalPassengers} pax -
-
- 💺 - {(data.searchParams.cabinClass || 'economy').replace('_', ' ')} -
-
-
- - {/* Flight Offers */} - {data.offers.length > 0 ? ( -
- {data.offers.map((offer) => ( -
handleFlightClick(offer.id)} - onMouseEnter={(e) => { - e.currentTarget.style.transform = 'translateY(-2px)'; - e.currentTarget.style.boxShadow = isDark - ? '0 4px 12px rgba(59, 159, 255, 0.2)' - : '0 4px 12px rgba(59, 159, 255, 0.15)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.transform = 'translateY(0)'; - e.currentTarget.style.boxShadow = isDark - ? '0 2px 8px rgba(0,0,0,0.3)' - : '0 2px 8px rgba(0,0,0,0.1)'; - }}> - {/* Offer Header */} -
-
-
- {getAirlineInitials(offer.outbound.airline)} -
-
-
- {offer.outbound.airline} -
-
- {offer.outbound.flightNumber} -
-
-
- -
-
- {offer.totalCurrency} {parseFloat(offer.totalAmount).toFixed(0)} -
-
- Total -
-
-
- - {/* Flight Segments */} - - {offer.return && } - - {/* Badges */} -
- - {offer.refundable ? '✓ Refundable' : '✗ Non-refundable'} - - {offer.changeable && ( - ✓ Changeable - )} - {offer.fareType} -
-
- ))} -
- ) : ( -
-
✈️
-
- No flights found. Try adjusting your search. -
-
- )} -
- ); -} diff --git a/nitrostack/templates/flight-booking/src/widgets/app/globals.css b/nitrostack/templates/flight-booking/src/widgets/app/globals.css deleted file mode 100644 index ca47c6b..0000000 --- a/nitrostack/templates/flight-booking/src/widgets/app/globals.css +++ /dev/null @@ -1,167 +0,0 @@ -/* Nitrocloud Widget Styles - Professional Theme */ - -:root { - /* Professional Color Palette */ - --primary: #3B82F6; - --primary-hover: #2563EB; - --secondary: #6B7280; - --secondary-hover: #4B5563; - - --success: #10B981; - --warning: #F59E0B; - --error: #EF4444; - --info: #3B82F6; - - /* Light Theme */ - --bg-primary: #FFFFFF; - --bg-secondary: #F9FAFB; - --bg-tertiary: #F3F4F6; - --bg-elevated: #FFFFFF; - - --text-primary: #111827; - --text-secondary: #6B7280; - --text-muted: #9CA3AF; - - --border-color: #E5E7EB; - --border-hover: #D1D5DB; - - --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); - --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); - --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); -} - -.dark { - /* Dark Theme */ - --bg-primary: #111827; - --bg-secondary: #1F2937; - --bg-tertiary: #374151; - --bg-elevated: #1F2937; - - --text-primary: #F9FAFB; - --text-secondary: #D1D5DB; - --text-muted: #9CA3AF; - - --border-color: #374151; - --border-hover: #4B5563; - - --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); - --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4); - --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5); -} - -/* Reset */ -* { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -/* Card Styles */ -.card { - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 16px; - transition: all 0.2s ease; - box-shadow: var(--shadow-sm); -} - -.card:hover { - border-color: var(--border-hover); - box-shadow: var(--shadow-md); -} - -/* Button Styles */ -.btn-primary { - background: var(--primary); - color: white; - border: none; - padding: 10px 20px; - border-radius: 6px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s ease; - font-size: 14px; -} - -.btn-primary:hover { - background: var(--primary-hover); - box-shadow: var(--shadow-md); -} - -.btn-secondary { - background: var(--bg-secondary); - color: var(--text-primary); - border: 1px solid var(--border-color); - padding: 10px 20px; - border-radius: 6px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s ease; - font-size: 14px; -} - -.btn-secondary:hover { - background: var(--bg-tertiary); - border-color: var(--border-hover); -} - -/* Badge Styles */ -.badge { - display: inline-flex; - align-items: center; - padding: 4px 12px; - border-radius: 6px; - font-size: 12px; - font-weight: 600; -} - -.badge-success { - background: rgba(16, 185, 129, 0.1); - color: var(--success); - border: 1px solid rgba(16, 185, 129, 0.2); -} - -.badge-warning { - background: rgba(245, 158, 11, 0.1); - color: var(--warning); - border: 1px solid rgba(245, 158, 11, 0.2); -} - -.badge-error { - background: rgba(239, 68, 68, 0.1); - color: var(--error); - border: 1px solid rgba(239, 68, 68, 0.2); -} - -.badge-info { - background: rgba(59, 130, 246, 0.1); - color: var(--info); - border: 1px solid rgba(59, 130, 246, 0.2); -} - -.dark .badge-success { - background: rgba(16, 185, 129, 0.2); - color: #6EE7B7; -} - -.dark .badge-warning { - background: rgba(245, 158, 11, 0.2); - color: #FCD34D; -} - -.dark .badge-error { - background: rgba(239, 68, 68, 0.2); - color: #FCA5A5; -} - -.dark .badge-info { - background: rgba(59, 130, 246, 0.2); - color: #93C5FD; -} \ No newline at end of file diff --git a/nitrostack/templates/flight-booking/src/widgets/app/layout.tsx b/nitrostack/templates/flight-booking/src/widgets/app/layout.tsx deleted file mode 100644 index fe7980c..0000000 --- a/nitrostack/templates/flight-booking/src/widgets/app/layout.tsx +++ /dev/null @@ -1,18 +0,0 @@ -'use client'; - -import { WidgetLayout } from '@nitrostack/widgets'; -import './globals.css'; - -export default function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( - - - {children} - - - ); -} diff --git a/nitrostack/templates/flight-booking/src/widgets/app/order-cancellation/page.tsx b/nitrostack/templates/flight-booking/src/widgets/app/order-cancellation/page.tsx deleted file mode 100644 index d87d851..0000000 --- a/nitrostack/templates/flight-booking/src/widgets/app/order-cancellation/page.tsx +++ /dev/null @@ -1,207 +0,0 @@ -'use client'; - -import { useWidgetSDK, useTheme } from '@nitrostack/widgets'; - -/** - * Order Cancellation Widget - Cancellation confirmation with refund info - */ - -interface CancellationData { - orderId: string; - cancellationId: string; - status: string; - refundAmount?: string; - refundCurrency?: string; - confirmedAt: string; - message: string; -} - -export default function OrderCancellation() { - const { getToolOutput } = useWidgetSDK(); - const theme = useTheme(); - const data = getToolOutput(); - - const isDark = theme === 'dark'; - - const formatDateTime = (isoString: string) => { - return new Date(isoString).toLocaleString('en-US', { - month: 'long', - day: 'numeric', - year: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); - }; - - if (!data) { - return
Loading...
; - } - - const hasRefund = data.refundAmount && parseFloat(data.refundAmount) > 0; - - return ( -
-
- {/* Icon */} -
-
- {hasRefund ? '💰' : '✗'} -
-
- - {/* Title */} -

- Booking Cancelled -

- -

- {data.message} -

- - {/* Cancellation Details */} -
-
- {/* Order ID */} -
-
- Order ID -
-
- {data.orderId} -
-
- - {/* Cancellation ID */} -
-
- Cancellation Reference -
-
- {data.cancellationId} -
-
- - {/* Cancelled On */} -
-
- Cancelled On -
-
- {formatDateTime(data.confirmedAt)} -
-
- - {/* Refund Information */} - {hasRefund ? ( -
-
-
💰
-
-
- Refund Amount -
-
- {data.refundCurrency} {parseFloat(data.refundAmount!).toFixed(2)} -
-
-
-
- Refund will be processed to your original payment method within 5-10 business days. -
-
- ) : ( -
-
-
ℹ️
-
- No Refund Available: This booking was non-refundable or outside the cancellation window. -
-
-
- )} -
-
- - {/* Status Badge */} -
-
- ✗ {data.status} -
-
- - {/* Action Buttons */} -
- - - -
- - {/* Help Section */} -
-
- 💬 - Need Help? -
-
- Questions about this cancellation? Contact our support team 24/7. -
- -
-
-
- ); -} diff --git a/nitrostack/templates/flight-booking/src/widgets/app/order-summary/page.tsx b/nitrostack/templates/flight-booking/src/widgets/app/order-summary/page.tsx deleted file mode 100644 index 105361b..0000000 --- a/nitrostack/templates/flight-booking/src/widgets/app/order-summary/page.tsx +++ /dev/null @@ -1,245 +0,0 @@ -'use client'; - -import { useWidgetSDK, useTheme } from '@nitrostack/widgets'; - -/** - * Order Summary Widget - Compact booking confirmation - */ - -interface OrderData { - orderId: string; - status: string; - bookingReference?: string; - totalAmount: string; - totalCurrency: string; - createdAt?: string; - expiresAt?: string; - passengers: Array<{ id: string; name: string; type: string; email?: string }>; - slices: Array<{ - origin: { code: string; city?: string }; - destination: { code: string; city?: string }; - segments?: Array<{ airline: string; flightNumber: string }>; - }>; - message?: string; -} - -export default function OrderSummary() { - const { getToolOutput } = useWidgetSDK(); - const theme = useTheme(); - const data = getToolOutput(); - - const isDark = theme === 'dark'; - - const formatDateTime = (isoString: string) => { - return new Date(isoString).toLocaleString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); - }; - - const getStatusIcon = (status: string) => { - return { 'confirmed': '🎉', 'held': '⏱️', 'cancelled': '✗', 'pending': '⋯' }[status.toLowerCase()] || '📋'; - }; - - if (!data) { - return
Loading...
; - } - - return ( -
- {/* Header */} -
-
- {getStatusIcon(data.status)} -
-

- {data.status === 'confirmed' ? 'Booking Confirmed!' : - data.status === 'held' ? 'Order On Hold' : 'Order Summary'} -

- - {data.bookingReference && ( -
- Reference: {data.bookingReference} -
- )} - -
- {data.status.toUpperCase()} -
- - {data.message && ( -
- {data.message} -
- )} - - {data.expiresAt && data.status === 'held' && ( -
-
- ⏰ Payment Required -
-
- Complete before: {formatDateTime(data.expiresAt)} -
-
- )} -
- - {/* Order Info */} -
-

Order Information

-
-
- Order ID: - {data.orderId} -
-
- Total: - - {data.totalCurrency} {parseFloat(data.totalAmount).toFixed(2)} - -
- {data.createdAt && ( -
- Created: - {formatDateTime(data.createdAt)} -
- )} -
-
- - {/* Passengers */} -
-

- 👥 - Passengers ({data.passengers.length}) -

-
- {data.passengers.map((passenger, index) => ( -
-
-
- {passenger.name} -
-
- {passenger.type} -
- {passenger.email && ( -
- 📧 {passenger.email} -
- )} -
-
- {index + 1} -
-
- ))} -
-
- - {/* Flight Itinerary */} -
-

- ✈️ - Flight Itinerary -

-
- {data.slices.map((slice, index) => ( -
-
- {index === 0 ? 'Outbound' : 'Return'} Flight -
-
-
-
- {slice.origin.code} -
- {slice.origin.city && ( -
- {slice.origin.city} -
- )} -
- -
- -
-
- {slice.destination.code} -
- {slice.destination.city && ( -
- {slice.destination.city} -
- )} -
-
- - {slice.segments && slice.segments.length > 0 && ( -
- {slice.segments.map((segment, segIndex) => ( -
- {segment.airline} {segment.flightNumber} -
- ))} -
- )} -
- ))} -
-
-
- ); -} diff --git a/nitrostack/templates/flight-booking/src/widgets/app/payment-confirmation/page.tsx b/nitrostack/templates/flight-booking/src/widgets/app/payment-confirmation/page.tsx deleted file mode 100644 index 0898312..0000000 --- a/nitrostack/templates/flight-booking/src/widgets/app/payment-confirmation/page.tsx +++ /dev/null @@ -1,152 +0,0 @@ -'use client'; - -import { useWidgetSDK, useTheme } from '@nitrostack/widgets'; - -/** - * Payment Confirmation Widget - Success state with booking details - */ - -interface PaymentData { - orderId: string; - status: string; - totalAmount: string; - totalCurrency: string; - bookingReference?: string; - message?: string; -} - -export default function PaymentConfirmation() { - const { getToolOutput } = useWidgetSDK(); - const theme = useTheme(); - const data = getToolOutput(); - - const isDark = theme === 'dark'; - - if (!data) { - return
Loading...
; - } - - const isConfirmed = data.status === 'confirmed'; - - return ( -
-
- {/* Success Icon */} -
-
- {isConfirmed ? '✓' : '💳'} -
-
- - {/* Title */} -

- {isConfirmed ? 'Payment Successful!' : 'Complete Payment'} -

- -

- {data.message || (isConfirmed ? 'Your booking has been confirmed.' : 'Review and confirm your payment.')} -

- - {/* Booking Details */} -
- {data.bookingReference && ( -
-
- Booking Reference -
-
- {data.bookingReference} -
-
- )} - -
-
- Order ID: - {data.orderId} -
- -
- - {isConfirmed ? 'Amount Paid:' : 'Total Amount:'} - - - {data.totalCurrency} {parseFloat(data.totalAmount).toFixed(2)} - -
-
-
- - {/* Action Buttons */} - {isConfirmed ? ( -
- - -
- ) : ( -
- -
- 🔒 Your payment is secure and encrypted -
-
- )} - - {/* Footer Note */} - {isConfirmed && ( -
- 📱 Important: A confirmation email has been sent to your registered email address. -
- )} -
-
- ); -} diff --git a/nitrostack/templates/flight-booking/src/widgets/app/seat-selection/page.tsx b/nitrostack/templates/flight-booking/src/widgets/app/seat-selection/page.tsx deleted file mode 100644 index 0ae6b06..0000000 --- a/nitrostack/templates/flight-booking/src/widgets/app/seat-selection/page.tsx +++ /dev/null @@ -1,486 +0,0 @@ -'use client'; - -import { useWidgetSDK, useTheme } from '@nitrostack/widgets'; -import { useState } from 'react'; - -/** - * Seat Selection Widget - * - * Interactive seat map with real-time selection for multiple passengers. - */ - -interface Seat { - id: string; - column: string; - available: boolean; - price?: string; - type: string; -} - -interface Row { - rowNumber: number; - seats: Seat[]; -} - -interface Cabin { - cabinClass: string; - rows: Row[]; -} - -interface SeatMapData { - offerId: string; - cabins: Cabin[]; - message?: string; -} - -export default function SeatSelection() { - const { getToolOutput } = useWidgetSDK(); - const theme = useTheme(); - const data = getToolOutput(); - - const isDark = theme === 'dark'; - const [selectedSeats, setSelectedSeats] = useState>({}); - const [activePassenger, setActivePassenger] = useState(0); - const [hoveredSeat, setHoveredSeat] = useState(null); - - const passengers = [ - { id: 'pax_1', name: 'Passenger 1' }, - { id: 'pax_2', name: 'Passenger 2' } - ]; - - const handleSeatClick = (seatId: string, seat: Seat) => { - if (!seat.available) return; - - const currentPassengerId = passengers[activePassenger].id; - const seatOwner = Object.entries(selectedSeats).find(([_, id]) => id === seatId)?.[0]; - - if (seatOwner && seatOwner !== currentPassengerId) return; - - setSelectedSeats(prev => { - const newSelections = { ...prev }; - if (newSelections[currentPassengerId] === seatId) { - delete newSelections[currentPassengerId]; - } else { - delete newSelections[currentPassengerId]; - newSelections[currentPassengerId] = seatId; - if (activePassenger < passengers.length - 1) { - setTimeout(() => setActivePassenger(activePassenger + 1), 200); - } - } - return newSelections; - }); - }; - - const getSeatStatus = (seatId: string, seat: Seat) => { - if (!seat.available) return 'unavailable'; - const owner = Object.entries(selectedSeats).find(([_, id]) => id === seatId)?.[0]; - if (owner) { - const passengerIndex = passengers.findIndex(p => p.id === owner); - return passengerIndex === activePassenger ? 'selected-active' : 'selected-other'; - } - return 'available'; - }; - - const getSeatColor = (status: string) => { - if (isDark) { - return { - 'available': '#334155', - 'selected-active': '#3B9FFF', - 'selected-other': '#22C55E', - 'unavailable': '#1E293B' - }[status] || '#334155'; - } - return { - 'available': '#E2E8F0', - 'selected-active': '#3B9FFF', - 'selected-other': '#22C55E', - 'unavailable': '#CBD5E1' - }[status] || '#E2E8F0'; - }; - - const calculateTotalPrice = () => { - let total = 0; - Object.values(selectedSeats).forEach(seatId => { - data?.cabins.forEach(cabin => { - cabin.rows.forEach(row => { - const seat = row.seats.find(s => s.id === seatId); - if (seat?.price) total += parseFloat(seat.price); - }); - }); - }); - return total; - }; - - const getSelectedSeatInfo = (passengerId: string) => { - const seatId = selectedSeats[passengerId]; - if (!seatId || !data) return null; - - for (const cabin of data.cabins) { - for (const row of cabin.rows) { - const seat = row.seats.find(s => s.id === seatId); - if (seat) return { seat, row: row.rowNumber }; - } - } - return null; - }; - - if (!data) { - return
Loading...
; - } - - return ( -
- {/* Header */} -
-
-
-

- 💺 - Select Seats -

-

- {data.message || 'Choose seats for all passengers'} -

-
- - {Object.keys(selectedSeats).length > 0 && ( -
-
Total
-
- ${calculateTotalPrice().toFixed(2)} -
-
- )} -
-
- -
- {/* Seat Map */} -
- {/* Front Indicator */} -
-
✈️
-
- FRONT -
-
- - {data.cabins.map((cabin, cabinIndex) => ( -
-
- {cabin.cabinClass.replace('_', ' ')} -
- -
- {cabin.rows.map((row) => ( -
-
- {row.rowNumber} -
- -
- {row.seats.map((seat) => { - const status = getSeatStatus(seat.id, seat); - const isHovered = hoveredSeat === seat.id; - - return ( -
handleSeatClick(seat.id, seat)} - onMouseEnter={() => setHoveredSeat(seat.id)} - onMouseLeave={() => setHoveredSeat(null)} - style={{ - width: '40px', - height: '40px', - background: getSeatColor(status), - borderRadius: '6px', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - cursor: seat.available ? 'pointer' : 'not-allowed', - transition: 'all 0.2s ease', - transform: isHovered && seat.available ? 'scale(1.1)' : 'scale(1)', - border: status === 'selected-active' ? '2px solid #fff' : 'none', - position: 'relative', - opacity: seat.available ? 1 : 0.4 - }} - > - 💺 -
- {seat.column} -
- {seat.price && parseFloat(seat.price) > 0 && isHovered && ( -
- ${seat.price} -
- )} -
- ); - })} -
- -
- {row.rowNumber} -
-
- ))} -
-
- ))} - - {/* Legend */} -
- {[ - { label: 'Available', color: isDark ? '#334155' : '#E2E8F0' }, - { label: 'Your Seat', color: '#3B9FFF' }, - { label: 'Other', color: '#22C55E' }, - { label: 'Taken', color: isDark ? '#1E293B' : '#CBD5E1' } - ].map(item => ( -
-
- - {item.label} - -
- ))} -
-
- - {/* Sidebar */} -
- {/* Passengers */} -
-

- Passengers -

- -
- {passengers.map((passenger, index) => { - const seatInfo = getSelectedSeatInfo(passenger.id); - const isActive = activePassenger === index; - - return ( -
setActivePassenger(index)} - className={isActive ? 'nitro-gradient' : ''} - style={{ - padding: '12px', - background: isActive ? undefined : (isDark ? '#0F172A' : '#F8FAFC'), - borderRadius: '8px', - cursor: 'pointer', - transition: 'all 0.2s ease', - border: isActive ? '2px solid #fff' : '2px solid transparent' - }} - > -
-
-
- {passenger.name} -
- {seatInfo ? ( -
- {seatInfo.row}{seatInfo.seat.column} - {seatInfo.seat.price && ` • $${seatInfo.seat.price}`} -
- ) : ( -
- No seat -
- )} -
- - {seatInfo && ( -
- ✓ -
- )} -
-
- ); - })} -
-
- - {/* Summary */} -
-

- Summary -

- -
-
- Selected: - - {Object.keys(selectedSeats).length} / {passengers.length} - -
- -
- Total: - - ${calculateTotalPrice().toFixed(2)} - -
-
- - -
-
-
-
- ); -} diff --git a/nitrostack/templates/flight-booking/src/widgets/next-env.d.ts b/nitrostack/templates/flight-booking/src/widgets/next-env.d.ts deleted file mode 100644 index 40c3d68..0000000 --- a/nitrostack/templates/flight-booking/src/widgets/next-env.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/// -/// - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/nitrostack/templates/flight-booking/src/widgets/next.config.js b/nitrostack/templates/flight-booking/src/widgets/next.config.js deleted file mode 100644 index f35620c..0000000 --- a/nitrostack/templates/flight-booking/src/widgets/next.config.js +++ /dev/null @@ -1,45 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { - reactStrictMode: true, - transpilePackages: ['nitrostack'], - - // Static export for production builds - ...(process.env.NODE_ENV === 'production' && { - output: 'export', - distDir: 'out', - images: { - unoptimized: true, - }, - }), - - // Development optimizations to prevent cache corruption - ...(process.env.NODE_ENV === 'development' && { - // Use memory cache instead of filesystem cache in dev to avoid stale chunks - webpack: (config, { isServer }) => { - // Disable persistent caching in development to prevent chunk reference errors - if (config.cache && config.cache.type === 'filesystem') { - config.cache = { - type: 'memory', - }; - } - - // Improve cache busting for new files - if (!isServer) { - config.cache = false; // Disable cache completely on client in dev - } - - return config; - }, - - // Disable build activity indicator which can cause issues - devIndicators: { - buildActivity: false, - buildActivityPosition: 'bottom-right', - }, - - // Faster dev server - compress: false, - }), -}; - -export default nextConfig; diff --git a/nitrostack/templates/flight-booking/src/widgets/package.json b/nitrostack/templates/flight-booking/src/widgets/package.json deleted file mode 100644 index 04cbb86..0000000 --- a/nitrostack/templates/flight-booking/src/widgets/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "flight-booking-widgets", - "version": "1.0.0", - "type": "module", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start" - }, - "dependencies": { - "next": "^14.2.5", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "@nitrostack/widgets": "^1", - "@modelcontextprotocol/ext-apps": ">=0.1.0" - }, - "devDependencies": { - "@types/node": "^20", - "@types/react": "^18", - "@types/react-dom": "^18", - "typescript": "^5" - } -} - diff --git a/nitrostack/templates/flight-booking/src/widgets/tsconfig.json b/nitrostack/templates/flight-booking/src/widgets/tsconfig.json deleted file mode 100644 index 2c0ad66..0000000 --- a/nitrostack/templates/flight-booking/src/widgets/tsconfig.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "plugins": [ - { - "name": "next" - } - ], - "paths": { - "@/*": ["./*"] - } - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] -} - diff --git a/nitrostack/templates/flight-booking/src/widgets/widget-manifest.json b/nitrostack/templates/flight-booking/src/widgets/widget-manifest.json deleted file mode 100644 index 6dd7c34..0000000 --- a/nitrostack/templates/flight-booking/src/widgets/widget-manifest.json +++ /dev/null @@ -1,395 +0,0 @@ -{ - "version": "1.0.0", - "widgets": [ - { - "uri": "/flight-search-results", - "name": "Flight Search Results", - "description": "Displays flight search results with pricing, airlines, and flight details", - "examples": [ - { - "name": "Round Trip Search", - "description": "Shows results for a round trip flight search", - "data": { - "requestId": "orq_example123", - "searchParams": { - "origin": "JFK", - "destination": "LAX", - "departureDate": "2024-03-15", - "returnDate": "2024-03-22", - "passengers": { - "adults": 2, - "children": 0, - "infants": 0 - }, - "cabinClass": "economy" - }, - "totalOffers": 15, - "offers": [ - { - "id": "off_example123", - "totalAmount": "450.00", - "totalCurrency": "USD", - "expiresAt": "2024-03-01T12:00:00Z", - "outbound": { - "origin": "JFK", - "destination": "LAX", - "departureTime": "2024-03-15T08:00:00Z", - "arrivalTime": "2024-03-15T14:30:00Z", - "duration": "PT6H30M", - "stops": 0, - "airline": "American Airlines", - "flightNumber": "AA123", - "segments": [] - }, - "return": { - "origin": "LAX", - "destination": "JFK", - "departureTime": "2024-03-22T16:00:00Z", - "arrivalTime": "2024-03-23T00:30:00Z", - "duration": "PT5H30M", - "stops": 0, - "airline": "American Airlines", - "flightNumber": "AA456", - "segments": [] - }, - "fareType": "Domestic", - "refundable": false, - "changeable": true - } - ], - "message": "Found 15 flight options" - } - } - ], - "tags": [ - "flights", - "search", - "travel", - "booking" - ] - }, - { - "uri": "/flight-details", - "name": "Flight Details", - "description": "Displays comprehensive flight details including segments, baggage, and fare conditions", - "examples": [ - { - "name": "Flight Details Example", - "description": "Shows detailed information for a specific flight offer", - "data": { - "id": "off_example123", - "totalAmount": "450.00", - "totalCurrency": "USD", - "expiresAt": "2024-03-01T12:00:00Z", - "slices": [ - { - "origin": { - "code": "JFK", - "name": "John F. Kennedy International Airport", - "city": "New York" - }, - "destination": { - "code": "LAX", - "name": "Los Angeles International Airport", - "city": "Los Angeles" - }, - "duration": "PT6H30M", - "segments": [ - { - "id": "seg_123", - "origin": "JFK", - "destination": "LAX", - "departingAt": "2024-03-15T08:00:00Z", - "arrivingAt": "2024-03-15T14:30:00Z", - "duration": "PT6H30M", - "airline": { - "name": "American Airlines", - "code": "AA", - "flightNumber": "123" - }, - "aircraft": "Boeing 777-300ER" - } - ] - } - ], - "passengers": [ - { - "id": "pas_123", - "type": "adult", - "fareType": "economy", - "baggageAllowance": [ - { - "type": "checked", - "quantity": 1 - }, - { - "type": "carry_on", - "quantity": 1 - } - ] - } - ], - "conditions": { - "refundBeforeDeparture": { - "allowed": false - }, - "changeBeforeDeparture": { - "allowed": true, - "penaltyAmount": "75.00", - "penaltyCurrency": "USD" - } - }, - "paymentRequirements": { - "requiresInstantPayment": true, - "priceGuaranteeExpiresAt": "2024-03-01T12:00:00Z" - } - } - } - ], - "tags": [ - "flights", - "details", - "travel", - "booking" - ] - }, - { - "uri": "/airport-search", - "name": "Airport Search", - "description": "Displays airport search results with IATA codes and location details", - "examples": [ - { - "name": "London Airports", - "description": "Shows search results for London airports", - "data": { - "query": "London", - "results": [ - { - "id": "arp_lhr_gb", - "name": "Heathrow Airport", - "iataCode": "LHR", - "icaoCode": "EGLL", - "cityName": "London", - "type": "airport", - "latitude": 51.4700, - "longitude": -0.4543, - "timeZone": "Europe/London" - }, - { - "id": "arp_lgw_gb", - "name": "Gatwick Airport", - "iataCode": "LGW", - "icaoCode": "EGKK", - "cityName": "London", - "type": "airport", - "latitude": 51.1537, - "longitude": -0.1821, - "timeZone": "Europe/London" - }, - { - "id": "arp_stn_gb", - "name": "Stansted Airport", - "iataCode": "STN", - "icaoCode": "EGSS", - "cityName": "London", - "type": "airport", - "latitude": 51.8860, - "longitude": 0.2389, - "timeZone": "Europe/London" - } - ] - } - } - ], - "tags": [ - "airports", - "search", - "travel", - "iata" - ] - }, - { - "uri": "/order-summary", - "name": "Order Summary", - "description": "Displays booking confirmation with order details, passenger information, and flight itinerary", - "examples": [ - { - "name": "Confirmed Booking", - "description": "Shows a confirmed flight booking with all details", - "data": { - "orderId": "ord_example123", - "status": "confirmed", - "bookingReference": "ABC123", - "totalAmount": "450.00", - "totalCurrency": "USD", - "createdAt": "2024-03-01T10:00:00Z", - "passengers": [ - { - "id": "pax_1", - "name": "John Doe", - "type": "adult", - "email": "john@example.com" - } - ], - "slices": [ - { - "origin": { - "code": "JFK", - "name": "John F. Kennedy International", - "city": "New York" - }, - "destination": { - "code": "LAX", - "name": "Los Angeles International", - "city": "Los Angeles" - }, - "departureTime": "2024-03-15T08:00:00Z", - "arrivalTime": "2024-03-15T14:30:00Z", - "segments": [] - } - ], - "message": "Your booking has been confirmed!" - } - } - ], - "tags": [ - "booking", - "order", - "confirmation", - "travel" - ] - }, - { - "uri": "/seat-selection", - "name": "Seat Selection", - "description": "Interactive seat map for selecting seats with real-time pricing and availability", - "examples": [ - { - "name": "Economy Cabin Seats", - "description": "Shows available seats in economy cabin", - "data": { - "offerId": "off_example123", - "cabins": [ - { - "cabinClass": "economy", - "rows": [ - { - "rowNumber": 10, - "seats": [ - { - "id": "seat_10a", - "column": "A", - "available": true, - "price": "0", - "currency": "USD", - "type": "window" - }, - { - "id": "seat_10b", - "column": "B", - "available": true, - "price": "0", - "currency": "USD", - "type": "middle" - }, - { - "id": "seat_10c", - "column": "C", - "available": true, - "price": "0", - "currency": "USD", - "type": "aisle" - } - ] - } - ] - } - ], - "message": "Select your preferred seats" - } - } - ], - "tags": [ - "seats", - "selection", - "booking", - "travel" - ] - }, - { - "uri": "/payment-confirmation", - "name": "Payment Confirmation", - "description": "Payment processing and confirmation interface with order summary", - "examples": [ - { - "name": "Payment Success", - "description": "Shows successful payment confirmation", - "data": { - "orderId": "ord_example123", - "status": "confirmed", - "totalAmount": "450.00", - "totalCurrency": "USD", - "bookingReference": "ABC123", - "message": "Payment confirmed successfully!" - } - }, - { - "name": "Payment Pending", - "description": "Shows payment pending state", - "data": { - "orderId": "ord_example456", - "status": "pending", - "totalAmount": "650.00", - "totalCurrency": "USD" - } - } - ], - "tags": [ - "payment", - "confirmation", - "booking", - "checkout" - ] - }, - { - "uri": "/order-cancellation", - "name": "Order Cancellation", - "description": "Order cancellation confirmation with refund information and status", - "examples": [ - { - "name": "Cancellation with Refund", - "description": "Shows successful cancellation with refund", - "data": { - "orderId": "ord_example123", - "cancellationId": "ocr_example123", - "status": "cancelled", - "refundAmount": "450.00", - "refundCurrency": "USD", - "confirmedAt": "2024-03-01T12:00:00Z", - "message": "Order cancelled. Refund of USD 450.00 will be processed." - } - }, - { - "name": "Cancellation without Refund", - "description": "Shows cancellation with no refund", - "data": { - "orderId": "ord_example456", - "cancellationId": "ocr_example456", - "status": "cancelled", - "refundAmount": "0", - "refundCurrency": "USD", - "confirmedAt": "2024-03-01T12:00:00Z", - "message": "Order cancelled. No refund available for this booking." - } - } - ], - "tags": [ - "cancellation", - "refund", - "booking", - "order" - ] - } - ], - "generatedAt": "2025-01-01T00:00:00.000Z" -} \ No newline at end of file diff --git a/nitrostack/templates/flight-booking/widgets/out/airport-search.html b/nitrostack/templates/flight-booking/widgets/out/airport-search.html new file mode 100644 index 0000000..286d86e --- /dev/null +++ b/nitrostack/templates/flight-booking/widgets/out/airport-search.html @@ -0,0 +1,414 @@ + + + + + + + Airport search + + + + +
+

Airport search

Waiting for search_airports result.
+ + + diff --git a/nitrostack/templates/flight-booking/widgets/out/flight-details.html b/nitrostack/templates/flight-booking/widgets/out/flight-details.html new file mode 100644 index 0000000..b26de97 --- /dev/null +++ b/nitrostack/templates/flight-booking/widgets/out/flight-details.html @@ -0,0 +1,444 @@ + + + + + + + Flight details + + + + +
+

Flight details

Waiting for get_flight_details result.
+ + + diff --git a/nitrostack/templates/flight-booking/widgets/out/flight-search-results.html b/nitrostack/templates/flight-booking/widgets/out/flight-search-results.html new file mode 100644 index 0000000..c5f1c33 --- /dev/null +++ b/nitrostack/templates/flight-booking/widgets/out/flight-search-results.html @@ -0,0 +1,443 @@ + + + + + + + Flight search + + + + +
+

Flight search

Waiting for search_flights result.
+ + + diff --git a/nitrostack/templates/flight-booking/widgets/out/order-cancellation.html b/nitrostack/templates/flight-booking/widgets/out/order-cancellation.html new file mode 100644 index 0000000..d62275d --- /dev/null +++ b/nitrostack/templates/flight-booking/widgets/out/order-cancellation.html @@ -0,0 +1,416 @@ + + + + + + + Cancellation + + + + +
+

Cancellation

Waiting for cancel_order result.
+ + + diff --git a/nitrostack/templates/flight-booking/widgets/out/order-summary.html b/nitrostack/templates/flight-booking/widgets/out/order-summary.html new file mode 100644 index 0000000..d2892b9 --- /dev/null +++ b/nitrostack/templates/flight-booking/widgets/out/order-summary.html @@ -0,0 +1,474 @@ + + + + + + + Order summary + + + + +
+

Order summary

Waiting for order result.
+ + + diff --git a/nitrostack/templates/flight-booking/widgets/out/seat-selection.html b/nitrostack/templates/flight-booking/widgets/out/seat-selection.html new file mode 100644 index 0000000..d9269b7 --- /dev/null +++ b/nitrostack/templates/flight-booking/widgets/out/seat-selection.html @@ -0,0 +1,437 @@ + + + + + + + Seat map + + + + +
+

Seat map

Waiting for get_seat_map result.
+ + + diff --git a/nitrostack/templates/flight-booking/widgets/preview.html b/nitrostack/templates/flight-booking/widgets/preview.html new file mode 100644 index 0000000..5df9103 --- /dev/null +++ b/nitrostack/templates/flight-booking/widgets/preview.html @@ -0,0 +1,52 @@ + + + + + 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. +

+
+ +

+ + + + 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. -
-                    {JSON.stringify(data, null, 2)}
-                
-
- ); - } - - const toggleFavorite = (shopId: string) => { - const favorites = state?.favorites || []; - const newFavorites = favorites.includes(shopId) - ? favorites.filter(id => id !== shopId) - : [...favorites, shopId]; - - setState({ ...state, favorites: newFavorites }); - }; - - const handleShopClick = async (shopId: string) => { - // Call the show_pizza_shop tool to show shop details - await callTool('show_pizza_shop', { shopId }); - }; - - // Sort shops - let sortedShops = [...data.shops]; - switch (state?.sortBy) { - case 'rating': - sortedShops.sort((a, b) => b.rating - a.rating); - break; - case 'name': - sortedShops.sort((a, b) => a.name.localeCompare(b.name)); - break; - case 'price': - sortedShops.sort((a, b) => a.priceLevel - b.priceLevel); - break; - } - - return ( -
- {/* Header */} -
-
-
-

- 🍕 Pizza Shops -

-

- {data.totalShops} shops found -

-
- - {/* Sort and Filter Controls */} -
- - - -
-
- - {/* Filters */} - {showFilters && ( -
-

- Filters coming soon... -

-
- )} - - {/* Favorites Count */} - {state?.favorites && state.favorites.length > 0 && ( -
- ❤️ {state.favorites.length} favorite{state.favorites.length !== 1 ? 's' : ''} -
- )} -
- - {/* Horizontal Scrolling Shop Cards */} -
-
- {sortedShops.map(shop => ( -
- handleShopClick(shop.id)} - /> -
- ))} -
-
- - {/* Footer */} -
- Powered by NitroStack • Theme: {theme || 'light'} • Scroll horizontally → -
-
- ); -} diff --git a/nitrostack/templates/pizzaz/src/widgets/app/pizza-map/page.tsx b/nitrostack/templates/pizzaz/src/widgets/app/pizza-map/page.tsx deleted file mode 100644 index f1187d4..0000000 --- a/nitrostack/templates/pizzaz/src/widgets/app/pizza-map/page.tsx +++ /dev/null @@ -1,216 +0,0 @@ -'use client'; - -import { useTheme, useWidgetState, useMaxHeight, useDisplayMode, useWidgetSDK } from '@nitrostack/widgets'; - -// Disable static generation - this is a dynamic widget -export const dynamic = 'force-dynamic'; -import { useEffect, useRef, useState } from 'react'; -import mapboxgl from 'mapbox-gl'; -import 'mapbox-gl/dist/mapbox-gl.css'; -import { CompactShopCard } from '../../components/CompactShopCard'; -import { Maximize2 } from 'lucide-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[]; - filter: string; - totalShops: number; -} - -export default function PizzaMapWidget() { - const theme = useTheme(); - const maxHeight = useMaxHeight(); - const displayMode = useDisplayMode(); - const isDark = theme === 'dark'; - const mapContainer = useRef(null); - const map = useRef(null); - const [selectedShop, setSelectedShop] = useState(null); - - const { isReady, getToolOutput, callTool, requestFullscreen } = useWidgetSDK(); - - // Access tool output - const data = getToolOutput(); - - // Persistent state - const [state, setState] = useWidgetState<{ - favorites: string[]; - }>(() => ({ - favorites: [], - })); - - useEffect(() => { - if (!mapContainer.current || !data || map.current) return; - - // Initialize Mapbox - const initMap = async () => { - try { - // Set your Mapbox token here - mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN || 'YOUR_MAPBOX_TOKEN'; - - const mapInstance = new mapboxgl.Map({ - container: mapContainer.current!, - style: isDark ? 'mapbox://styles/mapbox/dark-v11' : 'mapbox://styles/mapbox/streets-v12', - center: data.shops[0]?.coords || [-122.4194, 37.7749], - zoom: 12, - }); - - // Add markers - data.shops.forEach(shop => { - const el = document.createElement('div'); - el.className = 'marker'; - el.style.backgroundImage = 'url(https://docs.mapbox.com/mapbox-gl-js/assets/custom_marker.png)'; - el.style.width = '30px'; - el.style.height = '40px'; - el.style.backgroundSize = '100%'; - el.style.cursor = 'pointer'; - - el.addEventListener('click', () => { - setSelectedShop(shop); - }); - - new mapboxgl.Marker(el) - .setLngLat(shop.coords) - .addTo(mapInstance); - }); - - // Fit bounds to show all markers - if (data.shops.length > 1) { - const bounds = new mapboxgl.LngLatBounds(); - data.shops.forEach(shop => bounds.extend(shop.coords)); - mapInstance.fitBounds(bounds, { padding: 50 }); - } - - map.current = mapInstance; - } catch (error) { - console.error('Failed to load Mapbox:', error); - } - }; - - initMap(); - - return () => { - if (map.current) { - map.current.remove(); - } - }; - }, [data, isDark]); - - if (!data) { - return ( -
- Loading map... {isReady ? '(SDK ready but no data)' : '(waiting for SDK)'} -
- ); - } - - const handleShopClick = async (shopId: string) => { - // Call the show_pizza_shop tool to show shop details - await callTool('show_pizza_shop', { shopId }); - }; - - const requestFullscreenMode = async () => { - await requestFullscreen(); - }; - - return ( -
- {/* Map Container - Full Screen */} -
- - {/* Enlarge Button */} - - - {/* Overlay Shop Cards - Bottom */} -
-
- {data.shops.map(shop => ( -
- { - setSelectedShop(shop); - handleShopClick(shop.id); - }} - isDark={isDark} - /> -
- ))} -
-
-
- ); -} diff --git a/nitrostack/templates/pizzaz/src/widgets/app/pizza-shop/page.tsx b/nitrostack/templates/pizzaz/src/widgets/app/pizza-shop/page.tsx deleted file mode 100644 index 4f2a731..0000000 --- a/nitrostack/templates/pizzaz/src/widgets/app/pizza-shop/page.tsx +++ /dev/null @@ -1,374 +0,0 @@ -'use client'; - -import { useTheme, useMaxHeight, useWidgetSDK } from '@nitrostack/widgets'; - -// Disable static generation - this is a dynamic widget -export const dynamic = 'force-dynamic'; -import { Star, MapPin, Phone, Globe, Clock, Heart, Share2, Navigation } from 'lucide-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 { - shop: PizzaShop; - relatedShops: PizzaShop[]; -} - -export default function PizzaShopWidget() { - const theme = useTheme(); - const maxHeight = useMaxHeight(); - const isDark = theme === 'dark'; - const { isReady, getToolOutput, openExternal } = useWidgetSDK(); - - // Access tool output - const data = getToolOutput(); - - if (!data) { - return ( -
- Loading shop details... {isReady ? '(SDK ready but no data)' : '(waiting for SDK)'} -
- ); - } - - const { shop, relatedShops } = data; - const priceSymbol = '$'.repeat(shop.priceLevel); - - const openMaps = () => { - const url = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(shop.address)}`; - openExternal(url); - }; - - const callPhone = () => { - openExternal(`tel:${shop.phone}`); - }; - - const visitWebsite = () => { - if (shop.website) { - openExternal(shop.website); - } - }; - - return ( -
- {/* Hero Image */} -
- {shop.name} -
- - {/* Shop Name Overlay */} -
-

- {shop.name} -

-
-
- - - {shop.rating} - - - ({shop.reviews} reviews) - -
- {priceSymbol} - {shop.openNow && ( - - Open Now - - )} -
-
-
- - {/* Content */} -
- {/* Description */} -

- {shop.description} -

- - {/* Cuisine Tags */} -
-

- Cuisine -

-
- {shop.cuisine.map(c => ( - - {c} - - ))} -
-
- - {/* Specialties */} -
-

- Specialties -

-
- {shop.specialties.map(s => ( - - 🍕 {s} - - ))} -
-
- - {/* Contact Info */} -
-

- Contact & Hours -

- - {/* Address */} -
- -
-

- {shop.address} -

- -
-
- - {/* Phone */} -
- - -
- - {/* Website */} - {shop.website && ( -
- - -
- )} - - {/* Hours */} -
- - - {shop.hours.open} - {shop.hours.close} - -
-
- - {/* Related Shops */} - {relatedShops.length > 0 && ( -
-

- You Might Also Like -

-
- {relatedShops.map(related => ( -
- {related.name} -
-

- {related.name} -

-
- - - {related.rating} - -
-

- {related.description} -

-
-
- ))} -
-
- )} -
-
- ); -} diff --git a/nitrostack/templates/pizzaz/src/widgets/components/CompactShopCard.tsx b/nitrostack/templates/pizzaz/src/widgets/components/CompactShopCard.tsx deleted file mode 100644 index 7799ba9..0000000 --- a/nitrostack/templates/pizzaz/src/widgets/components/CompactShopCard.tsx +++ /dev/null @@ -1,144 +0,0 @@ -'use client'; - -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 CompactShopCardProps { - shop: PizzaShop; - isSelected: boolean; - onClick: () => void; - isDark?: boolean; -} - -export function CompactShopCard({ shop, isSelected, onClick, isDark = true }: CompactShopCardProps) { - const priceSymbol = '$'.repeat(shop.priceLevel); - - return ( -
{ - if (!isSelected) { - e.currentTarget.style.transform = 'translateY(-2px)'; - e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.15)'; - } - }} - onMouseLeave={(e) => { - if (!isSelected) { - e.currentTarget.style.transform = 'translateY(0)'; - e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.1)'; - } - }} - > - {/* Image */} - {shop.name} - - {/* Info */} -
- {/* Name */} -

- {shop.name} -

- - {/* Description */} -

- {shop.description} -

- - {/* Rating & Price */} -
-
- - {shop.rating} -
- - {priceSymbol} - {shop.openNow && ( - <> - - - Open Now - - - )} -
-
-
- ); -} diff --git a/nitrostack/templates/pizzaz/src/widgets/components/PizzaCard.tsx b/nitrostack/templates/pizzaz/src/widgets/components/PizzaCard.tsx deleted file mode 100644 index 7821977..0000000 --- a/nitrostack/templates/pizzaz/src/widgets/components/PizzaCard.tsx +++ /dev/null @@ -1,191 +0,0 @@ -'use client'; - -import { useTheme, useWidgetState, useMaxHeight, useDisplayMode } from '@nitrostack/widgets'; -import { Star, MapPin, Phone, Globe, Clock } from 'lucide-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 PizzaCardProps { - shop: PizzaShop; - onSelect?: (shop: PizzaShop) => void; - isFavorite?: boolean; - onToggleFavorite?: (shopId: string) => void; -} - -export function PizzaCard({ shop, onSelect, isFavorite, onToggleFavorite }: PizzaCardProps) { - const theme = useTheme(); - const isDark = theme === 'dark'; - - const priceSymbol = '$'.repeat(shop.priceLevel); - - return ( -
onSelect?.(shop)} - style={{ - background: isDark ? '#1a1a1a' : '#ffffff', - border: `1px solid ${isDark ? '#333' : '#e5e7eb'}`, - borderRadius: '12px', - overflow: 'hidden', - cursor: onSelect ? 'pointer' : 'default', - transition: 'all 0.2s', - boxShadow: isDark ? '0 2px 8px rgba(0,0,0,0.3)' : '0 2px 8px rgba(0,0,0,0.1)', - }} - onMouseEnter={(e) => { - if (onSelect) { - e.currentTarget.style.transform = 'translateY(-2px)'; - e.currentTarget.style.boxShadow = isDark - ? '0 4px 12px rgba(0,0,0,0.4)' - : '0 4px 12px rgba(0,0,0,0.15)'; - } - }} - onMouseLeave={(e) => { - if (onSelect) { - e.currentTarget.style.transform = 'translateY(0)'; - e.currentTarget.style.boxShadow = isDark - ? '0 2px 8px rgba(0,0,0,0.3)' - : '0 2px 8px rgba(0,0,0,0.1)'; - } - }} - > - {/* Image */} -
- {shop.name} - {shop.openNow && ( -
- Open Now -
- )} - {onToggleFavorite && ( - - )} -
- - {/* Content */} -
-

- {shop.name} -

- - {/* Rating & Price */} -
-
- - - {shop.rating} - - - ({shop.reviews}) - -
- - {priceSymbol} - -
- - {/* Description */} -

- {shop.description} -

- - {/* Cuisine Tags */} -
- {shop.cuisine.slice(0, 3).map(c => ( - - {c} - - ))} -
- - {/* Address */} -
- - - {shop.address} - -
- - {/* Hours */} -
- - - {shop.hours.open} - {shop.hours.close} - -
-
-
- ); -} diff --git a/nitrostack/templates/pizzaz/src/widgets/next.config.js b/nitrostack/templates/pizzaz/src/widgets/next.config.js deleted file mode 100644 index f35620c..0000000 --- a/nitrostack/templates/pizzaz/src/widgets/next.config.js +++ /dev/null @@ -1,45 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { - reactStrictMode: true, - transpilePackages: ['nitrostack'], - - // Static export for production builds - ...(process.env.NODE_ENV === 'production' && { - output: 'export', - distDir: 'out', - images: { - unoptimized: true, - }, - }), - - // Development optimizations to prevent cache corruption - ...(process.env.NODE_ENV === 'development' && { - // Use memory cache instead of filesystem cache in dev to avoid stale chunks - webpack: (config, { isServer }) => { - // Disable persistent caching in development to prevent chunk reference errors - if (config.cache && config.cache.type === 'filesystem') { - config.cache = { - type: 'memory', - }; - } - - // Improve cache busting for new files - if (!isServer) { - config.cache = false; // Disable cache completely on client in dev - } - - return config; - }, - - // Disable build activity indicator which can cause issues - devIndicators: { - buildActivity: false, - buildActivityPosition: 'bottom-right', - }, - - // Faster dev server - compress: false, - }), -}; - -export default nextConfig; diff --git a/nitrostack/templates/pizzaz/src/widgets/package.json b/nitrostack/templates/pizzaz/src/widgets/package.json deleted file mode 100644 index 3367fd1..0000000 --- a/nitrostack/templates/pizzaz/src/widgets/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "pizzaz-widgets", - "version": "1.0.0", - "type": "module", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start" - }, - "dependencies": { - "next": "^14.2.5", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "@nitrostack/widgets": "^1", - "@modelcontextprotocol/ext-apps": ">=0.1.0", - "mapbox-gl": "^3.0.1", - "framer-motion": "^10.16.16", - "lucide-react": "^0.294.0" - }, - "devDependencies": { - "@types/node": "^20", - "@types/react": "^18", - "@types/react-dom": "^18", - "@types/mapbox-gl": "^3.0.0", - "typescript": "^5", - "tailwindcss": "^3.4.0", - "postcss": "^8.4.32", - "autoprefixer": "^10.4.16" - } -} \ No newline at end of file diff --git a/nitrostack/templates/pizzaz/src/widgets/tsconfig.json b/nitrostack/templates/pizzaz/src/widgets/tsconfig.json deleted file mode 100644 index 2c0ad66..0000000 --- a/nitrostack/templates/pizzaz/src/widgets/tsconfig.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "plugins": [ - { - "name": "next" - } - ], - "paths": { - "@/*": ["./*"] - } - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] -} - diff --git a/nitrostack/templates/pizzaz/src/widgets/widget-manifest.json b/nitrostack/templates/pizzaz/src/widgets/widget-manifest.json deleted file mode 100644 index b7f81be..0000000 --- a/nitrostack/templates/pizzaz/src/widgets/widget-manifest.json +++ /dev/null @@ -1,253 +0,0 @@ -{ - "version": "1.0.0", - "widgets": [ - { - "uri": "/pizza-map", - "name": "Pizza Map", - "description": "Interactive map showing pizza shop locations", - "examples": [ - { - "name": "All Shops", - "description": "Display all pizza shops on the map", - "data": { - "shops": [ - { - "id": "tonys-pizza", - "name": "Tony's New York Pizza", - "description": "Authentic New York-style pizza with a crispy thin crust", - "address": "1570 Stockton St, San Francisco, CA 94133", - "coords": [ - -122.4106, - 37.8006 - ], - "rating": 4.5, - "reviews": 1250, - "priceLevel": 2, - "cuisine": [ - "Italian", - "Pizza", - "New York Style" - ], - "hours": { - "open": "11:00 AM", - "close": "10:00 PM" - }, - "phone": "(415) 835-9888", - "website": "https://tonyspizzasf.com", - "image": "https://images.unsplash.com/photo-1513104890138-7c749659a591", - "specialties": [ - "Margherita", - "Pepperoni", - "White Pizza" - ], - "openNow": true - }, - { - "id": "bella-napoli", - "name": "Bella Napoli", - "description": "Traditional Neapolitan pizza baked in a wood-fired oven", - "address": "3854 Geary Blvd, San Francisco, CA 94118", - "coords": [ - -122.4603, - 37.7808 - ], - "rating": 4.7, - "reviews": 890, - "priceLevel": 3, - "cuisine": [ - "Italian", - "Pizza", - "Neapolitan" - ], - "hours": { - "open": "12:00 PM", - "close": "9:00 PM" - }, - "phone": "(415) 221-0305", - "image": "https://images.unsplash.com/photo-1574071318508-1cdbab80d002", - "specialties": [ - "Marinara", - "Quattro Formaggi", - "Prosciutto e Funghi" - ], - "openNow": true - } - ], - "filter": "all", - "totalShops": 2 - } - } - ], - "tags": [ - "map", - "location", - "interactive" - ] - }, - { - "uri": "/pizza-list", - "name": "Pizza List", - "description": "List view of pizza shops with filtering and sorting", - "examples": [ - { - "name": "All Shops List", - "description": "Display all shops in a list", - "data": { - "shops": [ - { - "id": "tonys-pizza", - "name": "Tony's New York Pizza", - "description": "Authentic New York-style pizza with a crispy thin crust", - "address": "1570 Stockton St, San Francisco, CA 94133", - "coords": [ - -122.4106, - 37.8006 - ], - "rating": 4.5, - "reviews": 1250, - "priceLevel": 2, - "cuisine": [ - "Italian", - "Pizza", - "New York Style" - ], - "hours": { - "open": "11:00 AM", - "close": "10:00 PM" - }, - "phone": "(415) 835-9888", - "website": "https://tonyspizzasf.com", - "image": "https://images.unsplash.com/photo-1513104890138-7c749659a591", - "specialties": [ - "Margherita", - "Pepperoni", - "White Pizza" - ], - "openNow": true - }, - { - "id": "bella-napoli", - "name": "Bella Napoli", - "description": "Traditional Neapolitan pizza baked in a wood-fired oven", - "address": "3854 Geary Blvd, San Francisco, CA 94118", - "coords": [ - -122.4603, - 37.7808 - ], - "rating": 4.7, - "reviews": 890, - "priceLevel": 3, - "cuisine": [ - "Italian", - "Pizza", - "Neapolitan" - ], - "hours": { - "open": "12:00 PM", - "close": "9:00 PM" - }, - "phone": "(415) 221-0305", - "image": "https://images.unsplash.com/photo-1574071318508-1cdbab80d002", - "specialties": [ - "Marinara", - "Quattro Formaggi", - "Prosciutto e Funghi" - ], - "openNow": true - } - ], - "filters": {}, - "totalShops": 2 - } - } - ], - "tags": [ - "list", - "filter", - "sort" - ] - }, - { - "uri": "/pizza-shop", - "name": "Pizza Shop Details", - "description": "Detailed information about a specific pizza shop", - "examples": [ - { - "name": "Shop Detail", - "description": "Show details for a specific shop", - "data": { - "shop": { - "id": "tonys-pizza", - "name": "Tony's New York Pizza", - "description": "Authentic New York-style pizza with a crispy thin crust", - "address": "1570 Stockton St, San Francisco, CA 94133", - "coords": [ - -122.4106, - 37.8006 - ], - "rating": 4.5, - "reviews": 1250, - "priceLevel": 2, - "cuisine": [ - "Italian", - "Pizza", - "New York Style" - ], - "hours": { - "open": "11:00 AM", - "close": "10:00 PM" - }, - "phone": "(415) 835-9888", - "website": "https://tonyspizzasf.com", - "image": "https://images.unsplash.com/photo-1513104890138-7c749659a591", - "specialties": [ - "Margherita", - "Pepperoni", - "White Pizza" - ], - "openNow": true - }, - "relatedShops": [ - { - "id": "bella-napoli", - "name": "Bella Napoli", - "description": "Traditional Neapolitan pizza baked in a wood-fired oven", - "address": "3854 Geary Blvd, San Francisco, CA 94118", - "coords": [ - -122.4603, - 37.7808 - ], - "rating": 4.7, - "reviews": 890, - "priceLevel": 3, - "cuisine": [ - "Italian", - "Pizza", - "Neapolitan" - ], - "hours": { - "open": "12:00 PM", - "close": "9:00 PM" - }, - "phone": "(415) 221-0305", - "image": "https://images.unsplash.com/photo-1574071318508-1cdbab80d002", - "specialties": [ - "Marinara", - "Quattro Formaggi", - "Prosciutto e Funghi" - ], - "openNow": true - } - ] - } - } - ], - "tags": [ - "detail", - "info", - "contact" - ] - } - ], - "generatedAt": "2025-12-03T00:00:00.000Z" -} \ No newline at end of file diff --git a/nitrostack/templates/pizzaz/widgets/out/pizza-list.html b/nitrostack/templates/pizzaz/widgets/out/pizza-list.html new file mode 100644 index 0000000..59d1f80 --- /dev/null +++ b/nitrostack/templates/pizzaz/widgets/out/pizza-list.html @@ -0,0 +1,451 @@ + + + + + + + Pizza shops + + + + +
+

Pizza shops

Waiting for show_pizza_list result.
+ + + diff --git a/nitrostack/templates/pizzaz/widgets/out/pizza-map.html b/nitrostack/templates/pizzaz/widgets/out/pizza-map.html new file mode 100644 index 0000000..0e51d30 --- /dev/null +++ b/nitrostack/templates/pizzaz/widgets/out/pizza-map.html @@ -0,0 +1,497 @@ + + + + + + + Pizza map + + + + + + +
+
Waiting for show_pizza_map result.
+ + + diff --git a/nitrostack/templates/pizzaz/widgets/out/pizza-shop.html b/nitrostack/templates/pizzaz/widgets/out/pizza-shop.html new file mode 100644 index 0000000..f4ee3d8 --- /dev/null +++ b/nitrostack/templates/pizzaz/widgets/out/pizza-shop.html @@ -0,0 +1,445 @@ + + + + + + + Pizza shop + + + + +
+

Pizza shop

Waiting for show_pizza_shop result.

+ + + diff --git a/nitrostack/templates/pizzaz/widgets/preview.html b/nitrostack/templates/pizzaz/widgets/preview.html new file mode 100644 index 0000000..16e24d5 --- /dev/null +++ b/nitrostack/templates/pizzaz/widgets/preview.html @@ -0,0 +1,65 @@ + + + + + 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. +

+
+ +

+ + + + diff --git a/nitrostack/templates/starter/.env b/nitrostack/templates/starter/.env index 766252b..34f7eb5 100644 --- a/nitrostack/templates/starter/.env +++ b/nitrostack/templates/starter/.env @@ -1,2 +1,3 @@ PORT=3000 NODE_ENV=development +NITROSTACK_APP_MODE=universal diff --git a/nitrostack/templates/starter/modules/calculator/calculator_tools.py b/nitrostack/templates/starter/modules/calculator/calculator_tools.py index 3044d87..aeb988f 100644 --- a/nitrostack/templates/starter/modules/calculator/calculator_tools.py +++ b/nitrostack/templates/starter/modules/calculator/calculator_tools.py @@ -15,6 +15,14 @@ class ConvertTemperatureInput(BaseModel): to_unit: Literal["celsius", "fahrenheit", "kelvin"] = Field(description="Unit to convert to") +class CalculateOutput(BaseModel): + operation: str + a: float + b: float + result: float + expression: str + + def _to_celsius(value: float, from_unit: str) -> float: if from_unit == "celsius": return value @@ -36,7 +44,8 @@ class CalculatorTools: @tool( name="calculate", description="Perform basic arithmetic calculations", - input_schema=CalculateInput + input_schema=CalculateInput, + output_schema=CalculateOutput, ) @widget("calculator-result") async def calculate(self, input: CalculateInput, context: ExecutionContext) -> dict: diff --git a/nitrostack/templates/starter/src/widgets/app/calculator-result/page.tsx b/nitrostack/templates/starter/src/widgets/app/calculator-result/page.tsx deleted file mode 100644 index 6d9859e..0000000 --- a/nitrostack/templates/starter/src/widgets/app/calculator-result/page.tsx +++ /dev/null @@ -1,180 +0,0 @@ -'use client'; - -import { useTheme, useWidgetState, useWidgetSDK } from '@nitrostack/widgets'; - -/** - * Example widget demonstrating NitroStack Widget SDK - * This widget is fully compatible with OpenAI ChatGPT - */ - -interface CalculatorData { - operation: string; - a: number; - b: number; - result: number; - expression: string; -} - -export default function CalculatorResult() { - // Use Widget SDK hooks - const theme = useTheme(); - const { getToolOutput } = useWidgetSDK(); - const [state, setState] = useWidgetState<{ viewMode: 'compact' | 'detailed' }>(() => ({ - viewMode: 'detailed' - })); - - // Access tool output from Widget SDK - const data = getToolOutput(); - - if (!data) { - return ( -
- Loading... -
- ); - } - - const getOperationColor = (op: string) => { - const colors: Record = { - add: '#10b981', - subtract: '#f59e0b', - multiply: '#3b82f6', - divide: '#8b5cf6' - }; - return colors[op] || '#6b7280'; - }; - - const getOperationIcon = (op: string) => { - const icons: Record = { - add: '➕', - subtract: '➖', - multiply: '✖️', - divide: '➗' - }; - return icons[op] || '🔢'; - }; - - const isDark = theme === 'dark'; - const bgColor = isDark ? '#1a1a1a' : '#ffffff'; - const textColor = isDark ? '#ffffff' : '#000000'; - const mutedColor = isDark ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)'; - - return ( -
-
-
- - {getOperationIcon(data.operation)} - -
-

- Calculator Result -

-

- {data.operation.charAt(0).toUpperCase() + data.operation.slice(1)} -

-
-
- - {/* View mode toggle */} - -
- -
-
- {data.expression} -
- - {state?.viewMode === 'detailed' && ( -
-
-
First
-
{data.a}
-
-
-
Second
-
{data.b}
-
-
-
Result
-
- {data.result} -
-
-
- )} -
- -
- ✨ NitroStack Calculator - - Theme: {theme || 'light'} | Mode: {state?.viewMode || 'detailed'} - -
-
- ); -} diff --git a/nitrostack/templates/starter/src/widgets/app/layout.tsx b/nitrostack/templates/starter/src/widgets/app/layout.tsx deleted file mode 100644 index 40b03ef..0000000 --- a/nitrostack/templates/starter/src/widgets/app/layout.tsx +++ /dev/null @@ -1,18 +0,0 @@ -'use client'; - -import { WidgetLayout } from '@nitrostack/widgets'; - - -export default function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( - - - {children} - - - ); -} diff --git a/nitrostack/templates/starter/src/widgets/next.config.js b/nitrostack/templates/starter/src/widgets/next.config.js deleted file mode 100644 index f35620c..0000000 --- a/nitrostack/templates/starter/src/widgets/next.config.js +++ /dev/null @@ -1,45 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { - reactStrictMode: true, - transpilePackages: ['nitrostack'], - - // Static export for production builds - ...(process.env.NODE_ENV === 'production' && { - output: 'export', - distDir: 'out', - images: { - unoptimized: true, - }, - }), - - // Development optimizations to prevent cache corruption - ...(process.env.NODE_ENV === 'development' && { - // Use memory cache instead of filesystem cache in dev to avoid stale chunks - webpack: (config, { isServer }) => { - // Disable persistent caching in development to prevent chunk reference errors - if (config.cache && config.cache.type === 'filesystem') { - config.cache = { - type: 'memory', - }; - } - - // Improve cache busting for new files - if (!isServer) { - config.cache = false; // Disable cache completely on client in dev - } - - return config; - }, - - // Disable build activity indicator which can cause issues - devIndicators: { - buildActivity: false, - buildActivityPosition: 'bottom-right', - }, - - // Faster dev server - compress: false, - }), -}; - -export default nextConfig; diff --git a/nitrostack/templates/starter/src/widgets/package.json b/nitrostack/templates/starter/src/widgets/package.json deleted file mode 100644 index eac94fe..0000000 --- a/nitrostack/templates/starter/src/widgets/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "calculator-widgets", - "version": "1.0.0", - "type": "module", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start" - }, - "dependencies": { - "next": "^14.2.5", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "@nitrostack/widgets": "^1", - "@modelcontextprotocol/ext-apps": ">=0.1.0" - }, - "devDependencies": { - "@types/node": "^20", - "@types/react": "^18", - "@types/react-dom": "^18", - "typescript": "^5" - } -} - diff --git a/nitrostack/templates/starter/src/widgets/tsconfig.json b/nitrostack/templates/starter/src/widgets/tsconfig.json deleted file mode 100644 index 2c0ad66..0000000 --- a/nitrostack/templates/starter/src/widgets/tsconfig.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "plugins": [ - { - "name": "next" - } - ], - "paths": { - "@/*": ["./*"] - } - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] -} - diff --git a/nitrostack/templates/starter/src/widgets/widget-manifest.json b/nitrostack/templates/starter/src/widgets/widget-manifest.json deleted file mode 100644 index 1fc23e5..0000000 --- a/nitrostack/templates/starter/src/widgets/widget-manifest.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "version": "1.0.0", - "widgets": [ - { - "uri": "/calculator-result", - "name": "Calculator Result", - "description": "Displays the result of a calculation with operation details", - "examples": [ - { - "name": "Addition Example", - "description": "Shows the result of adding 5 + 3", - "data": { - "operation": "add", - "a": 5, - "b": 3, - "result": 8, - "expression": "5 + 3 = 8" - } - }, - { - "name": "Multiplication Example", - "description": "Shows the result of multiplying 6 × 7", - "data": { - "operation": "multiply", - "a": 6, - "b": 7, - "result": 42, - "expression": "6 × 7 = 42" - } - }, - { - "name": "Division Example", - "description": "Shows the result of dividing 20 ÷ 4", - "data": { - "operation": "divide", - "a": 20, - "b": 4, - "result": 5, - "expression": "20 ÷ 4 = 5" - } - } - ], - "tags": ["calculator", "math", "result"] - } - ], - "generatedAt": "2025-01-01T00:00:00.000Z" -} - diff --git a/nitrostack/templates/starter/widgets/out/calculator-result.html b/nitrostack/templates/starter/widgets/out/calculator-result.html new file mode 100644 index 0000000..c406946 --- /dev/null +++ b/nitrostack/templates/starter/widgets/out/calculator-result.html @@ -0,0 +1,396 @@ + + + + + + + Calculator result + + + + +
+
+ + + diff --git a/nitrostack/templates/starter/widgets/preview.html b/nitrostack/templates/starter/widgets/preview.html new file mode 100644 index 0000000..9f8a06b --- /dev/null +++ b/nitrostack/templates/starter/widgets/preview.html @@ -0,0 +1,52 @@ + + + + + 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. +

+
+ +

+ + + + 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 live tools/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. +

+
+ + +
+ + + +

structuredContent

+
Waiting for a tool call…
+ + + +""" + + +def render_preview_page(tools: list) -> str: + from nitrostack.widgets.html_util import json_for_inline_script + + return PREVIEW_PAGE_HTML.replace("__TOOLS__", json_for_inline_script(tools)) diff --git a/nitrostack/widgets/route_templates.py b/nitrostack/widgets/route_templates.py new file mode 100644 index 0000000..6b36120 --- /dev/null +++ b/nitrostack/widgets/route_templates.py @@ -0,0 +1,870 @@ +"""Built-in per-route widget HTML generated by Python (no npm/React build).""" + +from __future__ import annotations + +from typing import Any, Callable, Dict, Optional + +from nitrostack.widgets.host_bridge import wrap_widget_page +from nitrostack.widgets.views import missing_body, render_body + + +def _generic_render_js() -> str: + return """ +window.__nitroWidgetRender = function(data) { + const root = document.getElementById("root"); + const meta = document.getElementById("meta"); + if (!root) return; + if (!data) { + if (meta) meta.textContent = "Waiting for tool result (host injects tool output)."; + return; + } + if (meta) meta.textContent = "Tool output"; + root.innerHTML = ""; + const pre = document.createElement("pre"); + pre.textContent = JSON.stringify(data, null, 2); + root.appendChild(pre); +}; +""".strip() + + +_PIZZA_LIST_CSS = """ + :root { --ns-accent: #ea580c; --ns-accent-soft: #ffedd5; } + .shop { min-width: 260px; max-width: 260px; scroll-snap-align: start; flex-shrink: 0; } + .img { width: 100%; height: 120px; object-fit: cover; border-radius: 10px; margin-bottom: 8px; background: var(--ns-accent-soft); } + .row { display: flex; justify-content: space-between; gap: 8px; align-items: baseline; } + .name { font-weight: 700; } + .addr { color: var(--ns-muted); font-size: 0.85rem; margin-top: 4px; } +""" + +_PIZZA_MAP_CSS = """ + :root { --ns-accent: #ea580c; } + html, body { width: 100%; height: 560px; } + body { position: relative; min-height: 560px; background: #e5e7eb; } + .meta { position: absolute; top: 12px; left: 12px; z-index: 3; margin: 0; + background: color-mix(in srgb, var(--ns-surface) 92%, transparent); border-radius: 8px; padding: 6px 10px; + box-shadow: var(--ns-shadow); } + .map { position: absolute; inset: 0; width: 100%; height: 560px; min-height: 560px; } + .map-live { position: absolute; inset: 0; } + .static-map { position: absolute; inset: 0; z-index: 1; width: 100%; height: 560px; + object-fit: cover; transition: opacity .4s ease; } + .static-map.is-ready { opacity: 0; pointer-events: none; } + .mapboxgl-map { width: 100%; height: 100%; } + .cards { position: absolute; left: 16px; right: 16px; bottom: 8px; z-index: 3; } + .map-card { min-width: 220px; max-width: 220px; flex-shrink: 0; scroll-snap-align: start; } + .map-card .name { font-weight: 700; } + .map-card .muted { margin-top: 4px; } + .map-card .rating { margin-top: 4px; } +""" + +_PIZZA_SHOP_CSS = """ + :root { --ns-accent: #e11d48; --ns-accent-soft: #ffe4e6; } + .card { background: var(--ns-surface); border-radius: var(--ns-radius); padding: 16px; border: 1px solid var(--ns-border); } + .hero { width: 100%; max-height: 180px; object-fit: cover; border-radius: 10px; margin-bottom: 12px; } + .chips { margin-top: 10px; } + .chip { margin: 0 4px 4px 0; } +""" + +_CALC_CSS = """ + :root { --ns-accent: #15803d; } + .card { background: var(--ns-surface); border: 1px solid var(--ns-border); border-radius: var(--ns-radius); padding: 20px; text-align: center; } + .expr { font-size: 0.95rem; color: var(--ns-muted); margin-bottom: 8px; } + .result { font-size: 2rem; font-weight: 800; color: var(--ns-accent); } +""" + +_CARD_CSS = """ + .card { background: var(--ns-surface); border-radius: var(--ns-radius); padding: 20px; box-shadow: var(--ns-shadow); max-width: 320px; border: 1px solid var(--ns-border); } + .card h2 { margin: 0 0 8px; font-size: 1.25rem; } + .price { font-size: 1.5rem; } + .desc { color: var(--ns-muted); margin-top: 8px; } +""" + +_TABLE_CSS = """ + table { border-collapse: collapse; width: 100%; max-width: 480px; background: var(--ns-surface); border-radius: 10px; overflow: hidden; } + th, td { border: 1px solid var(--ns-border); padding: 8px 12px; text-align: left; } + th { background: var(--ns-accent-soft); } +""" + +_CHART_CSS = """ + .chart { display: flex; align-items: flex-end; gap: 8px; height: 160px; } + .bar-wrap { flex: 1; display: flex; flex-direction: column; align-items: center; } + .bar { width: 100%; background: var(--ns-accent, #3b82f6); border-radius: 4px 4px 0 0; min-height: 4px; } + .label { font-size: 12px; margin-top: 4px; color: var(--ns-muted); } +""" + +_FLIGHT_CSS = """ + :root { --ns-accent: #0369a1; --ns-accent-soft: #e0f2fe; } + .offer, .card, #list .row { background: var(--ns-surface); border: 1px solid var(--ns-border); border-radius: var(--ns-radius); padding: 12px; margin-bottom: 10px; } + .row { display: flex; gap: 12px; align-items: center; } + .id { font-size: 0.8rem; color: var(--ns-muted); } + .code { font-size: 1.1rem; font-weight: 800; min-width: 3rem; } + .slice { margin-top: 8px; } + .seat-row { display: flex; gap: 6px; align-items: center; margin: 4px 0; } + .rn { width: 2rem; font-size: 0.8rem; color: var(--ns-muted); } + .seat { display: inline-block; min-width: 2.4rem; text-align: center; padding: 4px; border-radius: 6px; font-size: 0.75rem; } +""" + +_GENERIC_CSS = """ + pre { background: var(--ns-surface); border: 1px solid var(--ns-border); border-radius: 8px; padding: 12px; overflow: auto; } +""" + +_MISSING_CSS = """ + :root { --ns-bg: #fef2f2; --ns-text: #7f1d1d; } + .meta { font-size: 0.9rem; line-height: 1.5; } +""" + +_PIZZA_LIST_JS = """ +window.__nitroWidgetRender = function(data) { + const list = document.getElementById("list"); + const meta = document.getElementById("meta"); + if (!list || !meta) return; + list.innerHTML = ""; + if (!data || !Array.isArray(data.shops)) { + meta.textContent = "Waiting for show_pizza_list result."; + return; + } + const state = (window.openai && window.openai.widgetState) || {}; + const sort = state.sort || "rating"; + const shops = data.shops.slice().sort(function(a, b) { + if (sort === "name") return String(a.name || "").localeCompare(String(b.name || "")); + return (b.rating || 0) - (a.rating || 0); + }); + document.querySelectorAll("#sorts [data-sort]").forEach(function(btn) { + btn.classList.toggle("is-on", btn.getAttribute("data-sort") === sort); + }); + const total = data.totalShops ?? data.shops.length; + meta.textContent = total + " shop" + (total === 1 ? "" : "s"); + if (!shops.length) { + list.innerHTML = '
No shops match these filters.
'; + return; + } + shops.forEach(function(shop) { + const el = document.createElement("article"); + el.className = "shop ns-card"; + el.setAttribute("data-call-tool", "show_pizza_shop"); + el.setAttribute("data-args", JSON.stringify({ shopId: shop.id || "" })); + el.setAttribute("role", "button"); + el.tabIndex = 0; + const open = shop.openNow + ? 'Open' + : 'Closed'; + const price = "$".repeat(shop.priceLevel || 1); + let img = ""; + if (shop.image) { + img = ''; + } + el.innerHTML = + img + + '
' + + '
' + open + '
'; + el.querySelector(".name").textContent = shop.name || shop.id || "Shop"; + el.querySelector(".rating").textContent = shop.rating != null ? "★ " + shop.rating : ""; + el.querySelector(".addr").textContent = shop.address || ""; + el.querySelector(".price").textContent = price; + list.appendChild(el); + }); +}; +document.addEventListener("click", function(ev) { + const btn = ev.target.closest("#sorts [data-sort]"); + if (!btn || !window.nitrostack) return; + const next = Object.assign({}, (window.openai && window.openai.widgetState) || {}, { + sort: btn.getAttribute("data-sort") + }); + window.nitrostack.setWidgetState(next); + if (typeof window.__nitroWidgetRender === "function" && window.nitrostack.readHostData) { + window.__nitroWidgetRender(window.nitrostack.readHostData()); + } +}); +""".strip() + +_MAPBOX_HEAD = """ + + +""".strip() + + +def _pizza_map_js() -> str: + import json + + from nitrostack.widgets.html_util import get_mapbox_token + + token = json.dumps(get_mapbox_token()) + return f""" +window.__NITRO_MAPBOX_TOKEN = {token}; +window.__nitroWidgetRender = function(data) {{ + const meta = document.getElementById("meta"); + const mapEl = document.getElementById("map-live") || document.getElementById("map"); + const cards = document.getElementById("cards"); + const staticEl = document.getElementById("static-map"); + if (!mapEl) return; + if (!data || !Array.isArray(data.shops) || !data.shops.length) {{ + if (meta) meta.textContent = "Waiting for show_pizza_map result."; + return; + }} + const shops = data.shops.filter(function(s) {{ return Array.isArray(s.coords) && s.coords.length === 2; }}); + const sig = (data.filter || "all") + "|" + shops.map(function(s) {{ + return (s.id || "") + ":" + s.coords[0] + "," + s.coords[1]; + }}).join(";"); + if (window.__nitroMapSig === sig && window.__nitroMap) return; + if (meta) meta.textContent = (data.totalShops ?? data.shops.length) + " shops · filter: " + (data.filter || "all"); + if (cards && !cards.querySelector(".map-card")) {{ + data.shops.forEach(function(shop) {{ + const card = document.createElement("article"); + card.className = "map-card ns-card"; + card.setAttribute("data-shop-id", shop.id || ""); + card.setAttribute("data-call-tool", "show_pizza_shop"); + card.setAttribute("data-args", JSON.stringify({{ shopId: shop.id || "" }})); + card.setAttribute("role", "button"); + card.tabIndex = 0; + card.innerHTML = '
'; + card.querySelector(".name").textContent = shop.name || shop.id || "Shop"; + card.querySelector(".muted").textContent = shop.address || ""; + card.querySelector(".rating").textContent = shop.rating != null ? "★ " + shop.rating : ""; + cards.appendChild(card); + }}); + }} + function revealLive() {{ + if (staticEl) staticEl.classList.add("is-ready"); + }} + function mountMap(attempt) {{ + if (window.__nitroMapSig === sig && window.__nitroMap) return; + if (!window.__NITRO_MAPBOX_TOKEN) {{ + if (meta) meta.textContent = "Set MAPBOX_TOKEN in .env to load the map."; + if (mapEl) {{ + mapEl.innerHTML = '
Set MAPBOX_TOKEN in .env (Mapbox public pk. token) to load the map.
'; + }} + return; + }} + if (typeof mapboxgl === "undefined") {{ + if ((attempt || 0) < 80) {{ + setTimeout(function() {{ mountMap((attempt || 0) + 1); }}, 50); + }} + return; + }} + window.__nitroMapSig = sig; + if (window.__nitroMap) {{ + window.__nitroMap.remove(); + window.__nitroMap = null; + }} + mapboxgl.accessToken = window.__NITRO_MAPBOX_TOKEN; + const first = (shops[0] && shops[0].coords) || [-122.4194, 37.7749]; + const map = new mapboxgl.Map({{ + container: mapEl, + style: "mapbox://styles/mapbox/streets-v12", + center: first, + zoom: 12, + cooperativeGestures: false, + fadeDuration: 0 + }}); + map.addControl(new mapboxgl.NavigationControl({{ showCompass: false }}), "top-right"); + map.addControl(new mapboxgl.FullscreenControl(), "top-right"); + shops.forEach(function(shop) {{ + const el = document.createElement("div"); + el.style.backgroundImage = "url(https://docs.mapbox.com/mapbox-gl-js/assets/custom_marker.png)"; + el.style.width = "30px"; + el.style.height = "40px"; + el.style.backgroundSize = "100%"; + el.style.cursor = "pointer"; + el.title = shop.name || shop.id || ""; + el.addEventListener("click", function() {{ + if (window.nitrostack) window.nitrostack.callTool("show_pizza_shop", {{ shopId: shop.id || "" }}); + }}); + new mapboxgl.Marker(el).setLngLat(shop.coords).addTo(map); + }}); + if (shops.length > 1) {{ + const bounds = new mapboxgl.LngLatBounds(); + shops.forEach(function(s) {{ bounds.extend(s.coords); }}); + map.fitBounds(bounds, {{ padding: 60, maxZoom: 14, duration: 0 }}); + }} + map.once("idle", function() {{ + try {{ map.resize(); }} catch (e) {{}} + revealLive(); + }}); + window.__nitroMap = map; + }} + mountMap(0); +}}; +""".strip() + +_PIZZA_SHOP_JS = """ +window.__nitroWidgetRender = function(data) { + const shop = data && (data.shop || data); + const hero = document.getElementById("hero"); + if (!shop || !shop.name) { + const desc = document.getElementById("desc"); + if (desc) desc.textContent = "Waiting for show_pizza_shop result."; + return; + } + if (shop.image && hero) { + hero.src = shop.image; + hero.style.display = "block"; + } else if (hero) { + hero.style.display = "none"; + } + document.getElementById("name").textContent = shop.name; + document.getElementById("rating").textContent = + shop.rating != null ? "★ " + shop.rating + " (" + (shop.reviews || 0) + " reviews)" : ""; + document.getElementById("desc").textContent = shop.description || ""; + document.getElementById("addr").textContent = shop.address || ""; + const hours = shop.hours ? (shop.hours.open + " – " + shop.hours.close) : ""; + document.getElementById("hours").textContent = hours ? "Hours: " + hours : ""; + document.getElementById("phone").textContent = shop.phone || ""; + const chips = document.getElementById("chips"); + chips.innerHTML = ""; + (shop.specialties || []).forEach(function(item) { + const chip = document.createElement("span"); + chip.className = "chip"; + chip.textContent = item; + chips.appendChild(chip); + }); + const actions = document.getElementById("actions"); + if (actions) { + actions.innerHTML = ""; + function addLink(label, url, primary) { + if (!url) return; + const a = document.createElement("a"); + a.className = primary ? "ns-btn ns-btn-primary" : "ns-btn"; + a.setAttribute("data-open-link", url); + a.href = url; + a.textContent = label; + actions.appendChild(a); + } + let maps = ""; + if (Array.isArray(shop.coords) && shop.coords.length === 2) { + maps = "https://www.google.com/maps/search/?api=1&query=" + shop.coords[1] + "," + shop.coords[0]; + } else if (shop.address) { + maps = "https://www.google.com/maps/search/?api=1&query=" + encodeURIComponent(shop.address); + } + addLink("Maps", maps, false); + if (shop.phone) { + const tel = String(shop.phone).replace(/[^0-9+]/g, ""); + if (tel) addLink("Call", "tel:" + tel, false); + } + addLink("Website", shop.website || "", true); + } +}; +""".strip() + +_CALC_JS = """ +window.__nitroWidgetRender = function(data) { + if (!data) return; + const expr = document.getElementById("expr"); + const result = document.getElementById("result"); + if (data.expression) expr.textContent = data.expression; + if (data.result != null) result.textContent = String(data.result); + else if (data.value != null) result.textContent = String(data.value); +}; +""".strip() + +_CARD_JS = """ +window.__nitroWidgetRender = function(data) { + if (!data) return; + const name = document.getElementById("name"); + const price = document.getElementById("price"); + const desc = document.getElementById("desc"); + if (name) name.textContent = data.name || "—"; + if (price) price.textContent = data.price != null ? "$" + Number(data.price).toFixed(2) : "—"; + if (desc) desc.textContent = data.description || ""; +}; +""".strip() + +_TABLE_JS = """ +window.__nitroWidgetRender = function(data) { + if (!data || !Array.isArray(data.rows)) return; + const cols = data.columns || Object.keys(data.rows[0] || {}); + const header = document.getElementById("header"); + const body = document.getElementById("body"); + if (!header || !body) return; + header.innerHTML = ""; + body.innerHTML = ""; + cols.forEach(function(c) { + const th = document.createElement("th"); + th.textContent = c; + header.appendChild(th); + }); + data.rows.forEach(function(row) { + const tr = document.createElement("tr"); + cols.forEach(function(c) { + const td = document.createElement("td"); + td.textContent = row[c] ?? ""; + tr.appendChild(td); + }); + body.appendChild(tr); + }); +}; +""".strip() + +_CHART_JS = """ +window.__nitroWidgetRender = function(data) { + if (!data) return; + const title = document.getElementById("title"); + const chart = document.getElementById("chart"); + if (title) title.textContent = data.title || "Chart"; + if (!chart) return; + chart.innerHTML = ""; + const items = data.items || []; + const max = Math.max.apply(null, items.map(function(i) { return i.value || 0; }).concat([1])); + items.forEach(function(item) { + const wrap = document.createElement("div"); + wrap.className = "bar-wrap"; + const bar = document.createElement("div"); + bar.className = "bar"; + bar.style.height = (((item.value || 0) / max) * 140) + "px"; + const label = document.createElement("div"); + label.className = "label"; + label.textContent = item.label || ""; + wrap.appendChild(bar); + wrap.appendChild(label); + chart.appendChild(wrap); + }); +}; +""".strip() + + +_FLIGHT_SEARCH_JS = """ +window.__nitroWidgetRender = function(data) { + const list = document.getElementById("list"); + const meta = document.getElementById("meta"); + if (!list) return; + const offers = (data && (data.offers || data.results)) || []; + const params = (data && data.searchParams) || {}; + const total = (data && data.totalOffers != null) ? data.totalOffers : offers.length; + if (meta) { + meta.textContent = (params.origin && params.destination) + ? (params.origin + " → " + params.destination + " · " + total + " offer" + (total === 1 ? "" : "s")) + : (total + " offer" + (total === 1 ? "" : "s")); + } + list.innerHTML = ""; + if (!offers.length) { + list.innerHTML = '
No flight offers.
'; + return; + } + function code(p) { + if (!p) return ""; + if (typeof p === "string") return p; + return p.iata_code || p.iataCode || p.code || ""; + } + function legs(offer) { + if (offer.slices && offer.slices.length) return offer.slices; + const out = []; + if (offer.outbound) out.push(offer.outbound); + if (offer.return) out.push(offer.return); + return out; + } + offers.forEach(function(offer) { + const el = document.createElement("article"); + el.className = "offer ns-card"; + el.setAttribute("data-call-tool", "get_flight_details"); + el.setAttribute("data-args", JSON.stringify({ offerId: offer.id || "" })); + el.setAttribute("role", "button"); + el.tabIndex = 0; + const amount = offer.total_amount || offer.totalAmount || ""; + const currency = offer.total_currency || offer.totalCurrency || ""; + el.innerHTML = '
'; + el.querySelector(".price").textContent = amount + (currency ? " " + currency : ""); + el.querySelector(".id").textContent = offer.id || ""; + legs(offer).forEach(function(sl) { + const s = document.createElement("div"); + s.className = "slice"; + s.textContent = code(sl.origin) + " → " + code(sl.destination) + " · " + (sl.duration || ""); + el.appendChild(s); + }); + list.appendChild(el); + }); +}; +""".strip() + +_AIRPORT_JS = """ +window.__nitroWidgetRender = function(data) { + const list = document.getElementById("list"); + const meta = document.getElementById("meta"); + if (!list) return; + const results = (data && (data.results || data.airports || data.places)) || []; + if (meta) meta.textContent = "Query: " + ((data && data.query) || "") + " · " + results.length + " result(s)"; + list.innerHTML = ""; + if (!results.length) { + list.innerHTML = '
No airports found.
'; + return; + } + results.forEach(function(item) { + const el = document.createElement("div"); + el.className = "row"; + el.innerHTML = '
'; + el.querySelector(".code").textContent = item.iata_code || item.iataCode || ""; + el.querySelector(".name").textContent = item.name || ""; + el.querySelector(".muted").textContent = (item.city_name || item.cityName || item.city || "") + " · " + (item.type || "airport"); + list.appendChild(el); + }); +}; +""".strip() + + +def _place_code_js() -> str: + return """ + function code(p) { + if (!p) return ""; + if (typeof p === "string") return p; + return p.iata_code || p.iataCode || p.code || ""; + } + function money(amount, currency) { + if (amount == null || amount === "") return ""; + return String(amount) + (currency ? " " + currency : ""); + } + function sliceHtml(sl) { + const origin = code(sl.origin); + const dest = code(sl.destination); + const wrap = document.createElement("div"); + wrap.className = "slice"; + const title = document.createElement("strong"); + title.textContent = origin + " → " + dest; + const times = document.createElement("div"); + times.className = "muted"; + times.textContent = (sl.departing_at || sl.departingAt || sl.departureTime || "") + + " – " + (sl.arriving_at || sl.arrivingAt || sl.arrivalTime || "") + + " · " + (sl.duration || ""); + wrap.appendChild(title); + wrap.appendChild(times); + return wrap; + } +""".strip() + + +_FLIGHT_DETAILS_JS = f""" +window.__nitroWidgetRender = function(data) {{ + const meta = document.getElementById("meta"); + const root = document.getElementById("root"); + if (!root) return; + if (!data) {{ + if (meta) meta.textContent = "Waiting for get_flight_details result."; + return; + }} +{_place_code_js()} + const amount = data.total_amount || data.totalAmount || ""; + const currency = data.total_currency || data.totalCurrency || ""; + if (meta) meta.textContent = "Offer " + (data.id || "") + " · " + money(amount, currency); + root.innerHTML = ""; + (data.slices || []).forEach(function(sl) {{ root.appendChild(sliceHtml(sl)); }}); + if (data.id) {{ + const actions = document.createElement("div"); + actions.className = "ns-actions"; + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "ns-btn ns-btn-primary"; + btn.setAttribute("data-call-tool", "get_seat_map"); + btn.setAttribute("data-args", JSON.stringify({{ offerId: data.id }})); + btn.textContent = "Seat map"; + actions.appendChild(btn); + root.appendChild(actions); + }} +}}; +""".strip() + +_ORDER_SUMMARY_JS = f""" +window.__nitroWidgetRender = function(data) {{ + const root = document.getElementById("root"); + const card = document.querySelector(".card"); + if (!data) return; +{_place_code_js()} + const status = data.status || "held"; + const ref = data.booking_reference || data.bookingReference || ""; + const orderId = data.id || data.orderId || ""; + const amount = data.total_amount || data.totalAmount || ""; + const currency = data.total_currency || data.totalCurrency || ""; + const host = card || root; + if (!host) return; + host.innerHTML = ""; + const h1 = document.createElement("h1"); + h1.textContent = "Order " + status; + const meta = document.createElement("div"); + meta.className = "meta"; + meta.textContent = "Ref " + ref + " · " + orderId; + const price = document.createElement("div"); + price.className = "price"; + price.textContent = money(amount, currency); + host.appendChild(h1); + host.appendChild(meta); + host.appendChild(price); + const paxTitle = document.createElement("h2"); + paxTitle.textContent = "Passengers"; + host.appendChild(paxTitle); + const passengers = data.passengers || []; + if (!passengers.length) {{ + const empty = document.createElement("div"); + empty.className = "muted"; + empty.textContent = "None listed"; + host.appendChild(empty); + }} + passengers.forEach(function(p) {{ + const line = document.createElement("div"); + line.className = "muted"; + line.textContent = p.name || [p.given_name || p.givenName, p.family_name || p.familyName].filter(Boolean).join(" ") || p.id || ""; + host.appendChild(line); + }}); + const itin = document.createElement("h2"); + itin.textContent = "Itinerary"; + host.appendChild(itin); + (data.slices || []).forEach(function(sl) {{ host.appendChild(sliceHtml(sl)); }}); + if (orderId && String(status).toLowerCase().indexOf("cancel") < 0) {{ + const actions = document.createElement("div"); + actions.className = "ns-actions"; + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "ns-btn"; + btn.setAttribute("data-call-tool", "cancel_order"); + btn.setAttribute("data-args", JSON.stringify({{ orderId: orderId }})); + btn.textContent = "Cancel order"; + actions.appendChild(btn); + host.appendChild(actions); + }} +}}; +""".strip() + +_SEAT_JS = """ +window.__nitroWidgetRender = function(data) { + const meta = document.getElementById("meta"); + const root = document.getElementById("root"); + if (!root) return; + if (!data) { + if (meta) meta.textContent = "Waiting for get_seat_map result."; + return; + } + if (meta) meta.textContent = "Offer " + (data.offerId || data.offer_id || ""); + root.innerHTML = ""; + const cabins = data.cabins || []; + if (!cabins.length) { + root.innerHTML = '
No seat map data.
'; + return; + } + cabins.forEach(function(cabin) { + const h2 = document.createElement("h2"); + h2.textContent = cabin.cabin_class || cabin.cabinClass || "cabin"; + root.appendChild(h2); + (cabin.rows || []).forEach(function(row) { + const line = document.createElement("div"); + line.className = "seat-row"; + const rn = document.createElement("span"); + rn.className = "rn"; + rn.textContent = row.row_number || row.rowNumber || ""; + line.appendChild(rn); + let seats = row.seats || row.elements || []; + if (!seats.length) { + (row.sections || []).forEach(function(section) { + seats = seats.concat(section.elements || []); + }); + } + seats.forEach(function(seat) { + if (seat.type && seat.type !== "seat") return; + const cell = document.createElement("span"); + const available = seat.available != null ? seat.available : (seat.available_services != null); + cell.className = available ? "seat ok" : "seat no"; + cell.textContent = seat.designator || seat.id || seat.column || ""; + line.appendChild(cell); + }); + root.appendChild(line); + }); + }); +}; +""".strip() + +_CANCEL_JS = """ +window.__nitroWidgetRender = function(data) { + const card = document.querySelector(".card"); + if (!card || !data) return; + const status = data.status || "cancelled"; + const cid = data.id || data.cancellationId || ""; + const refund = data.refund_amount || data.refundAmount || ""; + const currency = data.refund_currency || data.refundCurrency || ""; + card.innerHTML = ""; + const h1 = document.createElement("h1"); + h1.textContent = "Booking cancelled"; + const meta = document.createElement("div"); + meta.className = "meta"; + meta.textContent = status + " · " + cid; + const p = document.createElement("p"); + p.textContent = data.message || "Booking cancelled."; + const price = document.createElement("div"); + price.className = "price"; + price.textContent = "Refund " + (refund ? (refund + (currency ? " " + currency : "")) : "n/a"); + card.appendChild(h1); + card.appendChild(meta); + card.appendChild(p); + card.appendChild(price); +}; +""".strip() + + +def _page( + title: str, + styles: str, + body: str, + render_js: str, + data: Any | None, + extra_head: str = "", + flush: bool = False, + chrome: bool = True, +) -> str: + return wrap_widget_page( + title=title, + styles=styles, + body=body, + render_js=render_js, + data=data, + extra_head=extra_head, + flush=flush, + chrome=chrome, + ) + + +def build_generic_widget(route: str, data: Any | None = None) -> str: + return _page(route, _GENERIC_CSS, render_body(route, data), _generic_render_js(), data) + + +def build_missing_widget(route: str, data: Any | None = None) -> str: + return _page( + f"Missing widget: {route}", + _MISSING_CSS, + missing_body(route), + _generic_render_js(), + data, + ) + + +def build_pizza_list_widget(data: Any | None = None) -> str: + return _page("Pizza shops", _PIZZA_LIST_CSS, render_body("pizza-list", data), _PIZZA_LIST_JS, data) + + +def build_pizza_map_widget(data: Any | None = None) -> str: + return _page( + "Pizza map", + _PIZZA_MAP_CSS, + render_body("pizza-map", data), + _pizza_map_js(), + data, + extra_head=_MAPBOX_HEAD, + flush=True, + ) + + +def build_pizza_shop_widget(data: Any | None = None) -> str: + return _page("Pizza shop", _PIZZA_SHOP_CSS, render_body("pizza-shop", data), _PIZZA_SHOP_JS, data) + + +def build_calculator_result_widget(data: Any | None = None) -> str: + return _page( + "Calculator result", + _CALC_CSS, + render_body("calculator-result", data), + _CALC_JS, + data, + ) + + +def build_card_widget(data: Any | None = None) -> str: + return _page("Product Card", _CARD_CSS, render_body("card", data), _CARD_JS, data) + + +def build_table_widget(data: Any | None = None) -> str: + return _page("Data Table", _TABLE_CSS, render_body("table", data), _TABLE_JS, data) + + +def build_chart_widget(data: Any | None = None) -> str: + return _page("Bar Chart", _CHART_CSS, render_body("chart", data), _CHART_JS, data) + + +def build_flight_search_results_widget(data: Any | None = None) -> str: + return _page( + "Flight search", + _FLIGHT_CSS, + render_body("flight-search-results", data), + _FLIGHT_SEARCH_JS, + data, + ) + + +def build_flight_details_widget(data: Any | None = None) -> str: + return _page( + "Flight details", + _FLIGHT_CSS, + render_body("flight-details", data), + _FLIGHT_DETAILS_JS, + data, + ) + + +def build_airport_search_widget(data: Any | None = None) -> str: + return _page( + "Airport search", + _FLIGHT_CSS, + render_body("airport-search", data), + _AIRPORT_JS, + data, + ) + + +def build_order_summary_widget(data: Any | None = None) -> str: + return _page( + "Order summary", + _FLIGHT_CSS, + render_body("order-summary", data), + _ORDER_SUMMARY_JS, + data, + ) + + +def build_seat_selection_widget(data: Any | None = None) -> str: + return _page( + "Seat map", + _FLIGHT_CSS, + render_body("seat-selection", data), + _SEAT_JS, + data, + ) + + +def build_order_cancellation_widget(data: Any | None = None) -> str: + return _page( + "Cancellation", + _FLIGHT_CSS, + render_body("order-cancellation", data), + _CANCEL_JS, + data, + ) + + +_ROUTE_BUILDERS: Dict[str, Callable[[Any | None], str]] = { + "pizza-list": build_pizza_list_widget, + "pizza-map": build_pizza_map_widget, + "pizza-shop": build_pizza_shop_widget, + "calculator-result": build_calculator_result_widget, + "card": build_card_widget, + "table": build_table_widget, + "chart": build_chart_widget, + "flight-search-results": build_flight_search_results_widget, + "flight-details": build_flight_details_widget, + "airport-search": build_airport_search_widget, + "order-summary": build_order_summary_widget, + "seat-selection": build_seat_selection_widget, + "order-cancellation": build_order_cancellation_widget, +} + + +def _route_id(route: str) -> str: + return (route or "").strip().strip("/").removeprefix("widget/").removesuffix(".html") + + +def render_widget_html(route: str, data: Any | None = None) -> Optional[str]: + """Python-rendered HTML for a known route, or None if unknown.""" + route_id = _route_id(route) + builder = _ROUTE_BUILDERS.get(route_id) + if builder is None: + return None + return builder(data) + + +def get_builtin_route_html(route: str) -> Optional[str]: + """Static template (no tool data) for ``resources/read`` / scaffolding.""" + return render_widget_html(route, None) + + +def build_widget_html_for_route(route: str) -> str: + """HTML for scaffolding: known route template or generic JSON viewer.""" + return get_builtin_route_html(route) or build_generic_widget(_route_id(route)) diff --git a/nitrostack/widgets/ui.py b/nitrostack/widgets/ui.py new file mode 100644 index 0000000..ea289e7 --- /dev/null +++ b/nitrostack/widgets/ui.py @@ -0,0 +1,172 @@ +"""Shared Python HTML primitives and host-token CSS for widgets.""" + +from __future__ import annotations + +import json +import re +from typing import Any +from urllib.parse import quote_plus + +from nitrostack.widgets.html_util import as_dict, esc + +_SAFE_HREF = re.compile(r"^(https?|mailto|tel):", re.IGNORECASE) + +SHARED_CSS = """ +html { color-scheme: light dark; } +:root { + --ns-bg: var(--color-background-primary, light-dark(#f7f7f5, #161615)); + --ns-surface: var(--color-background-secondary, light-dark(#ffffff, #242422)); + --ns-text: var(--color-text-primary, light-dark(#141413, #f5f4ef)); + --ns-muted: var(--color-text-secondary, light-dark(#6b6b66, #a8a8a0)); + --ns-border: var(--color-border-primary, light-dark(#e6e4de, #3a3a36)); + --ns-accent: #ea580c; + --ns-accent-soft: #ffedd5; + --ns-ok: #166534; + --ns-ok-bg: #dcfce7; + --ns-bad: #991b1b; + --ns-bad-bg: #fee2e2; + --ns-radius: var(--border-radius-md, 14px); + --ns-shadow: 0 1px 2px rgb(0 0 0 / 6%); + --ns-shadow-hover: 0 8px 24px rgb(0 0 0 / 12%); +} +[data-theme="dark"] { + --ns-accent-soft: #3b2414; + --ns-ok-bg: #14532d; + --ns-ok: #bbf7d0; + --ns-bad-bg: #7f1d1d; + --ns-bad: #fecaca; +} +* { box-sizing: border-box; } +body { + margin: 0; + font-family: var(--font-sans, system-ui, -apple-system, sans-serif); + background: var(--ns-bg); + color: var(--ns-text); + padding: max(12px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) + max(12px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); +} +body.ns-flush { padding: 0; overflow: hidden; } +h1 { font-size: 1.1rem; font-weight: 700; margin: 0 0 4px; letter-spacing: -0.02em; } +h2 { font-size: 0.95rem; margin: 14px 0 6px; } +.meta { color: var(--ns-muted); font-size: 0.82rem; margin-bottom: 12px; } +.muted { color: var(--ns-muted); font-size: 0.85rem; } +.empty { color: var(--ns-muted); padding: 24px 0; text-align: center; } +.ns-chrome { + display: flex; justify-content: flex-end; gap: 6px; margin: -4px 0 8px; +} +body.ns-flush .ns-chrome { + position: absolute; top: 12px; right: 12px; z-index: 5; margin: 0; +} +.ns-icon-btn, .ns-btn { + appearance: none; font: inherit; cursor: pointer; + border: 1px solid var(--ns-border); background: var(--ns-surface); + color: var(--ns-text); border-radius: 999px; text-decoration: none; +} +.ns-icon-btn { + width: 32px; height: 32px; display: inline-flex; align-items: center; justify-content: center; + box-shadow: var(--ns-shadow); +} +.ns-btn { + display: inline-flex; align-items: center; justify-content: center; + padding: 8px 12px; font-size: 0.82rem; font-weight: 600; + box-shadow: var(--ns-shadow); +} +.ns-btn-primary { background: var(--ns-accent); color: #fff; border-color: transparent; } +.ns-icon-btn:hover, .ns-btn:hover, .ns-card:hover { + box-shadow: var(--ns-shadow-hover); +} +.ns-card:hover { transform: translateY(-1px); } +.ns-icon-btn:focus-visible, .ns-btn:focus-visible, .ns-card:focus-visible { + outline: 2px solid var(--ns-accent); outline-offset: 2px; +} +.ns-card { + background: var(--ns-surface); border: 1px solid var(--ns-border); + border-radius: var(--ns-radius); padding: 12px 14px; box-shadow: var(--ns-shadow); + cursor: pointer; text-align: left; +} +.ns-card.is-busy, .ns-btn.is-busy { opacity: 0.6; pointer-events: none; } +.ns-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; } +.badge, .chip { + display: inline-block; font-size: 0.72rem; font-weight: 600; + padding: 2px 8px; border-radius: 999px; background: var(--ns-accent-soft); color: var(--ns-accent); +} +.badge.open, .seat.ok { background: var(--ns-ok-bg); color: var(--ns-ok); } +.badge.closed, .seat.no { background: var(--ns-bad-bg); color: var(--ns-bad); } +.rating { color: var(--ns-accent); font-weight: 700; font-variant-numeric: tabular-nums; } +.price { font-weight: 800; font-variant-numeric: tabular-nums; color: var(--ns-accent); } +.id, .code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.scroller, .cards { + display: flex; gap: 12px; overflow-x: auto; padding-bottom: 8px; + scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; +} +.sorts { display: flex; gap: 6px; margin: 0 0 12px; } +.sorts button { + appearance: none; font: inherit; font-size: 0.75rem; font-weight: 600; + padding: 4px 10px; border-radius: 999px; border: 1px solid var(--ns-border); + background: var(--ns-surface); color: var(--ns-muted); cursor: pointer; +} +.sorts button.is-on { background: var(--ns-accent); color: #fff; border-color: transparent; } +""".strip() + + +def attr_json(value: Any) -> str: + return esc(json.dumps(value, separators=(",", ":"))) + + +def call_attrs(tool: str, args: dict | None = None) -> str: + payload = args or {} + return ( + f'data-call-tool="{esc(tool)}" data-args="{attr_json(payload)}" ' + 'role="button" tabindex="0"' + ) + + +def safe_href(url: str) -> str: + """Allow only ``http(s)``, ``mailto``, and ``tel`` schemes.""" + raw = str(url or "").strip() + if not raw or not _SAFE_HREF.match(raw): + return "" + return raw + + +def link_attrs(url: str) -> str: + href = safe_href(url) + if not href: + return "" + return f'data-open-link="{esc(href)}" href="{esc(href)}"' + + +def maps_url(shop: Any) -> str: + shop = as_dict(shop) + coords = shop.get("coords") + if isinstance(coords, (list, tuple)) and len(coords) == 2: + try: + lon = float(coords[0]) + lat = float(coords[1]) + return f"https://www.google.com/maps/search/?api=1&query={lat},{lon}" + except (TypeError, ValueError): + pass + address = shop.get("address") + if address: + return f"https://www.google.com/maps/search/?api=1&query={quote_plus(str(address))}" + return "" + + +def phone_href(phone: Any) -> str: + raw = "".join(ch for ch in str(phone or "") if ch.isdigit() or ch == "+") + return f"tel:{raw}" if raw else "" + + +def action_row(*, maps: str = "", phone: str = "", website: str = "") -> str: + parts: list[str] = [] + maps_attrs = link_attrs(maps) + if maps_attrs: + parts.append(f'Maps') + tel = phone_href(phone) + tel_attrs = link_attrs(tel) + if tel_attrs: + parts.append(f'Call') + website_attrs = link_attrs(str(website) if website else "") + if website_attrs: + parts.append(f'Website') + return f'
{"".join(parts)}
' diff --git a/nitrostack/widgets/views.py b/nitrostack/widgets/views.py new file mode 100644 index 0000000..9822cbf --- /dev/null +++ b/nitrostack/widgets/views.py @@ -0,0 +1,569 @@ +"""Python-rendered widget bodies from tool ``structuredContent``. + +Hosts that inject data later still use the HTML document's small host-bridge +script. First paint (Inspector ``tools/call`` HTML, live preview) uses these +builders so the iframe matches the actual tool output — not sample JSON. +""" + +from __future__ import annotations + +from typing import Any, Callable, Dict + +from nitrostack.widgets.html_util import ( + as_dict, + as_list, + esc, + format_duration, + mapbox_static_url, + money, + pick, + place_code, + place_name, +) +from nitrostack.widgets.ui import action_row, call_attrs, maps_url + + +def _safe_int( + value: Any, + default: int = 0, + *, + minimum: int | None = None, + maximum: int | None = None, +) -> int: + try: + # OverflowError covers infinities: `json.loads("1e400")` yields `inf`, and + # `int(float("inf"))` raises. NaN already surfaces as ValueError. + n = int(float(value)) + except (TypeError, ValueError, OverflowError): + n = default + if minimum is not None: + n = max(minimum, n) + if maximum is not None: + n = min(maximum, n) + return n + + +def _safe_float(value: Any, default: float = 0.0) -> float: + try: + n = float(value) + except (TypeError, ValueError): + return default + if n != n or n in (float("inf"), float("-inf")): + return default + return n + + +def pizza_list_body(data: Any | None) -> str: + payload = as_dict(data) + shops = as_list(payload.get("shops")) + total = payload.get("totalShops") + if total is None: + total = len(shops) + if data is None: + meta = "Waiting for show_pizza_list result." + cards = "" + elif not shops: + meta = f"{total} shops" + cards = '
No shops match these filters.
' + else: + meta = f"{total} shop" + ("" if total == 1 else "s") + parts = [] + for shop in shops: + shop = as_dict(shop) + open_cls = "open" if shop.get("openNow") else "closed" + open_label = "Open" if shop.get("openNow") else "Closed" + price = "$" * _safe_int(shop.get("priceLevel"), 1, minimum=1, maximum=4) + img = "" + if shop.get("image"): + img = f'' + rating = f"★ {esc(shop.get('rating'))}" if shop.get("rating") is not None else "" + shop_id = shop.get("id") or "" + parts.append( + f'
' + f"{img}" + f'
{esc(shop.get("name") or shop_id or "Shop")}' + f'{rating}
' + f'
{esc(shop.get("address"))}
' + f'
{open_label} ' + f'{esc(price)}
' + "
" + ) + cards = "".join(parts) + return ( + "

Pizza shops

" + f'
{esc(meta)}
' + '
' + '' + '' + "
" + f'
{cards}
' + '
' + ) + + +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'Pizza shop map' + 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'
{esc(money(amount, currency))}' + f'{esc(offer_id)}
{slice_html}
' + ) + if not cards: + cards.append('
No flight offers.
') + return ( + "

Flight search

" + f'
{esc(meta)}
' + f'
{"".join(cards)}
' + ) + + +def flight_details_body(data: Any | None) -> str: + payload = as_dict(data) + if data is None or not payload: + return ( + "

Flight details

" + '
Waiting for get_flight_details result.
' + '
' + ) + amount = pick(payload, "total_amount", "totalAmount", default="") + currency = pick(payload, "total_currency", "totalCurrency", default="") + slices = as_list(payload.get("slices")) + slice_html = "".join(_offer_slice_html(as_dict(s)) for s in slices) + offer_id = payload.get("id") or "" + seats = "" + if offer_id: + seats = ( + f'
' + ) + return ( + "

Flight details

" + f'
Offer {esc(offer_id)} · {esc(money(amount, currency))}
' + f'
{slice_html}{seats}
' + ) + + +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 ( + "

Cancellation

" + '
Waiting for cancel_order result.
' + '
' + ) + refund = pick(payload, "refund_amount", "refundAmount", default="") + currency = pick(payload, "refund_currency", "refundCurrency", default="") + status = pick(payload, "status", default="cancelled") + msg = pick(payload, "message", default="Booking cancelled.") + cid = pick(payload, "id", "cancellationId", default="") + return ( + '
' + "

Booking cancelled

" + f'
{esc(status)} · {esc(cid)}
' + f'

{esc(msg)}

' + f'
Refund {esc(money(refund, currency) or "n/a")}
' + "
" + '
' + ) + + +def generic_body(route: str, data: Any | None) -> str: + import json + + if data is None: + preview = "Waiting for host to send tool output…" + else: + preview = json.dumps(data, indent=2, default=str) + return ( + f"

{esc(route)}

" + '
Tool output
' + f'
{esc(preview)}
' + ) + + +def missing_body(route: str) -> str: + return ( + "

Widget template missing

" + f'
No HTML found for route {esc(route)}. ' + f"Create widgets/out/{esc(route)}.html or run nitrostack-py init again.
" + '
' + ) + + +BODY_BUILDERS: Dict[str, Callable[[Any | None], str]] = { + "pizza-list": pizza_list_body, + "pizza-map": pizza_map_body, + "pizza-shop": pizza_shop_body, + "calculator-result": calculator_result_body, + "card": card_body, + "table": table_body, + "chart": chart_body, + "flight-search-results": flight_search_results_body, + "flight-details": flight_details_body, + "airport-search": airport_search_body, + "order-summary": order_summary_body, + "seat-selection": seat_selection_body, + "order-cancellation": order_cancellation_body, +} + + +def render_body(route: str, data: Any | None = None) -> str: + builder = BODY_BUILDERS.get(route) + if builder is None: + return generic_body(route, data) + return builder(data) diff --git a/tests/fixtures/widgets/out/file-route.html b/tests/fixtures/widgets/out/file-route.html new file mode 100644 index 0000000..574a400 --- /dev/null +++ b/tests/fixtures/widgets/out/file-route.html @@ -0,0 +1 @@ +from-file \ No newline at end of file diff --git a/tests/fixtures/widgets/out/sample.html b/tests/fixtures/widgets/out/sample.html new file mode 100644 index 0000000..fc11892 --- /dev/null +++ b/tests/fixtures/widgets/out/sample.html @@ -0,0 +1 @@ +widget \ No newline at end of file diff --git a/tests/test_cli.py b/tests/test_cli.py index 8039815..a2117bb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -129,8 +129,13 @@ def test_init_with_name_and_explicit_template(): env_text = open(os.path.join(project, ".env"), encoding="utf-8").read() assert "PORT=3000" in env_text assert "WIDGETS_DEV_PORT=3001" in env_text + assert "NITROSTACK_APP_MODE=universal" in env_text assert "SERVER_DESC=\"A test server\"" in env_text assert "SERVER_AUTHOR=\"Tester\"" in env_text + assert os.path.isfile(os.path.join(project, "widgets", "out", "calculator-result.html")) + calc_html = open(os.path.join(project, "widgets", "out", "calculator-result.html"), encoding="utf-8").read() + assert "ui/notifications/tool-result" in calc_html + assert os.path.isfile(os.path.join(project, "widgets", "preview.html")) print("Success! Named init with python-starter writes PORT=3000.") finally: sys.stdin = original_stdin @@ -165,6 +170,12 @@ def test_init_default_name_when_blank_readline(): assert os.path.isdir(os.path.join(tmp, DEFAULT_PROJECT_NAME)) env_text = open(os.path.join(tmp, DEFAULT_PROJECT_NAME, ".env"), encoding="utf-8").read() assert "PORT=3000" in env_text + project = os.path.join(tmp, DEFAULT_PROJECT_NAME) + for route in ("pizza-list", "pizza-map", "pizza-shop"): + assert os.path.isfile(os.path.join(project, "widgets", "out", f"{route}.html")) + preview = open(os.path.join(project, "widgets", "preview.html"), encoding="utf-8").read() + assert "Tony" in preview or "shops" in preview + assert os.path.isfile(os.path.join(project, "widgets", "preview.html")) print("Success! Blank name readline falls back to my-mcp-server.") finally: sys.stdin = original_stdin @@ -184,6 +195,18 @@ def test_init_python_oauth_template(): assert os.path.isfile(os.path.join(project, "OAUTH_SETUP.md")) env_text = open(os.path.join(project, ".env"), encoding="utf-8").read() assert "PORT=3000" in env_text + for route in ( + "flight-search-results", + "flight-details", + "airport-search", + "order-summary", + "seat-selection", + "order-cancellation", + ): + html = open(os.path.join(project, "widgets", "out", f"{route}.html"), encoding="utf-8").read() + assert os.path.isfile(os.path.join(project, "widgets", "out", f"{route}.html")), route + assert "nitrostack-tool-data" in html + assert os.path.isfile(os.path.join(project, "widgets", "preview.html")) print("Success! python-oauth template scaffolds with PORT=3000.") finally: sys.stdin = original_stdin @@ -250,8 +273,6 @@ def test_cli_dev_and_start_port_flags(): def test_init_port_and_widget_flags_override_defaults(): - import json - tmp = tempfile.mkdtemp(prefix="nitro-cli-ports-") original_cwd = os.getcwd() original_stdin = sys.stdin @@ -268,11 +289,8 @@ def test_init_port_and_widget_flags_override_defaults(): env_text = open(os.path.join(tmp, "ports-demo", ".env"), encoding="utf-8").read() assert "PORT=4000" in env_text assert "WIDGETS_DEV_PORT=4001" in env_text - # The port is supplied once by the CLI at run time, not baked into the - # npm scripts, so the scripts stay port-free. - pkg = json.load(open(os.path.join(tmp, "ports-demo", "src", "widgets", "package.json"), encoding="utf-8")) - assert pkg["scripts"]["dev"] == "next dev" - assert pkg["scripts"]["start"] == "next start" + assert os.path.isfile(os.path.join(tmp, "ports-demo", "widgets", "out", "calculator-result.html")) + assert not os.path.exists(os.path.join(tmp, "ports-demo", "src", "widgets", "package.json")) print("Success! --port and --widget override generated project ports.") finally: sys.stdin = original_stdin @@ -553,10 +571,8 @@ def test_init_project_overwrite_and_install_yes_calls_npm(): sys.stdin = io.StringIO("d\na\nY\n") with patch("nitrostack.cli.main._run_npm") as npm: init_project("installed", template="python-starter") - assert npm.call_count >= 2 - called_args = [call.args[0] for call in npm.call_args_list] - assert ["--version"] in called_args - assert ["install"] in called_args + npm.assert_not_called() + assert os.path.isfile(os.path.join("installed", "widgets", "out", "calculator-result.html")) print("Success! init_project overwrite and install-yes npm path work.") finally: sys.stdin = original_stdin @@ -662,6 +678,9 @@ def test_generate_tool_and_module(): content = open("hello_world_tool.py", encoding="utf-8").read() assert "hello_world" in content assert "HelloWorldInput" in content + assert '@widget("hello_world")' in content + assert os.path.isfile(os.path.join("widgets", "out", "hello_world.html")) + assert os.path.isfile(os.path.join("widgets", "preview.html")) try: generate_tool("hello_world") raise AssertionError("duplicate generate_tool should exit") @@ -682,6 +701,33 @@ def test_generate_tool_and_module(): shutil.rmtree(tmp, ignore_errors=True) +def test_generate_tool_rejects_path_traversal(): + from nitrostack.cli.main import write_widget_html + + tmp = tempfile.mkdtemp(prefix="nitro-cli-gen-safe-") + original_cwd = os.getcwd() + try: + os.chdir(tmp) + try: + generate_tool("../../ESCAPED") + raise AssertionError("path traversal generate_tool should exit") + except SystemExit: + pass + assert not os.path.isfile(os.path.join(tmp, "ESCAPED_tool.py")) + parent = os.path.abspath(os.path.join(tmp, "..", "..")) + assert "ESCAPED_tool.py" not in os.listdir(parent) + try: + write_widget_html(tmp, "../../ESCAPED") + raise AssertionError("path traversal write_widget_html should raise") + except ValueError: + pass + assert not os.path.isfile(os.path.join(tmp, "widgets", "out", "ESCAPED.html")) + print("Success! generate_tool rejects path traversal.") + finally: + os.chdir(original_cwd) + shutil.rmtree(tmp, ignore_errors=True) + + def test_get_claude_config_paths_by_platform(): from unittest.mock import patch @@ -1344,6 +1390,7 @@ def test_pack_and_validate_agree_on_bom_dependencies(tmp_path: Path): test_run_dev_passes_port_overrides_and_starts_widgets() test_run_start_passes_port_overrides() test_generate_tool_and_module() + test_generate_tool_rejects_path_traversal() test_get_claude_config_paths_by_platform() test_register_server_writes_config_and_handles_errors() test_main_dispatches_commands() diff --git a/tests/test_flight_booking.py b/tests/test_flight_booking.py new file mode 100644 index 0000000..101a460 --- /dev/null +++ b/tests/test_flight_booking.py @@ -0,0 +1,234 @@ +"""Flight-booking (oauth) Studio path: no token + mock Duffel + widgets.""" +from __future__ import annotations + +import asyncio +import os +import sys +from pathlib import Path + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +import mcp.types as types +from pydantic import BaseModel + +from nitrostack import ( + ExecutionContext, + OAuthGuard, + WidgetOptions, + injectable, + module, + tool, + use_guards, + widget, +) +from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app, parse_tool_input +from nitrostack.core.di import DIContainer +from nitrostack.testing import NitroTestingModule +from nitrostack.transports.http import _preview_default_arguments, build_http_app +from nitrostack.widgets.route_templates import get_builtin_route_html +from starlette.testclient import TestClient + +ROOT = Path(__file__).resolve().parent.parent +WIDGET_ROUTES = ( + "flight-search-results", + "flight-details", + "airport-search", + "order-summary", + "seat-selection", + "order-cancellation", +) + + +def test_flight_widget_html_is_registered_for_all_routes(): + out = ROOT / "nitrostack" / "templates" / "flight-booking" / "widgets" / "out" + for route in WIDGET_ROUTES: + path = out / f"{route}.html" + assert path.is_file(), f"missing {path}" + builtin = get_builtin_route_html(route) + assert builtin is not None + assert path.read_text(encoding="utf-8") == builtin + + +class SearchFlightsInput(BaseModel): + origin: str + destination: str + departureDate: str + adults: int = 1 + cabinClass: str = "economy" + + +@injectable() +class _MockDuffel: + async def search_flights(self, params: dict) -> dict: + return { + "id": "orq_mock123456", + "offers": [ + { + "id": "off_mock123456", + "total_amount": "450.00", + "total_currency": "USD", + "slices": [ + { + "origin": {"iata_code": params["origin"]}, + "destination": {"iata_code": params["destination"]}, + "duration": "PT6H30M", + } + ], + } + ], + } + + +@injectable(deps=[_MockDuffel]) +class FlightStudioTools: + def __init__(self, service: _MockDuffel): + self.service = service + + @tool(name="search_flights", description="search", input_schema=SearchFlightsInput) + @use_guards(OAuthGuard) + @widget(WidgetOptions(route="flight-search-results", prefers_border=True)) + async def search_flights(self, input: SearchFlightsInput, context: ExecutionContext) -> dict: + return await self.service.search_flights(input.model_dump()) + + +@module(name="flight_studio", controllers=[FlightStudioTools], providers=[_MockDuffel]) +class FlightStudioModule: + pass + + +def test_studio_can_search_flights_without_token(): + os.environ.pop("OAUTH_REQUIRED", None) + + async def run(): + harness = await NitroTestingModule.create(FlightStudioModule) + tools = harness.app.mcp_server.request_handlers[types.ListToolsRequest] + listed = (await tools(None)).root.tools + target = next(t for t in listed if t.name == "search_flights") + meta = getattr(target, "meta", None) or getattr(target, "_meta", {}) or {} + assert meta.get("ui", {}).get("resourceUri") == "ui://widget/flight-search-results.html" + + result = await harness.call_tool( + "search_flights", + {"origin": "JFK", "destination": "LAX", "departureDate": "2026-09-15"}, + ) + assert result["offers"][0]["id"] == "off_mock123456" + assert result["offers"][0]["slices"][0]["origin"]["iata_code"] == "JFK" + + call_handler = harness.app.mcp_server.request_handlers[types.CallToolRequest] + raw = await call_handler( + types.CallToolRequest( + method="tools/call", + params=types.CallToolRequestParams( + name="search_flights", + arguments={"origin": "JFK", "destination": "LAX", "departureDate": "2026-09-15"}, + ), + ) + ) + payload = raw.root + assert payload.isError is not True + assert payload.structuredContent["offers"] + html = next(b.resource.text for b in payload.content if getattr(b, "type", None) == "resource") + assert "JFK" in html + assert "LAX" in html + assert "450.00" in html + + asyncio.run(run()) + + +def test_oauth_required_blocks_studio_without_token(): + os.environ["OAUTH_REQUIRED"] = "true" + try: + async def run(): + harness = await NitroTestingModule.create(FlightStudioModule) + raw_handler = harness.app.mcp_server.request_handlers[types.CallToolRequest] + try: + resp = await raw_handler( + types.CallToolRequest( + method="tools/call", + params=types.CallToolRequestParams( + name="search_flights", + arguments={"origin": "JFK", "destination": "LAX", "departureDate": "2026-09-15"}, + ), + ) + ) + assert resp.root.isError is True + text = resp.root.content[0].text + assert "OAuth" in text or "Access denied" in text + except PermissionError as exc: + assert "OAuth token required" in str(exc) + + asyncio.run(run()) + finally: + os.environ.pop("OAUTH_REQUIRED", None) + + +def test_preview_defaults_and_blank_optional_flight_fields(): + class _Entry: + config = type("C", (), {"examples": None})() + input_model = SearchFlightsInput + + defaults = _preview_default_arguments(_Entry()) + assert defaults["origin"] == "JFK" + assert defaults["destination"] == "LAX" + assert defaults["departureDate"] == "2026-09-15" + + parsed = parse_tool_input(SearchFlightsInput, {"origin": "JFK", "destination": "LAX", "departureDate": "2026-09-15", "cabinClass": ""}) + assert parsed.cabinClass == "economy" + + +def test_mock_duffel_search_airports_matches_query(): + sys.path.insert(0, str(ROOT / "nitrostack" / "templates" / "flight-booking")) + from services.duffel_service import DuffelService + + os.environ.pop("DUFFEL_API_KEY", None) + service = DuffelService() + assert service.is_mock is True + + async def run(): + jfk = await service.search_airports("JFK") + assert jfk and jfk[0]["iata_code"] == "JFK" + lax = await service.search_airports("los angeles") + assert any(item["iata_code"] == "LAX" for item in lax) + delhi = await service.search_airports("Delhi") + assert any(item["iata_code"] == "DEL" for item in delhi) + assert delhi[0]["iata_code"] == "DEL" + del_code = await service.search_airports("DEL") + assert del_code[0]["iata_code"] == "DEL" + london = await service.search_airports("London") + codes = {item["iata_code"] for item in london} + assert {"LHR", "LGW", "STN"} <= codes + assert await service.search_airports("zzzznotanairport") == [] + flights = await service.search_flights( + {"origin": "DEL", "destination": "LHR", "departureDate": "2026-08-19", "adults": 2, "cabinClass": "economy"} + ) + assert flights["offers"][0]["slices"][0]["origin"]["iata_code"] == "DEL" + assert flights["offers"][0]["slices"][0]["destination"]["iata_code"] == "LHR" + details = await service.get_offer("off_mock123456") + assert details["slices"][0]["origin"]["iata_code"] == "DEL" + + asyncio.run(run()) + + +def test_live_preview_search_flights_without_token(): + os.environ.pop("OAUTH_REQUIRED", None) + DIContainer.reset() + + @mcp_app(module=FlightStudioModule, server=ServerConfig(name="flight-preview")) + class PreviewApp: + pass + + app = asyncio.run(McpApplicationFactory.create(PreviewApp)) + http_app = build_http_app(app, enable_cors=True, stateless=True) + with TestClient(http_app) as client: + called = client.post( + "/widgets/preview/call", + json={ + "tool": "search_flights", + "arguments": {"origin": "JFK", "destination": "LAX", "departureDate": "2026-09-15"}, + }, + ) + assert called.status_code == 200, called.text + body = called.json() + assert body["structuredContent"]["offers"][0]["id"] == "off_mock123456" + assert "JFK" in body["html"] + assert "flight-search-results" in body["resourceUri"] diff --git a/tests/test_flight_transforms.py b/tests/test_flight_transforms.py new file mode 100644 index 0000000..c6e6dbc --- /dev/null +++ b/tests/test_flight_transforms.py @@ -0,0 +1,108 @@ +"""TS-parity transforms for flight tools and the mock airport catalog.""" +from nitrostack.widgets.flight_catalog import search_mock_airports +from nitrostack.widgets.flight_transforms import ( + transform_airport_results, + transform_flight_details, + transform_flight_search, + transform_seat_map, +) +from nitrostack.widgets.views import render_body + + +def test_delhi_and_london_catalog(): + delhi = search_mock_airports("Delhi") + assert delhi[0]["iata_code"] == "DEL" + assert "Indira Gandhi" in delhi[0]["name"] + assert search_mock_airports("new delhi")[0]["iata_code"] == "DEL" + london = search_mock_airports("London") + assert {a["iata_code"] for a in london} >= {"LHR", "LGW", "STN"} + assert search_mock_airports("x") == [] + assert search_mock_airports("no-such-city") == [] + + +def test_transform_airport_results_camelcase(): + raw = search_mock_airports("DEL") + out = transform_airport_results("DEL", raw) + assert out["results"][0]["iataCode"] == "DEL" + assert out["results"][0]["cityName"] == "Delhi" + + +def test_transform_flight_search_matches_ts_widget_shape(): + raw = { + "id": "orq_mock123456", + "offers": [ + { + "id": "off_mock123456", + "total_amount": "450.00", + "total_currency": "USD", + "expires_at": "2026-12-31T12:00:00Z", + "slices": [ + { + "origin": {"iata_code": "DEL", "name": "Indira Gandhi International Airport", "city_name": "Delhi"}, + "destination": {"iata_code": "LHR", "name": "London Heathrow Airport", "city_name": "London"}, + "duration": "PT6H30M", + "segments": [ + { + "origin": {"iata_code": "DEL"}, + "destination": {"iata_code": "LHR"}, + "departing_at": "2026-08-19T08:00:00Z", + "arriving_at": "2026-08-19T14:30:00Z", + "marketing_carrier": {"name": "Mock Airlines", "iata_code": "MK"}, + "marketing_carrier_flight_number": "MK123", + "aircraft": {"name": "Boeing 787"}, + } + ], + } + ], + } + ], + } + out = transform_flight_search( + {"origin": "del", "destination": "lhr", "departureDate": "2026-08-19", "adults": 2, "cabinClass": "economy"}, + raw, + ) + assert out["searchParams"]["origin"] == "DEL" + assert out["searchParams"]["destination"] == "LHR" + assert out["searchParams"]["passengers"]["adults"] == 2 + offer = out["offers"][0] + assert offer["totalAmount"] == "450.00" + assert offer["outbound"]["origin"] == "DEL" + assert offer["outbound"]["destination"] == "LHR" + assert offer["outbound"]["airline"] == "Mock Airlines" + html = render_body("flight-search-results", out) + assert "DEL" in html and "LHR" in html + details = transform_flight_details(raw["offers"][0]) + assert details["slices"][0]["origin"]["code"] == "DEL" + + +def test_transform_seat_map_matches_ts_widget_shape(): + out = transform_seat_map( + "off_mock123456", + [ + { + "cabin_class": "economy", + "rows": [ + { + "row_number": 10, + "sections": [ + { + "elements": [ + { + "type": "seat", + "id": "seat_10a", + "designator": "10A", + "available_services": [{"total_amount": "25.00", "total_currency": "USD"}], + "disclosures": ["window"], + } + ] + } + ], + } + ], + } + ], + ) + seat = out["cabins"][0]["rows"][0]["seats"][0] + assert seat["column"] == "10A" + assert seat["available"] is True + assert seat["price"] == "25.00" diff --git a/tests/test_oauth.py b/tests/test_oauth.py index 548da51..b6d9ee0 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -23,7 +23,8 @@ async def _test_oauth_guard_validation(): guard = OAuthGuard() - # Case A: Missing Authorization Header + # Case A: Missing Authorization Header — Studio default (OAUTH_REQUIRED unset) + os.environ.pop("OAUTH_REQUIRED", None) ctx_missing = ExecutionContext( request_id="test-req-1", tool_name="test-tool", @@ -31,8 +32,24 @@ async def _test_oauth_guard_validation(): metadata={} ) res_missing = await guard.can_activate(ctx_missing) - print("Missing authorization header check:", res_missing) - assert res_missing is False + print("Missing authorization header (auth optional):", res_missing) + assert res_missing is True + + os.environ["OAUTH_REQUIRED"] = "true" + try: + ctx_required = ExecutionContext( + request_id="test-req-1b", + tool_name="test-tool", + logger=MagicMock(), + metadata={} + ) + try: + await guard.can_activate(ctx_required) + raise AssertionError("OAUTH_REQUIRED=true must reject a missing token") + except PermissionError as exc: + assert "OAuth token required" in str(exc) + finally: + os.environ.pop("OAUTH_REQUIRED", None) # Case B: Malformed Authorization Header (no Bearer prefix) ctx_malformed = ExecutionContext( @@ -42,8 +59,8 @@ async def _test_oauth_guard_validation(): metadata={"authorization": "Basic abcdef"} ) res_malformed = await guard.can_activate(ctx_malformed) - print("Malformed authorization header check:", res_malformed) - assert res_malformed is False + print("Malformed authorization header check (auth optional):", res_malformed) + assert res_malformed is True # Case C: Valid Bearer Token but Introspection returns active = False ctx_invalid = ExecutionContext( @@ -58,10 +75,18 @@ async def _test_oauth_guard_validation(): with patch.object(oauth_service, 'introspect_token', return_value={"active": False}) as mock_introspect: res_invalid = await guard.can_activate(ctx_invalid) - print("Invalid token check:", res_invalid) - assert res_invalid is False + print("Invalid token check (auth optional):", res_invalid) + assert res_invalid is True mock_introspect.assert_called_once_with("invalid-token") + os.environ["OAUTH_REQUIRED"] = "true" + try: + with patch.object(oauth_service, "introspect_token", return_value={"active": False}): + res_invalid_required = await guard.can_activate(ctx_invalid) + assert res_invalid_required is False + finally: + os.environ.pop("OAUTH_REQUIRED", None) + # Case D: Valid Bearer Token and Introspection returns active = True with scopes ctx_valid = ExecutionContext( request_id="test-req-4", diff --git a/tests/test_pizzaz_widgets.py b/tests/test_pizzaz_widgets.py new file mode 100644 index 0000000..67b97fa --- /dev/null +++ b/tests/test_pizzaz_widgets.py @@ -0,0 +1,321 @@ +"""Python-only Pizzaz widgets: HTML files + MCP resource registration.""" +from __future__ import annotations + +import asyncio +import os +import sys +from pathlib import Path + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +import mcp.types as types +from pydantic import BaseModel + +from starlette.testclient import TestClient + +from nitrostack import ( + ExecutionContext, + WidgetCsp, + WidgetOptions, + injectable, + module, + tool, + widget, +) +from nitrostack.core.di import DIContainer +from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app +from nitrostack.testing import NitroTestingModule +from nitrostack.transports.http import build_http_app +from nitrostack.widgets.html_util import get_mapbox_token, inject_mapbox_token +from nitrostack.widgets.component import create_component, load_widget_html +from nitrostack.widgets.route_templates import get_builtin_route_html, render_widget_html + +ROOT = Path(__file__).resolve().parent.parent +TEMPLATE_OUT = ROOT / "nitrostack" / "templates" / "pizzaz" / "widgets" / "out" +ROUTES = ("pizza-list", "pizza-map", "pizza-shop") + + +def test_pizzaz_html_files_exist_in_template(): + for route in ROUTES: + path = TEMPLATE_OUT / f"{route}.html" + assert path.is_file(), f"missing {path}" + text = path.read_text(encoding="utf-8") + assert "window.openai" in text + assert "structuredContent" in text + assert "openai:set_globals" in text + assert "ui/notifications/tool-result" in text + assert "ui/initialize" in text + assert "__nitroWidgetRender" in text + assert 'id="nitrostack-tool-data"' in text + if route == "pizza-list": + assert "shops.forEach" in text + assert "show_pizza_shop" in text + if route == "pizza-map": + assert "mapbox-gl" in text + assert "mapboxgl.accessToken" in text + assert "pk.eyJ" not in text + assert "data-nitro-needs-client" in text + assert "Set MAPBOX_TOKEN" in text + + +def test_pizzaz_builtin_templates_match_disk(): + for route in ROUTES: + builtin = get_builtin_route_html(route) + assert builtin is not None + disk = (TEMPLATE_OUT / f"{route}.html").read_text(encoding="utf-8") + assert disk == builtin + + +def test_load_widget_html_walks_from_tool_module(): + fake_module = ROOT / "nitrostack" / "templates" / "pizzaz" / "modules" / "pizzaz" / "pizzaz_tools.py" + html = load_widget_html("pizza-list", from_file=fake_module) + assert html is not None + assert "Pizza shops" in html + + +class EmptyInput(BaseModel): + pass + + +@injectable() +class PizzaListController: + @tool( + name="show_pizza_list", + description="List pizza shops", + input_schema=EmptyInput, + ) + @widget( + WidgetOptions( + route="pizza-list", + prefers_border=True, + csp=WidgetCsp(resource_domains=["https://images.unsplash.com"]), + ) + ) + async def show_pizza_list(self, input: EmptyInput, context: ExecutionContext) -> dict: + return { + "shops": [ + {"id": "a", "name": "Shop A", "rating": 4.5, "address": "1 Main", "priceLevel": 2, "openNow": True}, + {"id": "b", "name": "Shop B", "rating": 4.1, "address": "2 Main", "priceLevel": 1, "openNow": False}, + ], + "totalShops": 2, + } + + +@module(name="pizzaz_widget_test", controllers=[PizzaListController]) +class PizzaListModule: + pass + + +def test_pizzaz_resources_and_tool_call(): + async def run(): + old = os.environ.get("NITROSTACK_APP_MODE") + os.environ["NITROSTACK_APP_MODE"] = "universal" + try: + harness = await NitroTestingModule.create(PizzaListModule) + list_handler = harness.app.mcp_server.request_handlers[types.ListToolsRequest] + tools = (await list_handler(None)).root.tools + target = next(t for t in tools if t.name == "show_pizza_list") + meta = getattr(target, "meta", None) or getattr(target, "_meta", {}) or {} + assert meta["ui"]["resourceUri"] == "ui://widget/pizza-list.html" + assert meta["openai/widgetPrefersBorder"] is True + assert meta["openai/widgetCSP"]["resource_domains"] == ["https://images.unsplash.com"] + + list_res = harness.app.mcp_server.request_handlers[types.ListResourcesRequest] + resources = (await list_res(None)).root.resources + uris = {str(r.uri) for r in resources} + assert "ui://widget/pizza-list.html" in uris + + read_handler = harness.app.mcp_server.request_handlers[types.ReadResourceRequest] + read = await read_handler( + types.ReadResourceRequest( + method="resources/read", + params=types.ReadResourceRequestParams(uri="ui://widget/pizza-list.html"), + ) + ) + text = read.root.contents[0].text or "" + assert "Pizza shops" in text + assert "shops.forEach" in text + assert "show_pizza_shop" in text + + call_handler = harness.app.mcp_server.request_handlers[types.CallToolRequest] + resp = await call_handler( + types.CallToolRequest( + method="tools/call", + params=types.CallToolRequestParams(name="show_pizza_list", arguments={}), + ) + ) + result = resp.root + assert result.structuredContent is not None + assert len(result.structuredContent["shops"]) == 2 + assert result.structuredContent["totalShops"] == 2 + assert result.meta["ui"]["resourceUri"] == "ui://widget/pizza-list.html" + assert result.meta["openai/outputTemplate"] == "ui://widget/pizza-list.html" + embedded = next(b for b in result.content if getattr(b, "type", None) == "resource") + html = embedded.resource.text + assert "Shop A" in html + assert "Shop B" in html + assert "2 shops" in html or "2 shop" in html + finally: + if old is None: + os.environ.pop("NITROSTACK_APP_MODE", None) + else: + os.environ["NITROSTACK_APP_MODE"] = old + + asyncio.run(run()) + + +def test_live_http_widget_preview_calls_tool(): + DIContainer.reset() + @mcp_app(module=PizzaListModule, server=ServerConfig(name="preview-test")) + class PreviewApp: + pass + + app = asyncio.run(McpApplicationFactory.create(PreviewApp)) + http_app = build_http_app(app, enable_cors=True, stateless=True) + with TestClient(http_app) as client: + page = client.get("/widgets/preview") + assert page.status_code == 200 + assert "show_pizza_list" in page.text + assert "/widgets/preview/call" in page.text + assert "id=\"args\"" in page.text + called = client.post("/widgets/preview/call", json={"tool": "show_pizza_list", "arguments": {}}) + assert called.status_code == 200, called.text + body = called.json() + assert len(body["structuredContent"]["shops"]) == 2 + assert "Shop A" in body["html"] + assert "Shop B" in body["html"] + assert "Pizza shops" in body["html"] + missing = client.post("/widgets/preview/call", json={"tool": "nope", "arguments": {}}) + assert missing.status_code == 404 + bad_json = client.post( + "/widgets/preview/call", + content=b"not-json", + headers={"Content-Type": "application/json"}, + ) + assert bad_json.status_code == 400 + assert "Invalid JSON" in bad_json.json()["error"] + + +def _load_template_pizzaz(module_name: str): + """Import a pizzaz template module without leftover test stubs.""" + template_root = str(ROOT / "nitrostack" / "templates" / "pizzaz") + for key in list(sys.modules): + if key == "modules" or key.startswith("modules."): + del sys.modules[key] + if template_root not in sys.path: + sys.path.insert(0, template_root) + import importlib + + return importlib.import_module(f"modules.pizzaz.{module_name}") + + +def test_pizzaz_open_now_filter_excludes_closed_shops(): + """Match TS getShopsFiltered({ openNow: true }) — closed shops must drop out.""" + pizzaz_service = _load_template_pizzaz("pizzaz_service") + service = pizzaz_service.PizzazService() + all_shops = service.get_all_shops() + assert any(not shop["openNow"] for shop in all_shops) + + opened = service.get_shops_filtered({"openNow": True}) + assert opened + assert all(shop["openNow"] for shop in opened) + assert len(opened) < len(all_shops) + + as_string = service.get_shops_filtered({"openNow": "true"}) + assert [s["id"] for s in as_string] == [s["id"] for s in opened] + + closed_or_all = service.get_shops_filtered({"openNow": False}) + # False / empty / "false" means "don't care" — all shops, not closed-only. + assert [s["id"] for s in closed_or_all] == [s["id"] for s in all_shops] + assert [s["id"] for s in service.get_shops_filtered({"openNow": ""})] == [s["id"] for s in all_shops] + assert [s["id"] for s in service.get_shops_filtered({"openNow": "false"})] == [s["id"] for s in all_shops] + + +def test_show_pizza_map_blank_filter_returns_all_shops(): + """Inspector sends filter='' — must match TS optional enum + filter || 'all'.""" + from nitrostack.core.app import parse_tool_input + + pizzaz_tools = _load_template_pizzaz("pizzaz_tools") + parsed = parse_tool_input(pizzaz_tools.ShowMapInput, {"filter": ""}) + assert parsed.filter == "all" + assert pizzaz_tools.ShowMapInput(filter="").filter == "all" + assert pizzaz_tools.ShowMapInput(filter="all").filter == "all" + + +def test_mapbox_token_reads_env_only(): + os.environ.pop("MAPBOX_TOKEN", None) + os.environ.pop("NEXT_PUBLIC_MAPBOX_TOKEN", None) + assert get_mapbox_token() == "" + os.environ["NEXT_PUBLIC_MAPBOX_TOKEN"] = "pk.customtoken123" + try: + assert get_mapbox_token() == "pk.customtoken123" + finally: + os.environ.pop("NEXT_PUBLIC_MAPBOX_TOKEN", None) + + +def test_resources_read_bundle_injects_mapbox_token(): + """Studio loads widgets via resources/read → get_bundle, not tools/call HTML.""" + os.environ.pop("MAPBOX_TOKEN", None) + os.environ.pop("NEXT_PUBLIC_MAPBOX_TOKEN", None) + disk = (TEMPLATE_OUT / "pizza-map.html").read_text(encoding="utf-8") + assert 'window.__NITRO_MAPBOX_TOKEN = ""' in disk + component = create_component(id="pizza-map", name="Pizza map", html=disk) + assert 'window.__NITRO_MAPBOX_TOKEN = ""' in component.get_bundle() + + os.environ["MAPBOX_TOKEN"] = "pk.customtoken123" + try: + bundle = component.get_bundle() + assert 'window.__NITRO_MAPBOX_TOKEN = "pk.customtoken123"' in bundle + filled = component.html_with_data( + { + "shops": [{"id": "tonys-pizza", "name": "Tony's", "coords": [-122.4, 37.7]}], + "filter": "all", + "totalShops": 1, + } + ) + assert 'window.__NITRO_MAPBOX_TOKEN = "pk.customtoken123"' in filled + assert inject_mapbox_token(disk).count("pk.customtoken123") == 1 + finally: + os.environ.pop("MAPBOX_TOKEN", None) + + +def test_pizza_map_live_html_uses_mapbox(): + html = render_widget_html( + "pizza-map", + { + "shops": [ + { + "id": "tonys-pizza", + "name": "Tony's New York Pizza", + "address": "123 Main St", + "coords": [-122.4194, 37.7749], + "rating": 4.5, + } + ], + "filter": "all", + "totalShops": 1, + }, + ) + assert html is not None + assert "mapbox-gl.js" in html + assert "mapboxgl.accessToken" in html + assert "Tony" in html + assert 'id="map"' in html + assert "map-live" in html + assert "pk.eyJ" not in html + assert "NavigationControl" in html + assert "__nitroMapSig" in html + + +if __name__ == "__main__": + test_pizzaz_html_files_exist_in_template() + test_pizzaz_builtin_templates_match_disk() + test_load_widget_html_walks_from_tool_module() + test_pizzaz_resources_and_tool_call() + test_live_http_widget_preview_calls_tool() + test_pizzaz_open_now_filter_excludes_closed_shops() + test_show_pizza_map_blank_filter_returns_all_shops() + test_mapbox_token_reads_env_only() + test_resources_read_bundle_injects_mapbox_token() + test_pizza_map_live_html_uses_mapbox() + print("pizzaz widget tests passed") diff --git a/tests/test_pr12_review.py b/tests/test_pr12_review.py new file mode 100644 index 0000000..b1c1ef0 --- /dev/null +++ b/tests/test_pr12_review.py @@ -0,0 +1,173 @@ +"""Review fixes for PR #12 (widget render isolation, _meta merge, XSS, preview JSON).""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +import mcp.types as types + +from nitrostack.core.app import McpApplication +from nitrostack.widgets.component import Component +from nitrostack.widgets.html_util import json_for_inline_script +from nitrostack.widgets.preview_page import render_preview_page +from nitrostack.widgets.ui import link_attrs, safe_href +from nitrostack.widgets.views import chart_body, pizza_list_body + + +class _Host: + _widget_result_content = McpApplication._widget_result_content + _to_call_tool_result = McpApplication._to_call_tool_result + _call_tool_result_meta = staticmethod(McpApplication._call_tool_result_meta) + + +def test_widget_render_error_keeps_json_result(): + host = _Host() + component = Component(id="pizza-list", name="Pizza list", html="

ok

") + + def boom(_data): + raise ValueError("invalid literal for int() with base 10: 'expensive'") + + component.html_with_data = boom # type: ignore[method-assign] + structured = {"shops": [{"id": "x", "priceLevel": "expensive"}], "totalShops": 1} + content = host._widget_result_content(structured, component) + types_found = {getattr(block, "type", None) for block in content} + assert "text" in types_found + assert "resource" not in types_found + assert "resource_link" in types_found + text = next(block.text for block in content if getattr(block, "type", None) == "text") + assert "expensive" in text + + +def test_custom_meta_merges_widget_meta(): + host = _Host() + component = Component(id="sample", name="Sample", html="

ok

") + result = types.CallToolResult( + content=[types.TextContent(type="text", text="ok")], + structuredContent={"ok": True}, + isError=False, + **{"_meta": {"custom": 1}}, + ) + merged = host._to_call_tool_result(result, component) + meta = merged.meta or {} + assert meta["custom"] == 1 + assert meta["ui"]["resourceUri"] == component.resource_uri + assert meta["openai/outputTemplate"] == component.resource_uri + + +def test_link_attrs_allowlists_schemes(): + assert "javascript:" not in link_attrs("javascript:alert(1)") + assert safe_href("javascript:alert(1)") == "" + assert safe_href("https://example.com/x") == "https://example.com/x" + assert 'href="https://example.com/x"' in link_attrs("https://example.com/x") + assert safe_href("mailto:a@b.com").startswith("mailto:") + assert safe_href("tel:+1555").startswith("tel:") + assert safe_href("//evil.example") == "" + assert safe_href("data:text/html,hi") == "" + + +def test_pizza_list_coerces_bad_price_level(): + html = pizza_list_body( + { + "shops": [ + { + "id": "a", + "name": "Shop A", + "address": "1 Main", + "priceLevel": "expensive", + "openNow": True, + } + ], + "totalShops": 1, + } + ) + assert "Shop A" in html + assert "expensive" not in html + + +def test_chart_body_coerces_bad_values(): + html = chart_body({"title": "T", "items": [{"label": "A", "value": "nope"}]}) + assert "T" in html + assert "A" in html + + +def test_preview_page_escapes_script_breaking_json(): + html = render_preview_page( + [ + { + "name": "", + "resourceUri": "ui://widget/x.html", + "arguments": {}, + } + ] + ) + assert ""}] + ).startswith("[") + + +# --------------------------------------------------------------------------- +# Follow-up findings from the re-review of 7d5530e +# --------------------------------------------------------------------------- + + +def test_generate_tool_validates_route_before_writing(tmp_path, monkeypatch): + """`_foo` is a valid Python identifier but an invalid widget route. + + The route check must run before any file is written, otherwise the `.py` file + is left behind and then blocks the retry with "File already exists". + """ + from nitrostack.cli.main import generate_tool + + monkeypatch.chdir(tmp_path) + try: + generate_tool("_foo") + raise AssertionError("expected generate_tool to exit for a route-invalid name") + except SystemExit as exc: + assert exc.code == 1 + + assert list(tmp_path.iterdir()) == [], ( + f"generate_tool left files behind after failing: {list(tmp_path.iterdir())}" + ) + + +def test_ensure_python_widgets_only_reports_written_routes(tmp_path, capsys): + """A route that cannot be scaffolded must not be reported as created.""" + from nitrostack.cli.main import ensure_python_widgets + + (tmp_path / "tools.py").write_text( + 'from nitrostack import widget\n\n' + '@widget("good-route")\n' + 'def a(): pass\n\n' + '@widget("bad/route")\n' + 'def b(): pass\n', + encoding="utf-8", + ) + + reported = ensure_python_widgets(str(tmp_path)) + out_dir = tmp_path / "widgets" / "out" + on_disk = sorted(p.stem for p in out_dir.glob("*.html")) + + assert reported == on_disk == ["good-route"] + assert "bad/route" not in reported + assert "skipped widget route" in capsys.readouterr().out + + +def test_safe_int_survives_infinity(): + """`json.loads('1e400')` yields `inf`; `int(float('inf'))` raises OverflowError. + + `_safe_float` already guards this — `_safe_int` must too, otherwise the + EmbeddedResource is dropped and the widget silently fails to render. + """ + from nitrostack.widgets.views import _safe_float, _safe_int + + assert _safe_int(float("inf")) == 0 + assert _safe_int(float("-inf")) == 0 + assert _safe_int(float("nan")) == 0 + assert _safe_int(float("inf"), minimum=0, maximum=4) == 0 + assert _safe_float(float("inf")) == 0.0 + # Ordinary values still coerce normally. + assert _safe_int(3.7) == 3 + assert _safe_int("2") == 2 diff --git a/tests/test_template_widgets.py b/tests/test_template_widgets.py new file mode 100644 index 0000000..89085e2 --- /dev/null +++ b/tests/test_template_widgets.py @@ -0,0 +1,154 @@ +"""Widget HTML for all three official Python templates (starter, pizzaz, oauth).""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from pathlib import Path + +from nitrostack.widgets.component import Component +from nitrostack.widgets.route_templates import get_builtin_route_html, render_widget_html +from nitrostack.widgets.views import pizza_list_body, render_body + +ROOT = Path(__file__).resolve().parent.parent +TEMPLATES = ROOT / "nitrostack" / "templates" + + +def test_starter_calculator_html_exists(): + path = TEMPLATES / "starter" / "widgets" / "out" / "calculator-result.html" + assert path.is_file() + text = path.read_text(encoding="utf-8") + assert "Calculator result" in text + assert text == get_builtin_route_html("calculator-result") + + +def test_oauth_flight_html_files_exist(): + routes = ( + "flight-search-results", + "flight-details", + "airport-search", + "order-summary", + "seat-selection", + "order-cancellation", + ) + out = TEMPLATES / "flight-booking" / "widgets" / "out" + for route in routes: + path = out / f"{route}.html" + assert path.is_file(), f"missing {path}" + builtin = get_builtin_route_html(route) + assert builtin is not None + assert path.read_text(encoding="utf-8") == builtin + + +def test_python_pizza_list_renders_all_tool_shops(): + shops = [ + {"id": "a", "name": "Shop A", "rating": 4.5, "address": "1 Main", "priceLevel": 2, "openNow": True}, + {"id": "b", "name": "Shop B", "rating": 4.1, "address": "2 Main", "priceLevel": 1, "openNow": False}, + {"id": "c", "name": "Shop C", "rating": 4.9, "address": "3 Main", "priceLevel": 3, "openNow": True}, + ] + html = pizza_list_body({"shops": shops, "totalShops": 3}) + assert "Shop A" in html + assert "Shop B" in html + assert "Shop C" in html + assert "3 shops" in html + empty = pizza_list_body(None) + assert "Waiting" in empty + assert "Shop A" not in empty + + +def test_component_html_with_data_matches_tool_output(): + component = Component(id="pizza-list", name="Pizza list", html=get_builtin_route_html("pizza-list") or "") + filled = component.html_with_data( + { + "shops": [ + {"id": "a", "name": "Live Shop", "rating": 5, "address": "A St", "priceLevel": 1, "openNow": True} + ], + "totalShops": 1, + } + ) + assert "Live Shop" in filled + assert 'data-nitro-ssr="1"' in filled + static = get_builtin_route_html("pizza-list") or "" + assert "Live Shop" not in static + + +def test_flight_search_body_uses_ts_camelcase_shape(): + html = render_body( + "flight-search-results", + { + "searchParams": {"origin": "DEL", "destination": "LHR", "departureDate": "2026-08-19"}, + "totalOffers": 1, + "offers": [ + { + "id": "off_1", + "totalAmount": "450.00", + "totalCurrency": "USD", + "outbound": { + "origin": "DEL", + "destination": "LHR", + "departureTime": "2026-08-19T08:00:00Z", + "arrivalTime": "2026-08-19T14:30:00Z", + "duration": "PT6H30M", + "airline": "Mock Airlines", + "flightNumber": "MK123", + }, + } + ], + }, + ) + assert "DEL" in html + assert "LHR" in html + assert "450.00" in html + assert "Mock Airlines" in html + + +def test_airport_search_body_uses_ts_camelcase_shape(): + html = render_body( + "airport-search", + { + "query": "Delhi", + "results": [ + { + "iataCode": "DEL", + "name": "Indira Gandhi International Airport", + "cityName": "Delhi", + "type": "airport", + } + ], + }, + ) + assert "DEL" in html + assert "Indira Gandhi" in html + assert "Delhi" in html + + +def test_flight_search_body_uses_duffel_shape(): + html = render_body( + "flight-search-results", + { + "offers": [ + { + "id": "off_1", + "total_amount": "450.00", + "total_currency": "USD", + "slices": [ + { + "origin": {"iata_code": "JFK"}, + "destination": {"iata_code": "LAX"}, + "duration": "PT6H30M", + } + ], + } + ] + }, + ) + assert "JFK" in html + assert "LAX" in html + assert "450.00" in html + + +def test_render_widget_html_unknown_route(): + assert render_widget_html("not-a-real-route") is None + assert get_builtin_route_html("calculator-result") is not None diff --git a/tests/test_tool_input_schema.py b/tests/test_tool_input_schema.py index 0ec9c32..ed144b5 100644 --- a/tests/test_tool_input_schema.py +++ b/tests/test_tool_input_schema.py @@ -189,6 +189,31 @@ def test_parse_tool_input_accepts_inspector_and_legacy_wrap(): shop_wrapped = parse_tool_input(ShowShopInput, {"input": {"shopId": "bella-napoli"}}) assert shop_wrapped.shopId == "bella-napoli" + # Inspector leaves unused enum/optional fields as "". + blank_map = parse_tool_input(ShowMapInput, {"filter": ""}) + assert blank_map.filter == "all" + whitespace_map = parse_tool_input(ShowMapInput, {"filter": " "}) + assert whitespace_map.filter == "all" + missing_map = parse_tool_input(ShowMapInput, {}) + assert missing_map.filter == "all" + wrapped_blank = parse_tool_input(ShowMapInput, {"input": {"filter": ""}}) + assert wrapped_blank.filter == "all" + explicit_all = parse_tool_input(ShowMapInput, {"filter": "all"}) + assert explicit_all.filter == "all" + open_now = parse_tool_input(ShowMapInput, {"filter": "open_now"}) + assert open_now.filter == "open_now" + + blank_list = parse_tool_input(ShowListInput, {"openNow": "", "minRating": "", "maxPrice": ""}) + assert blank_list.openNow is None + assert blank_list.minRating is None + assert blank_list.maxPrice is None + + try: + parse_tool_input(ShowShopInput, {"shopId": ""}) + raise AssertionError("required shopId must still reject an empty string") + except Exception: + pass + async def _listed_tools() -> Dict[str, types.Tool]: harness = await NitroTestingModule.create(SchemaToolsModule) @@ -240,6 +265,14 @@ async def _run(): ) assert calc_wrapped["operation"] == "multiply" + # Inspector pizza-map form sends filter="" when left empty / "all". + mapped = await harness.call_tool("show_pizza_map", {"filter": ""}) + assert mapped["filter"] == "all" + mapped_all = await harness.call_tool("show_pizza_map", {"filter": "all"}) + assert mapped_all["filter"] == "all" + mapped_open = await harness.call_tool("show_pizza_map", {"filter": "open_now"}) + assert mapped_open["filter"] == "open_now" + asyncio.run(_run()) diff --git a/tests/test_transports.py b/tests/test_transports.py index a9b26eb..23bcabc 100644 --- a/tests/test_transports.py +++ b/tests/test_transports.py @@ -171,6 +171,7 @@ def test_http_health_and_cors(): assert root.status_code == 200 assert "text/html" in root.headers.get("content-type", "") assert "MCP" in root.text + assert "/widgets/preview" in root.text version = client.get("/json/version") assert version.status_code == 200 @@ -634,6 +635,53 @@ def test_delete_terminates_live_session_and_404s_unknown_one(): print("Success! DELETE /mcp terminates a live session and 404s an unknown one.") +def test_oauth_register_returns_json_not_html(): + """Inspector Auth-on DCR hits POST /register; HTML 404s parse as invalid OAuth JSON.""" + DIContainer.reset() + app = asyncio.run(_build_app()) + http_app = build_http_app(app, enable_cors=True, stateless=True) + with TestClient(http_app) as client: + resp = client.post("/register", json={"client_name": "inspector"}) + assert resp.status_code == 404 + assert "application/json" in resp.headers.get("content-type", "") + body = resp.json() + assert body["error"] == "invalid_request" + assert "OAuth" in body["error_description"] + well_known = client.get("/.well-known/oauth-authorization-server") + assert well_known.status_code == 404 + assert well_known.json()["error"] == "invalid_request" + + +def test_oauth_configured_skips_not_supported_stubs(): + """Real OAuthModule must not be shadowed by Inspector 'this server does not use OAuth' JSON.""" + from nitrostack.auth.oauth import OAuthModule + + DIContainer.reset() + OAuthModule.for_root( + resource_uri="http://localhost:3000/mcp", + authorization_servers=["http://localhost:3000/oauth"], + scopes_supported=["read"], + ) + try: + app = asyncio.run(_build_app()) + http_app = build_http_app(app, enable_cors=True, stateless=True) + with TestClient(http_app) as client: + well_known = client.get("/.well-known/oauth-protected-resource") + assert well_known.status_code == 404 + ctype = well_known.headers.get("content-type", "") + if "json" in ctype: + assert "does not use OAuth" not in well_known.json().get("error_description", "") + else: + assert "does not use OAuth" not in well_known.text + register = client.post("/register", json={"client_name": "inspector"}) + assert register.status_code == 404 + if "json" in register.headers.get("content-type", ""): + body = register.json() + assert body.get("error_description") is None or "does not use OAuth" not in body["error_description"] + finally: + DIContainer.reset() + + if __name__ == "__main__": DIContainer.reset() test_http_health_and_cors() @@ -649,4 +697,6 @@ def test_delete_terminates_live_session_and_404s_unknown_one(): test_wildcard_and_missing_accept_are_honoured() test_unsupported_protocol_version_header_does_not_fail_request() test_delete_terminates_live_session_and_404s_unknown_one() + test_oauth_register_returns_json_not_html() + test_oauth_configured_skips_not_supported_stubs() print("\nAll Phase 3 transport tests passed successfully!") diff --git a/tests/test_widget_host_ui.py b/tests/test_widget_host_ui.py new file mode 100644 index 0000000..858b668 --- /dev/null +++ b/tests/test_widget_host_ui.py @@ -0,0 +1,139 @@ +"""Host-bridge RPCs and Python HTML builders for interactive widgets.""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from nitrostack.widgets.host_bridge import HOST_BRIDGE_JS +from nitrostack.widgets.route_templates import render_widget_html +from nitrostack.widgets.ui import action_row, maps_url, phone_href +from nitrostack.widgets.views import ( + flight_details_body, + flight_search_results_body, + order_summary_body, + pizza_list_body, + pizza_map_body, + pizza_shop_body, +) + + +def test_host_bridge_exposes_mcp_and_openai_rpcs(): + assert "tools/call" in HOST_BRIDGE_JS + assert "ui/open-link" in HOST_BRIDGE_JS + assert "ui/request-display-mode" in HOST_BRIDGE_JS + assert "ui/notifications/host-context-changed" in HOST_BRIDGE_JS + assert "availableDisplayModes" in HOST_BRIDGE_JS + assert "window.openai.callTool" in HOST_BRIDGE_JS + assert "window.openai.openExternal" in HOST_BRIDGE_JS + assert "window.openai.requestDisplayMode" in HOST_BRIDGE_JS + assert "window.openai.setWidgetState" in HOST_BRIDGE_JS + assert "window.nitrostack" in HOST_BRIDGE_JS + assert "__nitrostack_isSafeUrl" in HOST_BRIDGE_JS + assert "/^(https?|mailto|tel):/i.test" in HOST_BRIDGE_JS + + +def test_pizza_list_cards_call_show_shop(): + html = pizza_list_body( + { + "shops": [ + {"id": "tonys-pizza", "name": "Tony's", "rating": 4.5, "address": "1 Main", "openNow": True} + ], + "totalShops": 1, + } + ) + assert 'data-call-tool="show_pizza_shop"' in html + assert "tonys-pizza" in html + assert "data-sort" in html + + +def test_pizza_map_cards_call_show_shop(): + html = pizza_map_body( + { + "shops": [ + { + "id": "tonys-pizza", + "name": "Tony's", + "address": "1 Main", + "coords": [-122.4, 37.7], + "rating": 4.5, + } + ], + "totalShops": 1, + } + ) + assert 'data-call-tool="show_pizza_shop"' in html + assert "tonys-pizza" in html + + +def test_pizza_shop_has_open_link_actions(): + html = pizza_shop_body( + { + "shop": { + "id": "tonys-pizza", + "name": "Tony's New York Pizza", + "address": "123 Main St", + "coords": [-122.4194, 37.7749], + "phone": "(415) 555-0123", + "website": "https://tonyspizza.example.com", + "rating": 4.5, + "reviews": 10, + } + } + ) + assert "data-open-link" in html + assert "tonyspizza.example.com" in html + assert "tel:" in html + assert "google.com/maps" in html + + +def test_maps_and_phone_helpers(): + assert maps_url({"coords": [-122.4, 37.8]}).endswith("37.8,-122.4") + assert phone_href("(415) 555-0123") == "tel:4155550123" + row = action_row(maps="https://maps.example", phone="555", website="https://x.example") + assert "data-open-link" in row + assert "Maps" in row + assert "Call" in row + assert "Website" in row + unsafe = action_row(website="javascript:alert(1)") + assert "javascript:" not in unsafe + assert "Website" not in unsafe + + +def test_flight_search_cards_call_details(): + html = flight_search_results_body( + { + "searchParams": {"origin": "DEL", "destination": "LHR"}, + "offers": [{"id": "off_1", "totalAmount": "450.00", "totalCurrency": "USD"}], + } + ) + assert 'data-call-tool="get_flight_details"' in html + assert "off_1" in html + + +def test_flight_details_and_order_have_next_actions(): + details = flight_details_body({"id": "off_1", "totalAmount": "10", "slices": []}) + assert 'data-call-tool="get_seat_map"' in details + order = order_summary_body({"id": "ord_1", "status": "held", "passengers": [], "slices": []}) + assert 'data-call-tool="cancel_order"' in order + + +def test_rendered_widgets_include_theme_and_not_generic_json_viewer(): + details = render_widget_html("flight-details", {"id": "off_1", "slices": []}) + assert details is not None + assert "get_seat_map" in details + assert "JSON.stringify(data, null, 2)" not in details + assert "color-scheme" in details + assert "--ns-bg" in details + assert "data-display-mode" in details + + seats = render_widget_html("seat-selection", {"offerId": "off_1", "cabins": []}) + assert seats is not None + assert "No seat map data" in seats + assert "JSON.stringify(data, null, 2)" not in seats + + cancel = render_widget_html("order-cancellation", {"status": "cancelled", "message": "Done"}) + assert cancel is not None + assert "Booking cancelled" in cancel + assert "JSON.stringify(data, null, 2)" not in cancel diff --git a/tests/test_widget_metadata.py b/tests/test_widget_metadata.py index 614cbe9..d474ba9 100644 --- a/tests/test_widget_metadata.py +++ b/tests/test_widget_metadata.py @@ -2,77 +2,104 @@ import os import sys -# Ensure parent directory is in sys.path sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -from nitrostack import injectable, tool, widget, module, ExecutionContext +from nitrostack import injectable, tool, widget, module, ExecutionContext, WidgetOptions from nitrostack.core.decorators import widget_resource_uri from nitrostack.testing import NitroTestingModule from pydantic import BaseModel import mcp.types as types + class DummyInput(BaseModel): pass + +class DummyOutput(BaseModel): + status: str + + @injectable() class WidgetController: @tool( name="widget_tool", description="A tool with a widget", - input_schema=DummyInput + input_schema=DummyInput, + output_schema=DummyOutput, ) @widget("my-custom-widget-route") async def widget_tool(self, input: DummyInput, context: ExecutionContext) -> dict: return {"status": "ok"} -@module( - name="widget_test", - controllers=[WidgetController] -) + @tool( + name="widget_tool_object", + description="Widget via WidgetOptions", + input_schema=DummyInput, + ) + @widget(WidgetOptions(route="object-route", prefers_border=True)) + async def widget_tool_object(self, input: DummyInput, context: ExecutionContext) -> dict: + return {"status": "ok"} + + +@module(name="widget_test", controllers=[WidgetController]) class WidgetTestModule: pass -async def main(): - print("Testing widget decorator metadata mapping...") - - # 1. Initialize test harness - harness = await NitroTestingModule.create(WidgetTestModule) - - # 2. Extract tools by invoking the registered `tools/list` handler directly - # (the owned low-level Server has no FastMCP-style `list_tools()` convenience method) - list_tools_handler = harness.app.mcp_server.request_handlers[types.ListToolsRequest] - list_result = await list_tools_handler(None) - tools = list_result.root.tools - - # Find our tool - target_tool = None - for t in tools: - if t.name == "widget_tool": - target_tool = t - break - - assert target_tool is not None, "widget_tool was not registered" - - print("Registered tool representation:", target_tool) - - # Verify metadata fields are present - meta = getattr(target_tool, "meta", None) - if meta is None: - meta = getattr(target_tool, "_meta", {}) - - assert meta is not None, "Tool metadata is missing" - print("Tool metadata:", meta) - - # Check that widget fields are populated in metadata - assert meta.get("ui/template") == "ui://widget/my-custom-widget-route.html" - assert meta.get("openai/outputTemplate") == "ui://widget/my-custom-widget-route.html" - assert meta.get("ui") == {"resourceUri": "ui://widget/my-custom-widget-route.html"} - - print("Success! Widget metadata is correctly mapped and verified in the MCP Tool specification.") - - -def test_widget_metadata_on_listed_tool(): - asyncio.run(main()) + +def _tool_meta(tool: types.Tool) -> dict: + return getattr(tool, "meta", None) or getattr(tool, "_meta", {}) or {} + + +async def _list_tools_for_mode(mode: str | None): + old = os.environ.get("NITROSTACK_APP_MODE") + if mode is None: + os.environ.pop("NITROSTACK_APP_MODE", None) + else: + os.environ["NITROSTACK_APP_MODE"] = mode + try: + harness = await NitroTestingModule.create(WidgetTestModule) + handler = harness.app.mcp_server.request_handlers[types.ListToolsRequest] + result = await handler(None) + return result.root.tools + finally: + if old is None: + os.environ.pop("NITROSTACK_APP_MODE", None) + else: + os.environ["NITROSTACK_APP_MODE"] = old + + +def test_widget_metadata_openai_mode(): + tools = asyncio.run(_list_tools_for_mode("openai")) + target = next(t for t in tools if t.name == "widget_tool") + meta = _tool_meta(target) + uri = "ui://widget/my-custom-widget-route.html" + assert meta.get("ui/template") == uri + assert meta.get("openai/outputTemplate") == uri + assert "ui" not in meta + assert getattr(target, "outputTemplate", None) == uri + schema = getattr(target, "outputSchema", None) + assert isinstance(schema, dict) + assert "status" in (schema.get("properties") or {}) + + +def test_widget_metadata_mcp_app_mode(): + tools = asyncio.run(_list_tools_for_mode("mcp-app")) + target = next(t for t in tools if t.name == "widget_tool") + meta = _tool_meta(target) + uri = "ui://widget/my-custom-widget-route.html" + assert meta.get("ui/template") == uri + assert meta.get("ui") == {"resourceUri": uri, "visibility": "visible"} + assert "openai/outputTemplate" not in meta + + +def test_widget_metadata_object_form_universal(): + tools = asyncio.run(_list_tools_for_mode("universal")) + target = next(t for t in tools if t.name == "widget_tool_object") + meta = _tool_meta(target) + uri = "ui://widget/object-route.html" + assert meta.get("openai/outputTemplate") == uri + assert meta["ui"]["resourceUri"] == uri + assert meta["ui"]["prefersBorder"] is True def test_widget_resource_uri_normalization(): @@ -87,4 +114,7 @@ def test_widget_resource_uri_normalization(): if __name__ == "__main__": test_widget_resource_uri_normalization() - asyncio.run(main()) + test_widget_metadata_openai_mode() + test_widget_metadata_mcp_app_mode() + test_widget_metadata_object_form_universal() + print("widget metadata tests passed.") diff --git a/tests/test_widget_parity.py b/tests/test_widget_parity.py new file mode 100644 index 0000000..72ceac8 --- /dev/null +++ b/tests/test_widget_parity.py @@ -0,0 +1,184 @@ +"""TS template widget map must be exposed by every Python project copy. + +TypeScript ``@Widget`` tools: + starter calculate -> calculator-result + pizzaz show_pizza_map/list/shop + oauth search_flights, get_flight_details, search_airports, + create_order, get_order_details, get_seat_map, cancel_order + +Tools that must stay widget-free (same as TS): convert_temperature, get_airlines. +""" +from __future__ import annotations + +import asyncio +import os +import sys +from pathlib import Path + +import mcp.types as types + +from nitrostack.core.di import DIContainer +from nitrostack.testing import NitroTestingModule + +ROOT = Path(__file__).resolve().parent.parent + +TS_WIDGETS = { + "starter": { + "calculate": "calculator-result", + }, + "pizzaz": { + "show_pizza_map": "pizza-map", + "show_pizza_list": "pizza-list", + "show_pizza_shop": "pizza-shop", + }, + "flight-booking": { + "search_flights": "flight-search-results", + "get_flight_details": "flight-details", + "search_airports": "airport-search", + "create_order": "order-summary", + "get_order_details": "order-summary", + "get_seat_map": "seat-selection", + "cancel_order": "order-cancellation", + }, +} + +NO_WIDGET = { + "starter": ("convert_temperature",), + "pizzaz": (), + "flight-booking": ("get_airlines",), +} + +CALLS = { + "calculate": {"operation": "add", "a": 2, "b": 3}, + "show_pizza_map": {"filter": "all"}, + "show_pizza_list": {}, + "show_pizza_shop": {"shopId": "tonys-pizza"}, + "search_flights": { + "origin": "DEL", + "destination": "LHR", + "departureDate": "2026-08-19", + "adults": 2, + "cabinClass": "economy", + }, + "get_flight_details": {"offerId": "off_mock123456"}, + "search_airports": {"query": "Delhi"}, + "create_order": { + "offerId": "off_mock123456", + "passengers": '[{"title":"mr","givenName":"Ada","familyName":"Lovelace","gender":"F","bornOn":"1990-01-15","email":"ada@example.com","phoneNumber":"+15550001"}]', + }, + "get_order_details": {"orderId": "ord_mock123456"}, + "get_seat_map": {"offerId": "off_mock123456"}, + "cancel_order": {"orderId": "ord_mock123456"}, +} + +PROJECTS = ( + ("starter", ROOT / "nitrostack" / "templates" / "starter", "starter"), + ("pizzaz", ROOT / "nitrostack" / "templates" / "pizzaz", "pizzaz"), + ("pizza-app", ROOT / "pizza-app", "pizzaz"), + ("flight-booking", ROOT / "nitrostack" / "templates" / "flight-booking", "flight-booking"), + ("flight-book-app", ROOT / "flight-book-app", "flight-booking"), +) + + +def _purge_local_imports() -> None: + doomed = [ + key + for key in list(sys.modules) + if key == "app_module" + or key.startswith("app_module.") + or key in {"modules", "services", "guards", "health"} + or key.startswith(("modules.", "services.", "guards.", "health.")) + ] + for key in doomed: + sys.modules.pop(key, None) + + +def _tool_meta(tool: types.Tool) -> dict: + return getattr(tool, "meta", None) or getattr(tool, "_meta", None) or {} + + +def _boot(project_dir: Path): + os.environ.pop("OAUTH_REQUIRED", None) + os.environ.setdefault("NITROSTACK_APP_MODE", "universal") + os.environ.pop("DUFFEL_API_KEY", None) + _purge_local_imports() + DIContainer.reset() + sys.path.insert(0, str(project_dir)) + try: + from app_module import AppModule # type: ignore + + return asyncio.run(NitroTestingModule.create(AppModule)) + finally: + if sys.path and sys.path[0] == str(project_dir): + sys.path.pop(0) + + +def _assert_project(label: str, project_dir: Path, family: str) -> None: + expected = TS_WIDGETS[family] + harness = _boot(project_dir) + list_handler = harness.app.mcp_server.request_handlers[types.ListToolsRequest] + tools = asyncio.run(list_handler(None)).root.tools + by_name = {tool.name: tool for tool in tools} + + list_res = harness.app.mcp_server.request_handlers[types.ListResourcesRequest] + resources = asyncio.run(list_res(None)).root.resources + uris = {str(res.uri) for res in resources} + + for name, route in expected.items(): + assert name in by_name, f"{label}: missing tool {name}" + uri = f"ui://widget/{route}.html" + meta = _tool_meta(by_name[name]) + assert meta.get("ui", {}).get("resourceUri") == uri, f"{label}: {name} widget meta {meta}" + assert uri in uris, f"{label}: resources/list missing {uri}" + + call_handler = harness.app.mcp_server.request_handlers[types.CallToolRequest] + raw = asyncio.run( + call_handler( + types.CallToolRequest( + method="tools/call", + params=types.CallToolRequestParams(name=name, arguments=CALLS[name]), + ) + ) + ) + payload = raw.root + assert payload.isError is not True, f"{label}: {name} error {getattr(payload, 'content', None)}" + assert payload.structuredContent, f"{label}: {name} has no structuredContent" + embedded = [block for block in payload.content if getattr(block, "type", None) == "resource"] + assert embedded, f"{label}: {name} tools/call missing EmbeddedResource widget" + assert str(embedded[0].resource.uri) == uri + html = embedded[0].resource.text + assert html and " dict: + return getattr(tool, "meta", None) or getattr(tool, "_meta", {}) or {} + + +import contextlib + + +@contextlib.contextmanager +def app_mode(mode: str | None): + old = os.environ.get("NITROSTACK_APP_MODE") + if mode is None: + os.environ.pop("NITROSTACK_APP_MODE", None) + else: + os.environ["NITROSTACK_APP_MODE"] = mode + try: + yield + finally: + if old is None: + os.environ.pop("NITROSTACK_APP_MODE", None) + else: + os.environ["NITROSTACK_APP_MODE"] = old + + +def _make_widget_module( + *, + with_html_file: bool = False, + object_form: bool = False, + task_support: str = "forbidden", + invocation: ToolInvocation | None = None, +): + html_path = FIXTURES / "sample.html" + if with_html_file: + FIXTURES.mkdir(parents=True, exist_ok=True) + html_path.write_text("widget", encoding="utf-8") + + widget_spec = ( + WidgetOptions( + route="sample", + prefers_border=True, + domain="https://app.example.com", + csp=WidgetCsp(connect_domains=["https://api.example.com"]), + can_invoke_tools=True, + ) + if object_form + else "sample" + ) + + @injectable() + class WidgetTestController: + @tool( + name="plain_tool", + description="No widget", + input_schema=EmptyInput, + ) + async def plain_tool(self, input: EmptyInput, context: ExecutionContext) -> dict: + return {"ok": True} + + @tool( + name="widget_tool", + description="Widget tool", + input_schema=EchoInput, + task_support=task_support, + invocation=invocation, + ) + @widget(widget_spec) + async def widget_tool(self, input: EchoInput, context: ExecutionContext) -> dict: + return {"value": input.value, "rendered": True} + + @module(name="widget_test_mod", controllers=[WidgetTestController]) + class WidgetTestModule: + pass + + return WidgetTestModule, html_path if with_html_file else None + + +async def _harness(module_cls, *, chdir_to_fixtures: bool = False): + if chdir_to_fixtures: + os.chdir(FIXTURES.parent.parent.parent) # tests/fixtures + @mcp_app(module=module_cls, server=ServerConfig(name="widget-test")) + class _App: + pass + return await NitroTestingModule.create(module_cls) + + +# --------------------------------------------------------------------------- +# Unit: app mode + component +# --------------------------------------------------------------------------- + +def test_widget_mime_type_by_mode(): + with app_mode("openai"): + assert get_widget_mime_type() == RESOURCE_MIME_TYPE_OPENAI + with app_mode("mcp-app"): + assert get_widget_mime_type() == RESOURCE_MIME_TYPE_MCP_APP + with app_mode("universal"): + assert get_widget_mime_type() == RESOURCE_MIME_TYPE_MCP_APP + with app_mode(None): + assert get_widget_mime_type() == RESOURCE_MIME_TYPE_MCP_APP + assert OPENAI_SKYBRIDGE_MIME_TYPE == "text/html+skybridge" + assert get_widget_mime_type() != OPENAI_SKYBRIDGE_MIME_TYPE + + +def test_component_resource_uri_and_bundle(): + c = create_component(id="card", name="Card", html="
hi
", css="body{}", js="console.log(1)") + assert c.resource_uri == "ui://widget/card.html" + assert "
hi
" in c.get_bundle() + assert "