diff --git a/src/fastmcp/cli/apps_dev.py b/src/fastmcp/cli/apps_dev.py index ac15b9c4c..a2f16c0eb 100644 --- a/src/fastmcp/cli/apps_dev.py +++ b/src/fastmcp/cli/apps_dev.py @@ -562,6 +562,8 @@ _LOG_PANEL_HTML = """\ \u00b7 0
+ +
@@ -871,6 +873,32 @@ def _model_from_schema(tool_name: str, input_schema: dict[str, Any]) -> type[Any field_definitions: dict[str, Any] = {} for prop_name, prop in properties.items(): json_type = prop.get("type", "string") + + # Handle anyOf / oneOf (union types like str | dict | None) + for key in ("anyOf", "oneOf"): + if key in prop: + non_null = [ + t + for t in prop[key] + if isinstance(t, dict) and t.get("type") != "null" + ] + if non_null: + types = [t.get("type") for t in non_null if "type" in t] + # Prefer object/array (need textarea for JSON editing), + # then string (most versatile text input), then scalars. + for candidate in ( + "object", + "array", + "string", + "integer", + "number", + "boolean", + ): + if candidate in types: + json_type = candidate + break + break + match json_type: case "integer": py_type: type = int @@ -878,6 +906,9 @@ def _model_from_schema(tool_name: str, input_schema: dict[str, Any]) -> type[Any py_type = float case "boolean": py_type = bool + case "object" | "array": + # Render as a string textarea; api_launch parses JSON later + py_type = str case _: py_type = str @@ -897,10 +928,20 @@ def _model_from_schema(tool_name: str, input_schema: dict[str, Any]) -> type[Any from typing import Literal py_type = Literal[tuple(prop["enum"])] # type: ignore[assignment] - if prop.get("format") == "textarea" or ( - isinstance(prop.get("json_schema_extra"), dict) - and prop["json_schema_extra"].get("ui", {}).get("type") == "textarea" - ): + + # Textarea detection: + # 1. Explicit format: "textarea" in JSON schema + # 2. UI annotation: {"ui": {"type": "textarea"}} (json_schema_extra merged flat) + # 3. Object/array types need multiline JSON editing + use_textarea = ( + prop.get("format") == "textarea" + or ( + isinstance(prop.get("ui"), dict) + and prop["ui"].get("type") == "textarea" + ) + or json_type in ("object", "array") + ) + if use_textarea: extra["json_schema_extra"] = {"ui": {"type": "textarea"}} field_definitions[prop_name] = ( @@ -929,6 +970,7 @@ def _build_picker_html(tools: list[dict[str, Any]]) -> str: Pages, Select, SelectOption, + Textarea, ) from prefab_ui.components.form import Form from prefab_ui.rx import RESULT, Rx @@ -948,7 +990,7 @@ def _build_picker_html(tools: list[dict[str, Any]]) -> str: def _tool_title(tool: dict[str, Any]) -> str: return tool.get("title") or tool["name"] - with Column(gap=6, css_class="p-8 max-w-lg mx-auto") as view: + with Column(gap=6, css_class="p-8 max-w-2xl mx-auto") as view: Heading("FastMCP Apps") if len(tools) > 1: @@ -974,27 +1016,107 @@ def _build_picker_html(tools: list[dict[str, Any]]) -> str: input_schema: dict[str, Any] = tool.get("inputSchema") or {} model = _model_from_schema(name, input_schema) - body: dict[str, Any] = {"tool": name} + form_body: dict[str, Any] = {"tool": name} for field_name in model.model_fields: - body[field_name] = Rx(field_name) + form_body[field_name] = Rx(field_name) + json_body: dict[str, Any] = { + "tool": name, + "__json_args__": Rx("__json_args__"), + } + + on_error = ShowToast(Rx("$error"), variant="error") # type: ignore[arg-type] + + input_mode = f"_mode_{name}" + _desc_max_lines = 10 with Page(name, value=name), Column(gap=4): if desc: - Muted(desc, css_class="pb-2") - with Form( - on_submit=Fetch.post( - "/api/launch", - body=body, - on_success=OpenLink(RESULT), - on_error=ShowToast(Rx("$error"), variant="error"), # type: ignore[arg-type] - ), - ): - Form.from_model(model, fields_only=True) - Button( - "Launch", - variant="success", - button_type="submit", - ) + lines = desc.split("\n") + md_css = "text-sm text-muted-foreground" + if len(lines) <= _desc_max_lines: + Markdown(desc, css_class=md_css) + else: + desc_state = f"_desc_{name}" + short = "\n".join(lines[:_desc_max_lines]) + with Pages(name=desc_state, value="short"): + with ( + Page("short", value="short"), + Column(gap=1, css_class="items-start"), + ): + Markdown(short, css_class=md_css) + Button( + "Show more \u25be", + variant="link", + size="xs", + on_click=SetState(desc_state, "full"), + css_class="text-muted-foreground p-0 h-auto", + ) + with ( + Page("full", value="full"), + Column(gap=1, css_class="items-start"), + ): + Markdown(desc, css_class=md_css) + Button( + "Show less \u25b4", + variant="link", + size="xs", + on_click=SetState(desc_state, "short"), + css_class="text-muted-foreground p-0 h-auto", + ) + + with Pages(name=input_mode, value="form"): + with Page("form", value="form"), Column(gap=4): + with Column(gap=1, css_class="items-start"): + Heading("Arguments", level=3) + Button( + "Edit as JSON", + variant="link", + size="xs", + on_click=SetState(input_mode, "json"), + css_class="text-muted-foreground p-0 h-auto", + ) + with Form( + on_submit=Fetch.post( + "/api/launch", + body=form_body, + on_success=OpenLink(RESULT), + on_error=on_error, + ), + ): + Form.from_model(model, fields_only=True) + Button( + "Launch", + variant="success", + button_type="submit", + ) + with Page("json", value="json"), Column(gap=4): + with Column(gap=1, css_class="items-start"): + Heading("Arguments", level=3) + Button( + "Use form", + variant="link", + size="xs", + on_click=SetState(input_mode, "form"), + css_class="text-muted-foreground p-0 h-auto", + ) + with Form( + on_submit=Fetch.post( + "/api/launch", + body=json_body, + on_success=OpenLink(RESULT), + on_error=on_error, + ), + ): + Textarea( + name="__json_args__", + placeholder='{"key": "value"}', + rows=8, + ) + Button( + "Launch", + variant="success", + button_type="submit", + ) Markdown( "Generated by [Prefab](https://prefab.prefect.io) 🎨", @@ -1219,8 +1341,48 @@ def _make_dev_app( """Picker form submits here; returns a /launch URL string for OpenLink.""" data = await request.json() tool = data.pop("tool", "") - # Remaining keys are tool arguments; pass all including empty optionals - tool_args = dict(data) + + # JSON mode: the entire argument dict arrives as a raw JSON string. + # Key uses a dunder prefix to avoid collisions with real tool params. + raw_json_args = data.pop("__json_args__", None) + if raw_json_args is not None: + if not raw_json_args.strip(): + tool_args = {} + else: + try: + tool_args = json.loads(raw_json_args) + except json.JSONDecodeError as exc: + return Response( + content=json.dumps({"error": f"Invalid JSON: {exc}"}), + status_code=400, + media_type="application/json", + ) + if not isinstance(tool_args, dict): + return Response( + content=json.dumps( + { + "error": "JSON must be an object, not " + + type(tool_args).__name__ + } + ), + status_code=400, + media_type="application/json", + ) + else: + # Form mode: inputs are always strings — try to parse values + # that look like JSON objects or arrays. + tool_args = {} + for k, v in data.items(): + if isinstance(v, str): + stripped = v.strip() + if stripped and stripped[0] in ("{", "["): + try: + parsed = json.loads(stripped) + if isinstance(parsed, (dict, list)): + v = parsed + except (json.JSONDecodeError, TypeError): + pass + tool_args[k] = v args_json = quote(json.dumps(tool_args)) url = f"/launch?tool={tool}&args={args_json}" return Response( diff --git a/src/fastmcp/types.py b/src/fastmcp/types.py new file mode 100644 index 000000000..f078ceb6b --- /dev/null +++ b/src/fastmcp/types.py @@ -0,0 +1,32 @@ +"""Reusable type annotations for FastMCP tool parameters. + +These types can be used in tool function signatures to influence how +parameters are presented in UIs (e.g. ``fastmcp dev apps``) and +serialized in JSON Schema. + +Example:: + + from fastmcp import FastMCP + from fastmcp.types import Textarea + + mcp = FastMCP("demo") + + @mcp.tool() + def run_query(sql: Textarea) -> str: + ... +""" + +from __future__ import annotations + +from typing import Annotated + +from pydantic import Field + +Textarea = Annotated[str, Field(json_schema_extra={"format": "textarea"})] +"""A string rendered as a multiline textarea in form-based UIs. + +Produces ``"format": "textarea"`` in the JSON Schema, which +``fastmcp dev apps`` picks up automatically. +""" + +__all__ = ["Textarea"]