From 30308332bb13e75282f1bea5214a112db743454a Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 9 Mar 2026 13:46:14 -0400 Subject: [PATCH] =?UTF-8?q?Add=20FastMCPApp=20=E2=80=94=20a=20Provider=20f?= =?UTF-8?q?or=20composable=20MCP=20applications=20(#3385)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add FastMCPApp — a Provider for composable MCP applications * Wire Prefab callable resolver via to_json(tool_resolver=) parameter * Remove inspect.signature compat check, use try/except until prefab 0.10.0 * Address review: fix add_tool registry gaps, normalize auth errors, bump prefab to 0.10.0 * Register global key after _add_component succeeds * Simplify: extract decorator dispatch, use get_fastmcp_meta, expose get_global_tool * Remove prek from Marvin workflows These workflows run Claude to respond to /marvin mentions — linting the repo is unnecessary and fails without renderer deps installed. * Return ResolvedTool from callable resolver, add contacts example The callable resolver now returns ResolvedTool (from prefab_ui) instead of a plain string, carrying metadata like unwrap_result that the renderer needs to correctly handle structuredContent envelopes. The unwrap_result flag is derived from the tool's x-fastmcp-wrap-result output schema marker. * Bump prefab-ui requirement to >=0.11.0 * Remove stale ty ignore comments now that prefab-ui 0.11 is published --- .github/workflows/marvin-comment-on-issue.yml | 5 - .github/workflows/marvin-comment-on-pr.yml | 5 - examples/apps/contacts/contacts_server.py | 152 ++++ pyproject.toml | 2 +- src/fastmcp/__init__.py | 6 + src/fastmcp/server/app.py | 471 ++++++++++++ src/fastmcp/server/server.py | 19 + src/fastmcp/tools/tool.py | 23 +- tests/test_fastmcp_app.py | 692 ++++++++++++++++++ uv.lock | 18 +- 10 files changed, 1375 insertions(+), 18 deletions(-) create mode 100644 examples/apps/contacts/contacts_server.py create mode 100644 src/fastmcp/server/app.py create mode 100644 tests/test_fastmcp_app.py diff --git a/.github/workflows/marvin-comment-on-issue.yml b/.github/workflows/marvin-comment-on-issue.yml index 8d297a226..89579840f 100644 --- a/.github/workflows/marvin-comment-on-issue.yml +++ b/.github/workflows/marvin-comment-on-issue.yml @@ -36,11 +36,6 @@ jobs: - name: Install dependencies run: uv sync --python 3.12 - - name: Run prek - uses: j178/prek-action@v1 - env: - SKIP: no-commit-to-branch - - name: Generate Marvin App token id: marvin-token uses: actions/create-github-app-token@v2 diff --git a/.github/workflows/marvin-comment-on-pr.yml b/.github/workflows/marvin-comment-on-pr.yml index 09f699522..879c37eb5 100644 --- a/.github/workflows/marvin-comment-on-pr.yml +++ b/.github/workflows/marvin-comment-on-pr.yml @@ -38,11 +38,6 @@ jobs: - name: Install dependencies run: uv sync --python 3.12 - - name: Run prek - uses: j178/prek-action@v1 - env: - SKIP: no-commit-to-branch - - name: Generate Marvin App token id: marvin-token uses: actions/create-github-app-token@v2 diff --git a/examples/apps/contacts/contacts_server.py b/examples/apps/contacts/contacts_server.py new file mode 100644 index 000000000..6483af748 --- /dev/null +++ b/examples/apps/contacts/contacts_server.py @@ -0,0 +1,152 @@ +"""Contact manager — a FastMCPApp example with forms and callable tool references. + +Demonstrates the full FastMCPApp stack: +- @app.ui() entry point that the model calls to open the app +- @app.tool() backend tools that the UI calls via CallTool +- CallTool(fn) with function references (not strings) that resolve to global keys +- Form.from_model() for auto-generated Pydantic model forms +- Manual form construction with the context-manager pattern + +Usage: + uv run python contacts_server.py # HTTP (default) + uv run python contacts_server.py --stdio # stdio for MCP clients +""" + +from __future__ import annotations + +from typing import Literal + +from prefab_ui.actions import SetState, ShowToast +from prefab_ui.actions.mcp import CallTool +from prefab_ui.app import PrefabApp +from prefab_ui.components import ( + Badge, + Button, + Column, + ForEach, + Form, + Heading, + Input, + Muted, + Row, + Separator, + Text, +) +from prefab_ui.rx import RESULT +from pydantic import BaseModel, Field + +from fastmcp import FastMCP, FastMCPApp + +# --------------------------------------------------------------------------- +# Data +# --------------------------------------------------------------------------- + +_contacts: list[dict] = [ + { + "name": "Arthur Dent", + "email": "arthur@earth.com", + "category": "Customer", + "notes": "", + }, + { + "name": "Ford Prefect", + "email": "ford@betelgeuse.org", + "category": "Partner", + "notes": "Researcher", + }, +] + + +# --------------------------------------------------------------------------- +# Pydantic model for auto-generated forms +# --------------------------------------------------------------------------- + + +class ContactModel(BaseModel): + name: str = Field(title="Full Name", min_length=1) + email: str = Field(title="Email") + category: Literal["Customer", "Vendor", "Partner", "Other"] = "Other" + notes: str = Field( + default="", + title="Notes", + json_schema_extra={"ui": {"type": "textarea"}}, + ) + + +# --------------------------------------------------------------------------- +# App +# --------------------------------------------------------------------------- + +app = FastMCPApp("Contacts") + + +@app.tool() +def save_contact(data: ContactModel) -> list[dict]: + """Save a new contact and return the updated list.""" + _contacts.append(data.model_dump()) + return list(_contacts) + + +@app.tool() +def search_contacts(query: str) -> list[dict]: + """Filter contacts by name or email.""" + q = query.lower() + return [c for c in _contacts if q in c["name"].lower() or q in c["email"].lower()] + + +@app.tool(model=True) +def list_contacts() -> list[dict]: + """Return all contacts. Visible to both the model and the UI.""" + return list(_contacts) + + +@app.ui() +def contact_manager() -> PrefabApp: + """Open the contact manager. The model calls this to launch the app.""" + with Column(gap=6, css_class="p-6") as view: + Heading("Contacts") + + with ForEach("contacts") as contact: + with Row(gap=2, align="center"): + Text(contact.name, css_class="font-medium") + Muted(contact.email) + Badge(contact.category) + + Separator() + + Heading("Add Contact", level=3) + Form.from_model( + ContactModel, + on_submit=CallTool( + save_contact, + on_success=[ + SetState("contacts", RESULT), + ShowToast("Contact saved!", variant="success"), + ], + on_error=ShowToast("{{ $error }}", variant="error"), + ), + ) + + Separator() + + Heading("Search", level=3) + with Form( + on_submit=CallTool( + search_contacts, + arguments={"query": "{{ query }}"}, + on_success=SetState("contacts", RESULT), + ) + ): + Input(name="query", placeholder="Search by name or email...") + Button("Search") + + return PrefabApp( + view=view, + state={"contacts": list(_contacts)}, + ) + + +mcp = FastMCP("Contacts Server", providers=[app]) + +if __name__ == "__main__": + mcp.run(transport="http") diff --git a/pyproject.toml b/pyproject.toml index 320938b2e..7d808412a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ classifiers = [ [project.optional-dependencies] anthropic = ["anthropic>=0.40.0"] -apps = ["prefab-ui>=0.6.0"] +apps = ["prefab-ui>=0.11.0"] azure = ["azure-identity>=1.16.0"] code-mode = ["pydantic-monty>=0.0.7"] gemini = ["google-genai>=1.18.0"] diff --git a/src/fastmcp/__init__.py b/src/fastmcp/__init__.py index a524b402c..2fe1a4688 100644 --- a/src/fastmcp/__init__.py +++ b/src/fastmcp/__init__.py @@ -10,6 +10,7 @@ from fastmcp.utilities.logging import configure_logging as _configure_logging if TYPE_CHECKING: from fastmcp.client import Client as Client + from fastmcp.server.app import FastMCPApp as FastMCPApp settings = Settings() if settings.log_enabled: @@ -40,6 +41,10 @@ def __getattr__(name: str) -> object: from fastmcp.client import Client return Client + if name == "FastMCPApp": + from fastmcp.server.app import FastMCPApp + + return FastMCPApp if name == "client": return importlib.import_module("fastmcp.client") raise AttributeError(f"module {__name__!r} has no attribute {name!r}") @@ -49,5 +54,6 @@ __all__ = [ "Client", "Context", "FastMCP", + "FastMCPApp", "settings", ] diff --git a/src/fastmcp/server/app.py b/src/fastmcp/server/app.py new file mode 100644 index 000000000..7d7f93cb4 --- /dev/null +++ b/src/fastmcp/server/app.py @@ -0,0 +1,471 @@ +"""FastMCPApp — a Provider that represents a composable MCP application. + +FastMCPApp binds entry-point tools (model calls these) together with backend +tools (the UI calls these via CallTool). Backend tools get global keys — +UUID-suffixed stable identifiers that survive namespace transforms when +servers are composed — so ``CallTool(save_contact)`` keeps working even when +the app is mounted under a namespace. + +Usage:: + + from fastmcp import FastMCP, FastMCPApp + + app = FastMCPApp("Dashboard") + + @app.ui() + def show_dashboard() -> Component: + return Column(...) + + @app.tool() + def save_contact(name: str, email: str) -> dict: + return {"name": name, "email": email} + + server = FastMCP("Platform") + server.add_provider(app) +""" + +from __future__ import annotations + +import inspect +import uuid +from collections.abc import AsyncIterator, Callable, Sequence +from contextlib import asynccontextmanager, suppress +from typing import Any, Literal, TypeVar, overload + +from mcp.types import AnyFunction, Icon, ToolAnnotations + +from fastmcp.decorators import get_fastmcp_meta +from fastmcp.server.auth.authorization import AuthCheck +from fastmcp.server.providers.base import Provider +from fastmcp.server.providers.local_provider import LocalProvider +from fastmcp.tools.tool import Tool +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + +F = TypeVar("F", bound=Callable[..., Any]) + +# --------------------------------------------------------------------------- +# Process-level registries +# --------------------------------------------------------------------------- +# Global key → Tool object. FastMCP.call_tool checks this before normal +# provider resolution so that CallTool("save_contact-a1b2c3d4") reaches the +# right tool regardless of namespace transforms. +_APP_TOOL_REGISTRY: dict[str, Tool] = {} + +# id(original_fn) → global key. Used by the CallTool callable resolver to +# translate ``CallTool(save_contact)`` → ``"save_contact-a1b2c3d4"``. +_FN_TO_GLOBAL_KEY: dict[int, str] = {} + + +def get_global_tool(name: str) -> Tool | None: + """Look up a tool by its global key, or return None.""" + return _APP_TOOL_REGISTRY.get(name) + + +# --------------------------------------------------------------------------- +# Global key helpers +# --------------------------------------------------------------------------- + + +def _make_global_key(name: str) -> str: + """Generate a global key: ``{name}-{8_hex_chars}``.""" + return f"{name}-{uuid.uuid4().hex[:8]}" + + +def _register_global_key(tool: Tool, fn: Any, global_key: str) -> None: + """Register a tool in both process-level registries.""" + _APP_TOOL_REGISTRY[global_key] = tool + _FN_TO_GLOBAL_KEY[id(fn)] = global_key + + +def _stamp_global_key(tool: Tool, global_key: str) -> None: + """Write the global key into the tool's ``meta["ui"]["globalKey"]``.""" + meta = dict(tool.meta) if tool.meta else {} + ui = dict(meta.get("ui", {})) if isinstance(meta.get("ui"), dict) else {} + ui["globalKey"] = global_key + meta["ui"] = ui + tool.meta = meta + + +# --------------------------------------------------------------------------- +# CallTool callable resolver +# --------------------------------------------------------------------------- + + +def _resolve_tool_ref(fn: Any) -> Any: + """Resolve a callable to a ``ResolvedTool`` for CallTool serialization. + + Always returns a ``ResolvedTool`` with the resolved name and any + metadata the renderer needs (e.g. ``unwrap_result``). + + Resolution order: + 1. Global key registry (FastMCPApp tools) — includes metadata + 2. ``__fastmcp__`` metadata (decorated but not on a FastMCPApp) + 3. ``fn.__name__`` (bare function — works for standalone servers) + """ + from prefab_ui.app import ResolvedTool + + global_key = _FN_TO_GLOBAL_KEY.get(id(fn)) + if global_key is not None: + tool = _APP_TOOL_REGISTRY.get(global_key) + unwrap = bool( + tool is not None + and tool.output_schema + and tool.output_schema.get("x-fastmcp-wrap-result") + ) + return ResolvedTool(name=global_key, unwrap_result=unwrap) + + fmeta = get_fastmcp_meta(fn) + if fmeta is not None: + name: str | None = getattr(fmeta, "name", None) + if name is not None: + return ResolvedTool(name=name) + + fn_name = getattr(fn, "__name__", None) + if fn_name is not None: + return ResolvedTool(name=fn_name) + + raise ValueError(f"Cannot resolve tool reference: {fn!r}") + + +def _dispatch_decorator( + name_or_fn: str | AnyFunction | None, + name: str | None, + register: Callable[[Any, str | None], Any], + decorator_name: str, +) -> Any: + """Shared dispatch logic for @app.tool() and @app.ui() calling patterns.""" + if inspect.isroutine(name_or_fn): + return register(name_or_fn, name) + + if isinstance(name_or_fn, str): + if name is not None: + raise TypeError( + "Cannot specify both a name as first argument and as keyword argument." + ) + tool_name: str | None = name_or_fn + elif name_or_fn is None: + tool_name = name + else: + raise TypeError( + f"First argument to @{decorator_name} must be a function, string, or None, " + f"got {type(name_or_fn)}" + ) + + def decorator(fn: F) -> F: + return register(fn, tool_name) + + return decorator + + +# --------------------------------------------------------------------------- +# FastMCPApp +# --------------------------------------------------------------------------- + + +class FastMCPApp(Provider): + """A Provider that represents an MCP application. + + Binds together entry-point tools (``@app.ui``), backend tools + (``@app.tool``), the Prefab renderer resource, and global-key + infrastructure so that composed/namespaced servers can still reach + backend tools by stable identifiers. + """ + + def __init__(self, name: str) -> None: + super().__init__() + self.name = name + self._local = LocalProvider(on_duplicate="error") + + def __repr__(self) -> str: + return f"FastMCPApp({self.name!r})" + + # ------------------------------------------------------------------ + # @app.tool() — backend tools called by the UI + # ------------------------------------------------------------------ + + @overload + def tool( + self, + name_or_fn: F, + *, + name: str | None = None, + description: str | None = None, + model: bool = False, + auth: AuthCheck | list[AuthCheck] | None = None, + timeout: float | None = None, + ) -> F: ... + + @overload + def tool( + self, + name_or_fn: str | None = None, + *, + name: str | None = None, + description: str | None = None, + model: bool = False, + auth: AuthCheck | list[AuthCheck] | None = None, + timeout: float | None = None, + ) -> Callable[[F], F]: ... + + def tool( + self, + name_or_fn: str | AnyFunction | None = None, + *, + name: str | None = None, + description: str | None = None, + model: bool = False, + auth: AuthCheck | list[AuthCheck] | None = None, + timeout: float | None = None, + ) -> Any: + """Register a backend tool that the UI calls via CallTool. + + Backend tools get a global key for composition safety and default + to ``visibility=["app"]``. Pass ``model=True`` to also expose the + tool to the model (``visibility=["app", "model"]``). + + Supports multiple calling patterns:: + + @app.tool + def save(name: str): ... + + @app.tool() + def save(name: str): ... + + @app.tool("custom_name") + def save(name: str): ... + """ + visibility: list[Literal["app", "model"]] = ( + ["app", "model"] if model else ["app"] + ) + + def _register(fn: F, tool_name: str | None) -> F: + resolved_name = tool_name or getattr(fn, "__name__", None) + if resolved_name is None: + raise ValueError(f"Cannot determine tool name for {fn!r}") + + from fastmcp.server.apps import AppConfig, app_config_to_meta_dict + + global_key = _make_global_key(resolved_name) + app_config = AppConfig(visibility=visibility) + meta: dict[str, Any] = {"ui": app_config_to_meta_dict(app_config)} + meta["ui"]["globalKey"] = global_key + + tool_obj = Tool.from_function( + fn, + name=resolved_name, + description=description, + meta=meta, + timeout=timeout, + auth=auth, + ) + self._local._add_component(tool_obj) + _register_global_key(tool_obj, fn, global_key) + return fn + + return _dispatch_decorator(name_or_fn, name, _register, "tool") + + # ------------------------------------------------------------------ + # @app.ui() — entry-point tools the model calls to open the app + # ------------------------------------------------------------------ + + @overload + def ui( + self, + name_or_fn: F, + *, + name: str | None = None, + description: str | None = None, + title: str | None = None, + tags: set[str] | None = None, + icons: list[Icon] | None = None, + annotations: ToolAnnotations | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, + timeout: float | None = None, + ) -> F: ... + + @overload + def ui( + self, + name_or_fn: str | None = None, + *, + name: str | None = None, + description: str | None = None, + title: str | None = None, + tags: set[str] | None = None, + icons: list[Icon] | None = None, + annotations: ToolAnnotations | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, + timeout: float | None = None, + ) -> Callable[[F], F]: ... + + def ui( + self, + name_or_fn: str | AnyFunction | None = None, + *, + name: str | None = None, + description: str | None = None, + title: str | None = None, + tags: set[str] | None = None, + icons: list[Icon] | None = None, + annotations: ToolAnnotations | None = None, + auth: AuthCheck | list[AuthCheck] | None = None, + timeout: float | None = None, + ) -> Any: + """Register a UI entry-point tool that the model calls. + + Entry-point tools default to ``visibility=["model"]`` and auto-wire + the Prefab renderer resource and CSP. They do NOT get a global key — + the model resolves them through the normal transform chain. + + Supports multiple calling patterns:: + + @app.ui + def dashboard() -> Component: ... + + @app.ui() + def dashboard() -> Component: ... + + @app.ui("my_dashboard") + def dashboard() -> Component: ... + """ + + def _register(fn: F, tool_name: str | None) -> F: + from fastmcp.server.apps import AppConfig, app_config_to_meta_dict + from fastmcp.server.providers.local_provider.decorators.tools import ( + PREFAB_RENDERER_URI, + _ensure_prefab_renderer, + ) + + try: + from prefab_ui.renderer import get_renderer_csp + + from fastmcp.server.apps import ResourceCSP + + csp = get_renderer_csp() + app_config = AppConfig( + resource_uri=PREFAB_RENDERER_URI, + visibility=["model"], + csp=ResourceCSP( + resource_domains=csp.get("resource_domains"), + connect_domains=csp.get("connect_domains"), + ), + ) + except ImportError: + app_config = AppConfig( + resource_uri=PREFAB_RENDERER_URI, + visibility=["model"], + ) + + meta: dict[str, Any] = {"ui": app_config_to_meta_dict(app_config)} + + tool_obj = Tool.from_function( + fn, + name=tool_name, + description=description, + title=title, + tags=tags, + icons=icons, + annotations=annotations, + meta=meta, + timeout=timeout, + auth=auth, + ) + self._local._add_component(tool_obj) + + # Register the Prefab renderer resource on the internal provider + with suppress(ImportError): + _ensure_prefab_renderer(self._local) + + return fn + + return _dispatch_decorator(name_or_fn, name, _register, "ui") + + # ------------------------------------------------------------------ + # Programmatic tool addition + # ------------------------------------------------------------------ + + def add_tool( + self, + tool: Tool | Callable[..., Any], + *, + fn: Any | None = None, + ) -> Tool: + """Add a tool to this app programmatically. + + If the tool has ``meta["ui"]["globalKey"]``, it is assumed to already + be configured (but still registered for lookup). Otherwise it is + treated as a backend tool and gets a global key assigned automatically. + + Pass ``fn`` to register the original callable in the resolver so that + ``CallTool(fn)`` can resolve to the global key. + """ + if not isinstance(tool, Tool): + fn = fn or tool + tool = Tool._ensure_tool(tool) + + meta = tool.meta or {} + ui = meta.get("ui", {}) + if isinstance(ui, dict) and "globalKey" in ui: + global_key = ui["globalKey"] + else: + global_key = _make_global_key(tool.name) + _stamp_global_key(tool, global_key) + + self._local._add_component(tool) + + _APP_TOOL_REGISTRY[global_key] = tool + if fn is not None: + _FN_TO_GLOBAL_KEY[id(fn)] = global_key + + return tool + + # ------------------------------------------------------------------ + # Provider interface — delegate to internal LocalProvider + # ------------------------------------------------------------------ + + async def _list_tools(self) -> Sequence[Tool]: + return await self._local._list_tools() + + async def _get_tool(self, name: str, version: Any = None) -> Tool | None: + return await self._local._get_tool(name, version) + + async def _list_resources(self) -> Sequence[Any]: + return await self._local._list_resources() + + async def _get_resource(self, uri: str, version: Any = None) -> Any | None: + return await self._local._get_resource(uri, version) + + async def _list_resource_templates(self) -> Sequence[Any]: + return await self._local._list_resource_templates() + + async def _get_resource_template(self, uri: str, version: Any = None) -> Any | None: + return await self._local._get_resource_template(uri, version) + + async def _list_prompts(self) -> Sequence[Any]: + return await self._local._list_prompts() + + async def _get_prompt(self, name: str, version: Any = None) -> Any | None: + return await self._local._get_prompt(name, version) + + @asynccontextmanager + async def lifespan(self) -> AsyncIterator[None]: + async with self._local.lifespan(): + yield + + # ------------------------------------------------------------------ + # Convenience runner + # ------------------------------------------------------------------ + + def run( + self, + transport: Literal["stdio", "http", "sse", "streamable-http"] | None = None, + **kwargs: Any, + ) -> None: + """Create a temporary FastMCP server and run this app standalone.""" + from fastmcp.server.server import FastMCP + + server = FastMCP(self.name) + server.add_provider(self) + server.run(transport=transport, **kwargs) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 92e7afe36..f65cb86c9 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -1086,7 +1086,26 @@ class FastMCP( with server_span( f"tools/call {name}", "tools/call", self.name, "tool", name ) as span: + # Try normal provider resolution first (applies transforms, + # visibility, auth). Fall back to the global key registry + # so that FastMCPApp CallTool references survive namespace + # transforms. Global keys contain a UUID suffix, so they + # won't collide with human-written tool names. tool = await self.get_tool(name, version=version) + if tool is None: + from fastmcp.server.app import get_global_tool + + tool = get_global_tool(name) + if tool is not None: + # Auth still applies to global-key tools + skip_auth, token = _get_auth_context() + if not skip_auth and tool.auth is not None: + try: + ctx = AuthContext(token=token, component=tool) + if not await run_auth_checks(tool.auth, ctx): + raise NotFoundError(f"Unknown tool: {name!r}") + except AuthorizationError: + raise NotFoundError(f"Unknown tool: {name!r}") from None if tool is None: raise NotFoundError(f"Unknown tool: {name!r}") span.set_attributes(tool.get_span_attributes()) diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index b23ebfc8b..a720d262f 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -94,9 +94,11 @@ class ToolResult(BaseModel): # generic serialization, so the renderer gets the right shape. if _HAS_PREFAB: if isinstance(structured_content, _PrefabApp): - structured_content = structured_content.to_json() + structured_content = _prefab_to_json(structured_content) elif isinstance(structured_content, _PrefabComponent): - structured_content = _PrefabApp(view=structured_content).to_json() + structured_content = _prefab_to_json( + _PrefabApp(view=structured_content) + ) try: structured_content = pydantic_core.to_jsonable_python( @@ -479,11 +481,26 @@ def _convert_to_single_content_block( _PREFAB_TEXT_FALLBACK = "[Rendered Prefab UI]" +def _get_tool_resolver() -> Callable[..., str] | None: + """Get the FastMCPApp callable resolver, if available.""" + try: + from fastmcp.server.app import _resolve_tool_ref + + return _resolve_tool_ref + except ImportError: + return None + + +def _prefab_to_json(app: Any) -> dict[str, Any]: + """Call PrefabApp.to_json() with the FastMCPApp callable resolver.""" + return app.to_json(tool_resolver=_get_tool_resolver()) + + def _prefab_to_tool_result(app: Any) -> ToolResult: """Convert a PrefabApp to a FastMCP ToolResult.""" return ToolResult( content=[TextContent(type="text", text=_PREFAB_TEXT_FALLBACK)], - structured_content=app.to_json(), + structured_content=_prefab_to_json(app), ) diff --git a/tests/test_fastmcp_app.py b/tests/test_fastmcp_app.py new file mode 100644 index 000000000..eabcef4a6 --- /dev/null +++ b/tests/test_fastmcp_app.py @@ -0,0 +1,692 @@ +"""Tests for FastMCPApp — the composable application provider. + +Covers: +- @app.tool() decorator (global keys, visibility, calling patterns) +- @app.ui() decorator (model visibility, CSP auto-wiring) +- Global key registry and call_tool routing +- Callable resolver (_resolve_tool_ref) +- Composition with namespaced servers +- Provider interface delegation +""" + +from __future__ import annotations + +import re +from unittest.mock import AsyncMock + +import pytest +from prefab_ui.app import ResolvedTool + +from fastmcp import Client, FastMCP +from fastmcp.server.app import ( + _APP_TOOL_REGISTRY, + _FN_TO_GLOBAL_KEY, + FastMCPApp, + _make_global_key, + _resolve_tool_ref, +) +from fastmcp.tools.tool import Tool + +# --------------------------------------------------------------------------- +# Fixtures / helpers +# --------------------------------------------------------------------------- + +GLOBAL_KEY_PATTERN = re.compile(r"^.+-[0-9a-f]{8}$") + + +def _clear_registries() -> None: + """Clear process-level registries between tests.""" + _APP_TOOL_REGISTRY.clear() + _FN_TO_GLOBAL_KEY.clear() + + +# --------------------------------------------------------------------------- +# Global key generation +# --------------------------------------------------------------------------- + + +class TestGlobalKeyGeneration: + def test_make_global_key_format(self): + key = _make_global_key("save_contact") + assert GLOBAL_KEY_PATTERN.match(key) + assert key.startswith("save_contact-") + + def test_make_global_key_uniqueness(self): + keys = {_make_global_key("my_tool") for _ in range(100)} + assert len(keys) == 100 + + +# --------------------------------------------------------------------------- +# @app.tool() decorator +# --------------------------------------------------------------------------- + + +class TestAppTool: + def setup_method(self) -> None: + _clear_registries() + + def test_tool_bare_decorator(self): + app = FastMCPApp("test") + + @app.tool + def save(name: str) -> str: + return name + + # Function is returned unchanged + assert save("alice") == "alice" + + def test_tool_empty_parens(self): + app = FastMCPApp("test") + + @app.tool() + def save(name: str) -> str: + return name + + assert save("alice") == "alice" + + def test_tool_custom_name(self): + app = FastMCPApp("test") + + @app.tool("custom_save") + def save(name: str) -> str: + return name + + assert save("alice") == "alice" + + def test_tool_name_kwarg(self): + app = FastMCPApp("test") + + @app.tool(name="my_tool") + def save(name: str) -> str: + return name + + assert save("alice") == "alice" + + def test_tool_name_conflict_raises(self): + app = FastMCPApp("test") + + with pytest.raises(TypeError): + + @app.tool("x", name="y") + def save() -> str: + return "" + + async def test_tool_registers_in_provider(self): + app = FastMCPApp("test") + + @app.tool() + def save(name: str) -> str: + return name + + tools = await app._list_tools() + assert len(tools) == 1 + assert tools[0].name == "save" + + async def test_tool_custom_name_in_provider(self): + app = FastMCPApp("test") + + @app.tool("custom_save") + def save(name: str) -> str: + return name + + tools = await app._list_tools() + assert len(tools) == 1 + assert tools[0].name == "custom_save" + + def test_tool_gets_global_key(self): + app = FastMCPApp("test") + + @app.tool() + def save(name: str) -> str: + return name + + # Check the function is registered in the global key registry + assert id(save) in _FN_TO_GLOBAL_KEY + global_key = _FN_TO_GLOBAL_KEY[id(save)] + assert GLOBAL_KEY_PATTERN.match(global_key) + assert global_key.startswith("save-") + assert global_key in _APP_TOOL_REGISTRY + + async def test_tool_global_key_in_meta(self): + app = FastMCPApp("test") + + @app.tool() + def save(name: str) -> str: + return name + + tools = await app._list_tools() + tool = tools[0] + assert tool.meta is not None + assert "ui" in tool.meta + assert "globalKey" in tool.meta["ui"] + assert GLOBAL_KEY_PATTERN.match(tool.meta["ui"]["globalKey"]) + + async def test_tool_default_visibility_app_only(self): + app = FastMCPApp("test") + + @app.tool() + def save(name: str) -> str: + return name + + tools = await app._list_tools() + meta = tools[0].meta + assert meta is not None + assert meta["ui"]["visibility"] == ["app"] + + async def test_tool_model_visibility(self): + app = FastMCPApp("test") + + @app.tool(model=True) + def query(search: str) -> list: + return [] + + tools = await app._list_tools() + meta = tools[0].meta + assert meta is not None + assert meta["ui"]["visibility"] == ["app", "model"] + + def test_tool_with_description(self): + app = FastMCPApp("test") + + @app.tool(description="Save a contact") + def save(name: str) -> str: + return name + + def test_tool_with_auth(self): + app = FastMCPApp("test") + check = AsyncMock(return_value=True) + + @app.tool(auth=check) + def save(name: str) -> str: + return name + + def test_tool_with_timeout(self): + app = FastMCPApp("test") + + @app.tool(timeout=30.0) + def slow_save(name: str) -> str: + return name + + +# --------------------------------------------------------------------------- +# @app.ui() decorator +# --------------------------------------------------------------------------- + + +class TestAppUI: + def setup_method(self) -> None: + _clear_registries() + + def test_ui_bare_decorator(self): + app = FastMCPApp("test") + + @app.ui + def dashboard() -> str: + return "hi" + + assert dashboard() == "hi" + + def test_ui_empty_parens(self): + app = FastMCPApp("test") + + @app.ui() + def dashboard() -> str: + return "hi" + + assert dashboard() == "hi" + + def test_ui_custom_name(self): + app = FastMCPApp("test") + + @app.ui("my_dashboard") + def dashboard() -> str: + return "hi" + + assert dashboard() == "hi" + + async def test_ui_registers_in_provider(self): + app = FastMCPApp("test") + + @app.ui() + def dashboard() -> str: + return "dashboard" + + tools = await app._list_tools() + assert len(tools) == 1 + assert tools[0].name == "dashboard" + + async def test_ui_visibility_model_only(self): + app = FastMCPApp("test") + + @app.ui() + def dashboard() -> str: + return "dashboard" + + tools = await app._list_tools() + meta = tools[0].meta + assert meta is not None + assert meta["ui"]["visibility"] == ["model"] + + def test_ui_no_global_key(self): + """UI entry points should NOT get global keys.""" + app = FastMCPApp("test") + + @app.ui() + def dashboard() -> str: + return "dashboard" + + assert id(dashboard) not in _FN_TO_GLOBAL_KEY + + async def test_ui_has_resource_uri(self): + app = FastMCPApp("test") + + @app.ui() + def dashboard() -> str: + return "dashboard" + + tools = await app._list_tools() + meta = tools[0].meta + assert meta is not None + assert meta["ui"]["resourceUri"] == "ui://prefab/renderer.html" + + async def test_ui_has_csp(self): + app = FastMCPApp("test") + + @app.ui() + def dashboard() -> str: + return "dashboard" + + tools = await app._list_tools() + meta = tools[0].meta + assert meta is not None + csp = meta["ui"].get("csp") + assert csp is not None + + async def test_ui_with_title_and_description(self): + app = FastMCPApp("test") + + @app.ui(title="My Dashboard", description="Shows data") + def dashboard() -> str: + return "dashboard" + + tools = await app._list_tools() + assert tools[0].title == "My Dashboard" + assert tools[0].description == "Shows data" + + async def test_ui_with_tags(self): + app = FastMCPApp("test") + + @app.ui(tags={"dashboard", "main"}) + def dashboard() -> str: + return "dashboard" + + tools = await app._list_tools() + assert tools[0].tags == {"dashboard", "main"} + + +# --------------------------------------------------------------------------- +# Callable resolver +# --------------------------------------------------------------------------- + + +class TestResolveToolRef: + def setup_method(self) -> None: + _clear_registries() + + def test_resolve_global_key(self): + app = FastMCPApp("test") + + @app.tool() + def save(name: str) -> str: + return name + + result = _resolve_tool_ref(save) + # str return → wrapped tool → ResolvedTool with unwrap_result + assert isinstance(result, ResolvedTool) + assert GLOBAL_KEY_PATTERN.match(result.name) + assert result.name.startswith("save-") + assert result.unwrap_result is True + + def test_resolve_global_key_object_return(self): + """Tools returning dicts don't need unwrapping.""" + + app = FastMCPApp("test") + + @app.tool() + def save(name: str) -> dict: + return {"name": name} + + result = _resolve_tool_ref(save) + assert isinstance(result, ResolvedTool) + assert GLOBAL_KEY_PATTERN.match(result.name) + assert result.name.startswith("save-") + assert result.unwrap_result is False + + def test_resolve_fastmcp_metadata(self): + """Functions with __fastmcp__ metadata but no global key.""" + + from fastmcp.tools.function_tool import ToolMeta + + def my_tool(): + pass + + my_tool.__fastmcp__ = ToolMeta(name="custom_name") # type: ignore[attr-defined] + + result = _resolve_tool_ref(my_tool) + assert isinstance(result, ResolvedTool) + assert result.name == "custom_name" + + def test_resolve_bare_function(self): + def my_tool(): + pass + + result = _resolve_tool_ref(my_tool) + assert isinstance(result, ResolvedTool) + assert result.name == "my_tool" + + def test_resolve_unresolvable_raises(self): + with pytest.raises(ValueError): + _resolve_tool_ref(42) + + +# --------------------------------------------------------------------------- +# Provider interface +# --------------------------------------------------------------------------- + + +class TestProviderInterface: + def setup_method(self) -> None: + _clear_registries() + + async def test_list_tools_empty(self): + app = FastMCPApp("test") + assert await app._list_tools() == [] + + async def test_list_resources_empty(self): + app = FastMCPApp("test") + assert list(await app._list_resources()) == [] + + async def test_get_tool_by_name(self): + app = FastMCPApp("test") + + @app.tool() + def save(name: str) -> str: + return name + + tool = await app._get_tool("save") + assert tool is not None + assert tool.name == "save" + + async def test_get_tool_missing_returns_none(self): + app = FastMCPApp("test") + assert await app._get_tool("missing") is None + + +# --------------------------------------------------------------------------- +# call_tool with global key routing +# --------------------------------------------------------------------------- + + +class TestCallToolGlobalKeyRouting: + def setup_method(self) -> None: + _clear_registries() + + async def test_call_tool_by_global_key(self): + """Server.call_tool can find a FastMCPApp tool by its global key.""" + app = FastMCPApp("test") + + @app.tool() + def save(name: str) -> str: + return f"saved {name}" + + server = FastMCP("Platform") + server.add_provider(app) + + global_key = _FN_TO_GLOBAL_KEY[id(save)] + + result = await server.call_tool(global_key, {"name": "alice"}) + assert result.content[0].text == "saved alice" # type: ignore[union-attr] + + async def test_call_tool_by_name(self): + """Regular name-based resolution still works.""" + app = FastMCPApp("test") + + @app.tool() + def save(name: str) -> str: + return f"saved {name}" + + server = FastMCP("Platform") + server.add_provider(app) + + result = await server.call_tool("save", {"name": "bob"}) + assert result.content[0].text == "saved bob" # type: ignore[union-attr] + + async def test_global_key_survives_namespace(self): + """Global key works even when the app is mounted under a namespace.""" + app = FastMCPApp("crm") + + @app.tool() + def save_contact(name: str) -> str: + return f"saved {name}" + + server = FastMCP("Platform") + server.add_provider(app, namespace="crm") + + global_key = _FN_TO_GLOBAL_KEY[id(save_contact)] + + # Global key should still work + result = await server.call_tool(global_key, {"name": "alice"}) + assert result.content[0].text == "saved alice" # type: ignore[union-attr] + + async def test_namespaced_name_also_works(self): + """Namespaced tool name works through normal resolution.""" + app = FastMCPApp("crm") + + @app.tool(model=True) + def save_contact(name: str) -> str: + return f"saved {name}" + + server = FastMCP("Platform") + server.add_provider(app, namespace="crm") + + result = await server.call_tool("crm_save_contact", {"name": "bob"}) + assert result.content[0].text == "saved bob" # type: ignore[union-attr] + + async def test_global_key_auth_blocks_unauthorized(self): + """Auth checks run even when resolving via global key.""" + from fastmcp.exceptions import NotFoundError + from fastmcp.server.context import _current_transport + + app = FastMCPApp("test") + deny_all = AsyncMock(return_value=False) + + @app.tool(auth=deny_all) + def secret() -> str: + return "classified" + + server = FastMCP("Platform") + server.add_provider(app) + + global_key = _FN_TO_GLOBAL_KEY[id(secret)] + + # Simulate non-stdio transport so auth is not skipped + token = _current_transport.set("streamable-http") + try: + with pytest.raises(NotFoundError): + await server.call_tool(global_key, {}) + finally: + _current_transport.reset(token) + + +# --------------------------------------------------------------------------- +# End-to-end via Client +# --------------------------------------------------------------------------- + + +class TestEndToEnd: + def setup_method(self) -> None: + _clear_registries() + + async def test_ui_tool_visible_to_client(self): + """UI entry-point tools show up in list_tools via Client.""" + app = FastMCPApp("test") + + @app.ui() + def dashboard() -> str: + return "dashboard" + + server = FastMCP("Platform") + server.add_provider(app) + + async with Client(server) as client: + tools = await client.list_tools() + names = [t.name for t in tools] + assert "dashboard" in names + + async def test_app_tool_model_true_visible(self): + """App tools with model=True are visible via Client.""" + app = FastMCPApp("test") + + @app.tool(model=True) + def query(search: str) -> list: + return [search] + + server = FastMCP("Platform") + server.add_provider(app) + + async with Client(server) as client: + tools = await client.list_tools() + names = [t.name for t in tools] + assert "query" in names + + async def test_call_tool_via_global_key_through_client(self): + """Client can call a tool using its global key.""" + app = FastMCPApp("test") + + @app.tool() + def save(name: str) -> str: + return f"saved {name}" + + server = FastMCP("Platform") + server.add_provider(app) + + global_key = _FN_TO_GLOBAL_KEY[id(save)] + + async with Client(server) as client: + result = await client.call_tool(global_key, {"name": "test"}) + assert "saved test" in result.content[0].text + + +# --------------------------------------------------------------------------- +# .run() convenience +# --------------------------------------------------------------------------- + + +class TestRun: + def test_repr(self): + app = FastMCPApp("Dashboard") + assert repr(app) == "FastMCPApp('Dashboard')" + + +# --------------------------------------------------------------------------- +# add_tool programmatic +# --------------------------------------------------------------------------- + + +class TestAddTool: + def setup_method(self) -> None: + _clear_registries() + + async def test_add_tool_from_function(self): + app = FastMCPApp("test") + + def save(name: str) -> str: + return name + + tool = app.add_tool(save) + assert tool.name == "save" + + tools = await app._list_tools() + assert len(tools) == 1 + + async def test_add_tool_gets_global_key(self): + app = FastMCPApp("test") + + def save(name: str) -> str: + return name + + tool = app.add_tool(save) + assert tool.meta is not None + assert "globalKey" in tool.meta.get("ui", {}) + + async def test_add_tool_object(self): + app = FastMCPApp("test") + tool = Tool.from_function(lambda x: x, name="my_tool") + added = app.add_tool(tool) + assert added.name == "my_tool" + + tools = await app._list_tools() + assert len(tools) == 1 + + +# --------------------------------------------------------------------------- +# Composition +# --------------------------------------------------------------------------- + + +class TestComposition: + def setup_method(self) -> None: + _clear_registries() + + async def test_multiple_apps_on_one_server(self): + crm = FastMCPApp("CRM") + billing = FastMCPApp("Billing") + + @crm.tool() + def save_contact(name: str) -> str: + return name + + @billing.tool() + def create_invoice(amount: int) -> int: + return amount + + server = FastMCP("Platform") + server.add_provider(crm, namespace="crm") + server.add_provider(billing, namespace="billing") + + # Both tools reachable by global key + crm_key = _FN_TO_GLOBAL_KEY[id(save_contact)] + billing_key = _FN_TO_GLOBAL_KEY[id(create_invoice)] + + r1 = await server.call_tool(crm_key, {"name": "alice"}) + r2 = await server.call_tool(billing_key, {"amount": 100}) + + assert r1.content[0].text == "alice" # type: ignore[union-attr] + assert r2.content[0].text == "100" # type: ignore[union-attr] + + async def test_ui_and_tool_on_same_app(self): + app = FastMCPApp("test") + + @app.ui() + def dashboard() -> str: + return "ui" + + @app.tool() + def save(name: str) -> str: + return name + + tools = await app._list_tools() + assert len(tools) == 2 + names = {t.name for t in tools} + assert names == {"dashboard", "save"} + + async def test_ui_registers_prefab_renderer_resource(self): + app = FastMCPApp("test") + + @app.ui() + def dashboard() -> str: + return "ui" + + resources = await app._list_resources() + uris = [str(r.uri) for r in resources] + assert any("ui://prefab/renderer.html" in uri for uri in uris) diff --git a/uv.lock b/uv.lock index 4bf824746..c6e1ca62f 100644 --- a/uv.lock +++ b/uv.lock @@ -182,14 +182,24 @@ sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db wheels = [ { url = "https://files.pythonhosted.org/packages/6a/80/ea4ead0c5d52a9828692e7df20f0eafe8d26e671ce4883a0a146bb91049e/caio-0.9.25-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ca6c8ecda611478b6016cb94d23fd3eb7124852b985bdec7ecaad9f3116b9619", size = 36836, upload-time = "2025-12-26T15:22:04.662Z" }, { url = "https://files.pythonhosted.org/packages/17/b9/36715c97c873649d1029001578f901b50250916295e3dddf20c865438865/caio-0.9.25-cp310-cp310-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db9b5681e4af8176159f0d6598e73b2279bb661e718c7ac23342c550bd78c241", size = 79695, upload-time = "2025-12-26T15:22:18.818Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/07080ecb1adb55a02cbd8ec0126aa8e43af343ffabb6a71125b42670e9a1/caio-0.9.25-cp310-cp310-manylinux_2_34_aarch64.whl", hash = "sha256:bf61d7d0c4fd10ffdd98ca47f7e8db4d7408e74649ffaf4bef40b029ada3c21b", size = 79457, upload-time = "2026-03-04T22:08:16.024Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/dd55757bb671eb4c376e006c04e83beb413486821f517792ea603ef216e9/caio-0.9.25-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:ab52e5b643f8bbd64a0605d9412796cd3464cb8ca88593b13e95a0f0b10508ae", size = 77705, upload-time = "2026-03-04T22:08:17.202Z" }, { url = "https://files.pythonhosted.org/packages/ec/90/543f556fcfcfa270713eef906b6352ab048e1e557afec12925c991dc93c2/caio-0.9.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d6956d9e4a27021c8bd6c9677f3a59eb1d820cc32d0343cea7961a03b1371965", size = 36839, upload-time = "2025-12-26T15:21:40.267Z" }, { url = "https://files.pythonhosted.org/packages/51/3b/36f3e8ec38dafe8de4831decd2e44c69303d2a3892d16ceda42afed44e1b/caio-0.9.25-cp311-cp311-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf84bfa039f25ad91f4f52944452a5f6f405e8afab4d445450978cd6241d1478", size = 80255, upload-time = "2025-12-26T15:22:20.271Z" }, + { url = "https://files.pythonhosted.org/packages/df/ce/65e64867d928e6aff1b4f0e12dba0ef6d5bf412c240dc1df9d421ac10573/caio-0.9.25-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:ae3d62587332bce600f861a8de6256b1014d6485cfd25d68c15caf1611dd1f7c", size = 80052, upload-time = "2026-03-04T22:08:20.402Z" }, + { url = "https://files.pythonhosted.org/packages/46/90/e278863c47e14ec58309aa2e38a45882fbe67b4cc29ec9bc8f65852d3e45/caio-0.9.25-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:fc220b8533dcf0f238a6b1a4a937f92024c71e7b10b5a2dfc1c73604a25709bc", size = 78273, upload-time = "2026-03-04T22:08:21.368Z" }, { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" }, { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" }, { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, + { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, ] @@ -853,7 +863,7 @@ requires-dist = [ { name = "opentelemetry-api", specifier = ">=1.20.0" }, { name = "packaging", specifier = ">=24.0" }, { name = "platformdirs", specifier = ">=4.0.0" }, - { name = "prefab-ui", marker = "extra == 'apps'", specifier = ">=0.6.0" }, + { name = "prefab-ui", marker = "extra == 'apps'", specifier = ">=0.11.0" }, { name = "py-key-value-aio", extras = ["filetree", "keyring", "memory"], specifier = ">=0.4.4,<0.5.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.11.7" }, { name = "pydantic-monty", marker = "extra == 'code-mode'", specifier = ">=0.0.7" }, @@ -1762,16 +1772,16 @@ wheels = [ [[package]] name = "prefab-ui" -version = "0.8.3" +version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cyclopts" }, { name = "pydantic" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/32/e7bc3db47cd93e2683241bd2a328b3136c44c4332a4277a2ac2f3f6beca4/prefab_ui-0.8.3.tar.gz", hash = "sha256:bfe1304f0fc457da764763232f533bff732160e8cc8ce12977ba48f8e7574152", size = 2819196, upload-time = "2026-02-27T14:44:10.52Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/24/808cec73bcc23f5c06dfbaed5638b898264abfd790e1ab6a2a22cbbb0e5e/prefab_ui-0.11.0.tar.gz", hash = "sha256:a718ed9b9eec0afe7d37c586e824a9f69778794ab3c87d719c3916f087846420", size = 2921787, upload-time = "2026-03-09T11:12:03.532Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/23/cbc8decc176428a1f03b676fee4242f8a8d0ae7e76d5fc76ff96f13f13c1/prefab_ui-0.8.3-py3-none-any.whl", hash = "sha256:b1bac064958f08b20694e67b916ad8ab82de8db883578c31ba4437b521b425e1", size = 803888, upload-time = "2026-02-27T14:44:09.31Z" }, + { url = "https://files.pythonhosted.org/packages/13/bd/a3c1a4cbfdbe16a0f596415b3333b1f9905983bba57a29d1d8fdf859e231/prefab_ui-0.11.0-py3-none-any.whl", hash = "sha256:369b2bdba03c700b0a6aae6eb6d821224b68039f47b6980b6a133679f39d0225", size = 873016, upload-time = "2026-03-09T11:12:04.806Z" }, ] [[package]]