mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
Compare commits
8 commits
main
...
fix/dev-ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfefc51d25 |
||
|
|
106b08e654 |
||
|
|
e363dc2ab0 |
||
|
|
1d20fc5221 |
||
|
|
3b5d2c3a6a |
||
|
|
2075141864 |
||
|
|
854368f166 |
||
|
|
0cd95719b6 |
2 changed files with 218 additions and 24 deletions
|
|
@ -562,6 +562,8 @@ _LOG_PANEL_HTML = """\
|
|||
<span id="mcp-log-count-badge">\u00b7 <span id="mcp-log-count">0</span></span>
|
||||
</div>
|
||||
<div id="mcp-log-actions">
|
||||
<button id="mcp-log-reset" onclick="window.location.href='/'">← Back</button>
|
||||
<script>if (window.location.pathname === "/") document.getElementById("mcp-log-reset").style.display = "none";</script>
|
||||
<button id="mcp-log-clear">Clear</button>
|
||||
<button id="mcp-log-close">\u00d7</button>
|
||||
</div>
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
32
src/fastmcp/types.py
Normal file
32
src/fastmcp/types.py
Normal file
|
|
@ -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"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue