From 0cd95719b6a8bc9a174067d204909f4905eefe6e Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:20:43 -0400 Subject: [PATCH 1/8] Fix dev apps form: union types, textarea support, JSON parsing --- src/fastmcp/cli/apps_dev.py | 62 +++++++++++++++++++++++++++++++++---- src/fastmcp/types.py | 32 +++++++++++++++++++ 2 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 src/fastmcp/types.py diff --git a/src/fastmcp/cli/apps_dev.py b/src/fastmcp/cli/apps_dev.py index ac15b9c4c..bb17a235f 100644 --- a/src/fastmcp/cli/apps_dev.py +++ b/src/fastmcp/cli/apps_dev.py @@ -871,6 +871,30 @@ 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] + for candidate in ( + "object", + "array", + "integer", + "number", + "boolean", + "string", + ): + if candidate in types: + json_type = candidate + break + break + match json_type: case "integer": py_type: type = int @@ -878,6 +902,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 +924,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] = ( @@ -1219,8 +1256,21 @@ 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) + # Remaining keys are tool arguments. Form inputs always produce + # strings, but some parameters expect dicts/lists — try to parse + # string values that look like JSON objects or arrays. + tool_args: dict[str, Any] = {} + 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"] From 854368f1661f849407996fddc129a0c73b1e7219 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:14:12 -0400 Subject: [PATCH 2/8] Prefer string over scalars when resolving anyOf unions --- src/fastmcp/cli/apps_dev.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/fastmcp/cli/apps_dev.py b/src/fastmcp/cli/apps_dev.py index bb17a235f..9e141a94b 100644 --- a/src/fastmcp/cli/apps_dev.py +++ b/src/fastmcp/cli/apps_dev.py @@ -882,13 +882,15 @@ def _model_from_schema(tool_name: str, input_schema: dict[str, Any]) -> type[Any ] 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", - "string", ): if candidate in types: json_type = candidate From 20751418640d02242d76ff8058dda4c68c73e4c9 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:24:01 -0400 Subject: [PATCH 3/8] Add JSON mode tab, reset button, render description as markdown --- src/fastmcp/cli/apps_dev.py | 107 +++++++++++++++++++++++++----------- 1 file changed, 75 insertions(+), 32 deletions(-) diff --git a/src/fastmcp/cli/apps_dev.py b/src/fastmcp/cli/apps_dev.py index 9e141a94b..23aa1876c 100644 --- a/src/fastmcp/cli/apps_dev.py +++ b/src/fastmcp/cli/apps_dev.py @@ -562,6 +562,7 @@ _LOG_PANEL_HTML = """\ \u00b7 0
+
@@ -968,6 +969,9 @@ def _build_picker_html(tools: list[dict[str, Any]]) -> str: Pages, Select, SelectOption, + Tab, + Tabs, + Textarea, ) from prefab_ui.components.form import Form from prefab_ui.rx import RESULT, Rx @@ -1013,27 +1017,61 @@ 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] 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", - ) + Markdown(desc, css_class="pb-2 text-sm text-muted-foreground") + with Tabs(variant="line"): + with ( + Tab("Form"), + 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 ( + Tab("JSON"), + Form( + on_submit=Fetch.post( + "/api/launch", + body=json_body, + on_success=OpenLink(RESULT), + on_error=on_error, + ), + ), + ): + with Column(gap=2): + Label("Arguments") + Textarea( + name="_json_args", + placeholder='{"key": "value"}', + rows=8, + ) + Button( + "Launch", + variant="success", + button_type="submit", + ) Markdown( "Generated by [Prefab](https://prefab.prefect.io) 🎨", @@ -1258,21 +1296,26 @@ 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. Form inputs always produce - # strings, but some parameters expect dicts/lists — try to parse - # string values that look like JSON objects or arrays. - tool_args: dict[str, Any] = {} - 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 + + # JSON mode: the entire argument dict arrives as a raw JSON string. + raw_json_args = data.pop("_json_args", None) + if raw_json_args is not None: + tool_args = json.loads(raw_json_args) if raw_json_args.strip() else {} + 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( From 3b5d2c3a6a3f1f7439f4f84cfa3fa43e5eb54cb2 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:26:48 -0400 Subject: [PATCH 4/8] Widen picker, collapse long descriptions into accordion --- src/fastmcp/cli/apps_dev.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/fastmcp/cli/apps_dev.py b/src/fastmcp/cli/apps_dev.py index 23aa1876c..a6a7d76b8 100644 --- a/src/fastmcp/cli/apps_dev.py +++ b/src/fastmcp/cli/apps_dev.py @@ -959,6 +959,8 @@ def _build_picker_html(tools: list[dict[str, Any]]) -> str: from prefab_ui.actions import Fetch, OpenLink, SetState, ShowToast from prefab_ui.app import PrefabApp from prefab_ui.components import ( + Accordion, + AccordionItem, Button, Column, Heading, @@ -991,7 +993,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: @@ -1030,7 +1032,17 @@ def _build_picker_html(tools: list[dict[str, Any]]) -> str: with Page(name, value=name), Column(gap=4): if desc: - Markdown(desc, css_class="pb-2 text-sm text-muted-foreground") + # Show the first paragraph inline; collapse the + # rest into an expandable accordion. + parts = desc.split("\n\n", 1) + md_css = "text-sm text-muted-foreground" + Muted(parts[0]) + if len(parts) > 1: + with ( + Accordion(collapsible=True, css_class=md_css), + AccordionItem(title="Details"), + ): + Markdown(parts[1]) with Tabs(variant="line"): with ( Tab("Form"), From 1d20fc52213d84d0bb5e9b1a4db58c51d97c7726 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:28:17 -0400 Subject: [PATCH 5/8] Truncate long descriptions with show more/less toggle --- src/fastmcp/cli/apps_dev.py | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/fastmcp/cli/apps_dev.py b/src/fastmcp/cli/apps_dev.py index a6a7d76b8..b1cfe9c1f 100644 --- a/src/fastmcp/cli/apps_dev.py +++ b/src/fastmcp/cli/apps_dev.py @@ -959,8 +959,6 @@ def _build_picker_html(tools: list[dict[str, Any]]) -> str: from prefab_ui.actions import Fetch, OpenLink, SetState, ShowToast from prefab_ui.app import PrefabApp from prefab_ui.components import ( - Accordion, - AccordionItem, Button, Column, Heading, @@ -1030,19 +1028,33 @@ def _build_picker_html(tools: list[dict[str, Any]]) -> str: on_error = ShowToast(Rx("$error"), variant="error") # type: ignore[arg-type] + _desc_max_lines = 10 with Page(name, value=name), Column(gap=4): if desc: - # Show the first paragraph inline; collapse the - # rest into an expandable accordion. - parts = desc.split("\n\n", 1) + lines = desc.split("\n") md_css = "text-sm text-muted-foreground" - Muted(parts[0]) - if len(parts) > 1: - with ( - Accordion(collapsible=True, css_class=md_css), - AccordionItem(title="Details"), - ): - Markdown(parts[1]) + 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"): + Markdown(short, css_class=md_css) + Button( + "Show more", + variant="link", + size="sm", + on_click=SetState(desc_state, "full"), + ) + with Page("full", value="full"): + Markdown(desc, css_class=md_css) + Button( + "Show less", + variant="link", + size="sm", + on_click=SetState(desc_state, "short"), + ) with Tabs(variant="line"): with ( Tab("Form"), From e363dc2ab08302e9f4030dec199b905cd04ea4ba Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:42:35 -0400 Subject: [PATCH 6/8] Picker UI polish: description, JSON toggle, back button --- src/fastmcp/cli/apps_dev.py | 94 +++++++++++++++++-------------------- 1 file changed, 42 insertions(+), 52 deletions(-) diff --git a/src/fastmcp/cli/apps_dev.py b/src/fastmcp/cli/apps_dev.py index b1cfe9c1f..00b141b33 100644 --- a/src/fastmcp/cli/apps_dev.py +++ b/src/fastmcp/cli/apps_dev.py @@ -562,7 +562,8 @@ _LOG_PANEL_HTML = """\ \u00b7 0
- + +
@@ -969,8 +970,6 @@ def _build_picker_html(tools: list[dict[str, Any]]) -> str: Pages, Select, SelectOption, - Tab, - Tabs, Textarea, ) from prefab_ui.components.form import Form @@ -1028,74 +1027,65 @@ def _build_picker_html(tools: list[dict[str, Any]]) -> str: on_error = ShowToast(Rx("$error"), variant="error") # type: ignore[arg-type] - _desc_max_lines = 10 + input_mode = f"_mode_{name}" with Page(name, value=name), Column(gap=4): if desc: - 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"): - Markdown(short, css_class=md_css) - Button( - "Show more", - variant="link", - size="sm", - on_click=SetState(desc_state, "full"), - ) - with Page("full", value="full"): - Markdown(desc, css_class=md_css) - Button( - "Show less", - variant="link", - size="sm", - on_click=SetState(desc_state, "short"), - ) - with Tabs(variant="line"): - with ( - Tab("Form"), - Form( + first_para = desc.split("\n\n", 1)[0] + Muted(first_para) + + 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 ( - Tab("JSON"), - Form( + ): + 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, ), - ), - ): - with Column(gap=2): - Label("Arguments") + ): Textarea( name="_json_args", placeholder='{"key": "value"}', rows=8, ) - Button( - "Launch", - variant="success", - button_type="submit", - ) + Button( + "Launch", + variant="success", + button_type="submit", + ) Markdown( "Generated by [Prefab](https://prefab.prefect.io) 🎨", From 106b08e6548f27dc178cafed64ae46ca18485a73 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:48:09 -0400 Subject: [PATCH 7/8] Rename _json_args to __json_args__, validate JSON input --- src/fastmcp/cli/apps_dev.py | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/fastmcp/cli/apps_dev.py b/src/fastmcp/cli/apps_dev.py index 00b141b33..116e2a47d 100644 --- a/src/fastmcp/cli/apps_dev.py +++ b/src/fastmcp/cli/apps_dev.py @@ -1022,7 +1022,7 @@ def _build_picker_html(tools: list[dict[str, Any]]) -> str: json_body: dict[str, Any] = { "tool": name, - "_json_args": Rx("_json_args"), + "__json_args__": Rx("__json_args__"), } on_error = ShowToast(Rx("$error"), variant="error") # type: ignore[arg-type] @@ -1077,7 +1077,7 @@ def _build_picker_html(tools: list[dict[str, Any]]) -> str: ), ): Textarea( - name="_json_args", + name="__json_args__", placeholder='{"key": "value"}', rows=8, ) @@ -1312,9 +1312,31 @@ def _make_dev_app( tool = data.pop("tool", "") # JSON mode: the entire argument dict arrives as a raw JSON string. - raw_json_args = data.pop("_json_args", None) + # 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: - tool_args = json.loads(raw_json_args) if raw_json_args.strip() else {} + 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. From dfefc51d256cd3562f48e38734c000c53848cc4b Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:34:16 -0400 Subject: [PATCH 8/8] Truncate long descriptions with show more/less toggle --- src/fastmcp/cli/apps_dev.py | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/fastmcp/cli/apps_dev.py b/src/fastmcp/cli/apps_dev.py index 116e2a47d..a2f16c0eb 100644 --- a/src/fastmcp/cli/apps_dev.py +++ b/src/fastmcp/cli/apps_dev.py @@ -1028,10 +1028,41 @@ def _build_picker_html(tools: list[dict[str, Any]]) -> str: 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: - first_para = desc.split("\n\n", 1)[0] - Muted(first_para) + 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):