Spaces:
Running on Zero
Running on Zero
John Ho commited on
Commit Β·
c5df056
1
Parent(s): 9c917c1
recruited help from claude
Browse files
.claude/skills/gradio-mcp-tool-docstrings/SKILL.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: gradio-mcp-tool-docstrings
|
| 3 |
+
description: Write Gradio function docstrings that survive Gradio's MCP schema parser so the exposed tool is clear to LLM clients. Use this WHENEVER you write or edit a Gradio function that is (or will be) exposed over MCP, add mcp_server=True, set an api_name, or document a Gradio tool's description, parameters, or output schema β even if the user just says "make the tool description clearer" or "document the outputs". Especially load this before touching docstrings on functions wired into .click()/.submit()/gr.Interface in a Gradio app.
|
| 4 |
+
---
|
| 5 |
+
|
| 6 |
+
# Gradio MCP tool docstrings
|
| 7 |
+
|
| 8 |
+
When a Gradio app runs with `mcp_server=True`, every API endpoint becomes an MCP tool, and the
|
| 9 |
+
tool's schema β the description an LLM reads, and the per-parameter docs β is built **from the
|
| 10 |
+
Python function's docstring and type hints**. The catch: Gradio's docstring parser
|
| 11 |
+
(`gradio/utils.py::get_function_description`) is primitive and *silently* mangles anything not
|
| 12 |
+
shaped exactly right. A docstring that reads beautifully to a human can still produce a broken,
|
| 13 |
+
truncated, or URL-stripped tool schema. This skill encodes what actually survives the parser, so
|
| 14 |
+
your tools are unambiguous to the model calling them.
|
| 15 |
+
|
| 16 |
+
The rules below were verified empirically against a live `/gradio_api/mcp/schema` endpoint β trust
|
| 17 |
+
them over how the docstring "looks."
|
| 18 |
+
|
| 19 |
+
## How Gradio turns a function into an MCP tool
|
| 20 |
+
|
| 21 |
+
- **Tool name = the Python function name.** It is NOT the `api_name` you pass to `.click()` β
|
| 22 |
+
`api_name` only names the REST endpoint. So if you want the MCP tool called `detect_objects`,
|
| 23 |
+
**name the function `detect_objects`**. Renaming via `api_name` alone does nothing for MCP.
|
| 24 |
+
- **Tool description = the docstring prose *before* the `Args:` line.**
|
| 25 |
+
- **Per-parameter descriptions = the `Args:` section.**
|
| 26 |
+
- **Enable it:** `demo.launch(mcp_server=True)`. This needs **Gradio β₯ 5.28** and the
|
| 27 |
+
**`gradio[mcp]`** extra installed (on HF Spaces: bump `sdk_version` in `README.md` and add
|
| 28 |
+
`gradio[mcp]` to `requirements.txt`). On older Gradio, `mcp_server=True` is a no-op/error.
|
| 29 |
+
|
| 30 |
+
## The parser's three rules (and the traps)
|
| 31 |
+
|
| 32 |
+
Internalize these three β most broken schemas come from violating rule 2 or 3.
|
| 33 |
+
|
| 34 |
+
1. **Description block** (every line before `Args:`/`Returns:`): stripped and joined verbatim with
|
| 35 |
+
spaces. **Safe for anything** β URLs, colons, parentheses, multi-sentence prose. This is the
|
| 36 |
+
one place nothing gets mangled, so it is where the important content belongs.
|
| 37 |
+
|
| 38 |
+
2. **`Args:` section**: each line is split on the first `:` into `name: description`. A wrapped
|
| 39 |
+
continuation line has no `:`, so **it is silently dropped**. Consequence: if a parameter
|
| 40 |
+
description spans two lines, the second line vanishes and you won't be warned.
|
| 41 |
+
β **Keep every parameter description on a single line**, however long.
|
| 42 |
+
|
| 43 |
+
3. **`Returns:` section**: each line is split on the first `:` and **everything before the colon is
|
| 44 |
+
discarded**. This quietly destroys machine-facing detail:
|
| 45 |
+
- a URL becomes garbage β `https://example.com` loses its `https:` and turns into
|
| 46 |
+
`//example.com`;
|
| 47 |
+
- field-labelled lines like `class_id (int): COCO index` lose the `class_id (int)` part.
|
| 48 |
+
β **Do not put schema detail, field names, or URLs in `Returns:`.** Fold the output description
|
| 49 |
+
into the description-block prose instead.
|
| 50 |
+
|
| 51 |
+
## Checklist for a rock-solid docstring
|
| 52 |
+
|
| 53 |
+
- Put the **full output schema in the description prose** (rule 1 keeps it intact), including any
|
| 54 |
+
reference URLs.
|
| 55 |
+
- **One line per `Args:` entry** (rule 2).
|
| 56 |
+
- **Skip or minimize `Returns:`** (rule 3) β it can't hold URLs or field names safely.
|
| 57 |
+
- **Name the function** exactly what you want the MCP tool called.
|
| 58 |
+
- Give a real **return type hint** (e.g. `-> tuple[Image.Image, list[dict]]`) for a cleaner schema.
|
| 59 |
+
- Make outputs **JSON-serializable** (see below).
|
| 60 |
+
- After editing, **verify against the live schema** (see Verification) β never trust appearances.
|
| 61 |
+
|
| 62 |
+
## Coordinate / bounding-box outputs: always name the format
|
| 63 |
+
|
| 64 |
+
A bare number array like `[a, b, c, d]` is meaningless to a caller β the same four numbers mean
|
| 65 |
+
different boxes under different conventions. **Whenever a tool returns bounding boxes or any
|
| 66 |
+
coordinate/geometry, name the format explicitly in the description** and link a canonical
|
| 67 |
+
reference. Use the albumentations naming as the shared vocabulary:
|
| 68 |
+
https://albumentations.ai/docs/3-basic-usage/bounding-boxes-augmentations/#bounding-box-formats
|
| 69 |
+
|
| 70 |
+
Albumentations formats cheat-sheet β state which one you emit, and whether values are absolute
|
| 71 |
+
pixels or normalized 0β1:
|
| 72 |
+
|
| 73 |
+
| Format | Coordinates | Values |
|
| 74 |
+
|-----------------|------------------------------------------|-----------------|
|
| 75 |
+
| `pascal_voc` | `[x_min, y_min, x_max, y_max]` | absolute pixels |
|
| 76 |
+
| `albumentations`| `[x_min, y_min, x_max, y_max]` | normalized 0β1 |
|
| 77 |
+
| `coco` | `[x_min, y_min, width, height]` | absolute pixels |
|
| 78 |
+
| `yolo` | `[x_center, y_center, width, height]` | normalized 0β1 |
|
| 79 |
+
| `cxcywh` | `[x_center, y_center, width, height]` | absolute pixels |
|
| 80 |
+
|
| 81 |
+
Also state the **coordinate space** (which image the pixels are relative to). In this repo,
|
| 82 |
+
detections come from supervision `detections.xyxy` β `[x_min, y_min, x_max, y_max]` in **absolute
|
| 83 |
+
pixels of the ORIGINAL input image** = albumentations **`pascal_voc`**. Say all of that; say what
|
| 84 |
+
it is NOT (not normalized, not `[x, y, width, height]`).
|
| 85 |
+
|
| 86 |
+
## JSON-serializability
|
| 87 |
+
|
| 88 |
+
MCP structured output must be plain JSON. Detection/ML libraries hand back numpy scalars, which can
|
| 89 |
+
break serialization. Convert before returning: `int(...)` for indices/ids, `float(...)` for scores,
|
| 90 |
+
`.tolist()` for arrays. In this repo see `detect_and_annotate` in `app.py`.
|
| 91 |
+
|
| 92 |
+
## Worked example (this repo's `detect_objects` in `app.py`)
|
| 93 |
+
|
| 94 |
+
Note: schema/URL/field detail lives in the prose; each `Args:` line is single-line; no `Returns:`.
|
| 95 |
+
|
| 96 |
+
```python
|
| 97 |
+
def detect_objects(input_image, confidence, resolution, checkpoint) -> tuple[Image.Image, list[dict]]:
|
| 98 |
+
"""Detect objects in an image using RF-DETR and return the annotated image plus structured detections.
|
| 99 |
+
|
| 100 |
+
RF-DETR ... It produces two outputs. Output 1 is the annotated image. Output 2 is a JSON list
|
| 101 |
+
of detections; each entry has: "class_id" (integer COCO index 0-79); "classname" (string);
|
| 102 |
+
"confidence" (float 0.0-1.0); and "bounding_box". The "bounding_box" is [x_min, y_min, x_max,
|
| 103 |
+
y_max] in ABSOLUTE PIXEL coordinates of the ORIGINAL input image (top-left & bottom-right
|
| 104 |
+
corners) -- the albumentations "pascal_voc" format, documented at
|
| 105 |
+
https://albumentations.ai/docs/3-basic-usage/bounding-boxes-augmentations/#bounding-box-formats .
|
| 106 |
+
NOT normalized to 0-1 and NOT [x, y, width, height].
|
| 107 |
+
|
| 108 |
+
Args:
|
| 109 |
+
input_image: The RGB image to run object detection on.
|
| 110 |
+
confidence: Minimum confidence score from 0.0 to 1.0; detections below this are discarded.
|
| 111 |
+
resolution: Square inference resolution in pixels; snapped to the backbone's multiple.
|
| 112 |
+
checkpoint: Which model to run: "base" (faster) or "large" (more accurate).
|
| 113 |
+
"""
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
## Verification (do this after every docstring change)
|
| 117 |
+
|
| 118 |
+
1. Run the app with `mcp_server=True` (startup logs should print an MCP SSE URL).
|
| 119 |
+
2. Dump the live schema with the bundled script:
|
| 120 |
+
```bash
|
| 121 |
+
python .claude/skills/gradio-mcp-tool-docstrings/scripts/dump_mcp_schema.py --port 7860
|
| 122 |
+
# or target one tool:
|
| 123 |
+
python .../scripts/dump_mcp_schema.py --tool detect_objects
|
| 124 |
+
```
|
| 125 |
+
Confirm: the **tool name** is what you expect, the **description** is complete with any **URLs
|
| 126 |
+
intact**, and **every argument** has its full description (nothing truncated).
|
| 127 |
+
3. **Isolated-stub technique** (no model / GPU needed): to iterate fast on parsing, replicate just
|
| 128 |
+
the function *signature + docstring* (a stub that returns dummy values) in a tiny app, wire it to
|
| 129 |
+
a button `.click(...)`, `demo.launch(mcp_server=True, prevent_thread_lock=True)`, then run the
|
| 130 |
+
dump script against it. This checks the docstring parsing in seconds without loading heavy deps.
|
| 131 |
+
Requires only `pip install "gradio[mcp]"`.
|
.claude/skills/gradio-mcp-tool-docstrings/scripts/dump_mcp_schema.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Dump the tool schema a running Gradio MCP server exposes.
|
| 3 |
+
|
| 4 |
+
Gradio builds its MCP tool schema from each API function's docstring + type hints, but its
|
| 5 |
+
docstring parser is primitive and silently mangles anything not shaped exactly right. The only
|
| 6 |
+
way to know what an MCP client actually sees is to read the live schema. This script fetches it
|
| 7 |
+
and prints, per tool: the name, the description, and each input argument's description -- so you
|
| 8 |
+
can confirm your docstring survived the parser intact (URLs present, arg descriptions complete,
|
| 9 |
+
tool named what you expect).
|
| 10 |
+
|
| 11 |
+
Prerequisite: launch the app with ``demo.launch(mcp_server=True)`` (needs gradio>=5.28 and the
|
| 12 |
+
``gradio[mcp]`` extra). Then run this against the running server.
|
| 13 |
+
|
| 14 |
+
Usage:
|
| 15 |
+
python dump_mcp_schema.py # http://127.0.0.1:7860
|
| 16 |
+
python dump_mcp_schema.py --port 7860
|
| 17 |
+
python dump_mcp_schema.py --url http://127.0.0.1:7860
|
| 18 |
+
python dump_mcp_schema.py --tool detect_objects # only show one tool
|
| 19 |
+
|
| 20 |
+
Stdlib only -- no third-party deps, runs anywhere.
|
| 21 |
+
"""
|
| 22 |
+
import argparse
|
| 23 |
+
import json
|
| 24 |
+
import sys
|
| 25 |
+
import urllib.error
|
| 26 |
+
import urllib.request
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def fetch_schema(base_url: str, timeout: float = 15.0):
|
| 30 |
+
schema_url = base_url.rstrip("/") + "/gradio_api/mcp/schema"
|
| 31 |
+
try:
|
| 32 |
+
with urllib.request.urlopen(schema_url, timeout=timeout) as resp:
|
| 33 |
+
return json.loads(resp.read())
|
| 34 |
+
except urllib.error.URLError as e:
|
| 35 |
+
sys.exit(
|
| 36 |
+
f"ERROR: could not reach {schema_url}\n"
|
| 37 |
+
f" ({e})\n"
|
| 38 |
+
" Is the app running and launched with demo.launch(mcp_server=True)?\n"
|
| 39 |
+
" (mcp_server needs gradio>=5.28 and the gradio[mcp] extra installed.)"
|
| 40 |
+
)
|
| 41 |
+
except json.JSONDecodeError as e:
|
| 42 |
+
sys.exit(f"ERROR: {schema_url} did not return valid JSON: {e}")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def print_tool(tool: dict) -> None:
|
| 46 |
+
print("=" * 78)
|
| 47 |
+
print(f"TOOL: {tool.get('name', '<unnamed>')}")
|
| 48 |
+
print("-" * 78)
|
| 49 |
+
print("DESCRIPTION:")
|
| 50 |
+
print(f" {tool.get('description', '(none)')}")
|
| 51 |
+
props = (tool.get("inputSchema") or {}).get("properties") or {}
|
| 52 |
+
print("ARGUMENTS:")
|
| 53 |
+
if not props:
|
| 54 |
+
print(" (none)")
|
| 55 |
+
for name, spec in props.items():
|
| 56 |
+
desc = spec.get("description", "(no description)")
|
| 57 |
+
typ = spec.get("type", "?")
|
| 58 |
+
print(f" - {name} ({typ}): {desc}")
|
| 59 |
+
print()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def main() -> None:
|
| 63 |
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
| 64 |
+
parser.add_argument("--url", help="Base URL of the running Gradio app.")
|
| 65 |
+
parser.add_argument("--port", type=int, default=7860, help="Port (default 7860) if --url omitted.")
|
| 66 |
+
parser.add_argument("--host", default="127.0.0.1", help="Host (default 127.0.0.1) if --url omitted.")
|
| 67 |
+
parser.add_argument("--tool", help="Only print the tool whose name contains this string.")
|
| 68 |
+
parser.add_argument("--raw", action="store_true", help="Print the raw JSON schema instead of a summary.")
|
| 69 |
+
args = parser.parse_args()
|
| 70 |
+
|
| 71 |
+
base_url = args.url or f"http://{args.host}:{args.port}"
|
| 72 |
+
schema = fetch_schema(base_url)
|
| 73 |
+
|
| 74 |
+
if args.raw:
|
| 75 |
+
print(json.dumps(schema, indent=2))
|
| 76 |
+
return
|
| 77 |
+
|
| 78 |
+
tools = schema if isinstance(schema, list) else schema.get("tools", [])
|
| 79 |
+
if args.tool:
|
| 80 |
+
tools = [t for t in tools if args.tool in t.get("name", "")]
|
| 81 |
+
if not tools:
|
| 82 |
+
sys.exit(f"No tools found (filter={args.tool!r}). The app may expose no MCP tools.")
|
| 83 |
+
|
| 84 |
+
for tool in tools:
|
| 85 |
+
print_tool(tool)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
if __name__ == "__main__":
|
| 89 |
+
main()
|
CLAUDE.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CLAUDE.md
|
| 2 |
+
|
| 3 |
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
| 4 |
+
|
| 5 |
+
## What this is
|
| 6 |
+
|
| 7 |
+
A HuggingFace Space that exposes Moondream2's open-vocabulary detection (`point` and `object_detection`) via a Gradio interface. The entire app is `app.py` β a single `detect()` function wrapped by `gr.Interface`. README frontmatter (`title`, `sdk`, `sdk_version`, `app_file`) is the HF Space config and must stay valid YAML; the Space rebuilds from it.
|
| 8 |
+
|
| 9 |
+
## Running
|
| 10 |
+
|
| 11 |
+
```bash
|
| 12 |
+
pip install -r requirements.txt
|
| 13 |
+
python app.py
|
| 14 |
+
```
|
| 15 |
+
|
| 16 |
+
Gradio launches with `mcp_server=True` (exposes an MCP endpoint) and `app_kwargs={"docs_url": "/docs"}` (attempt to expose FastAPI Swagger). Per the README, the `/docs` route is known not to work in practice β see https://github.com/gradio-app/gradio/issues/4054.
|
| 17 |
+
|
| 18 |
+
## Runtime model
|
| 19 |
+
|
| 20 |
+
- Loads `vikhyatk/moondream2` at revision `2025-04-14` with `trust_remote_code=True`. Pinning the revision is intentional β the model repo changes its API surface across revisions, so bumping it can break `model.point(...)` / `model.detect(...)` return shapes.
|
| 21 |
+
- `@spaces.GPU(duration=30)` decorators are HF Spaces ZeroGPU directives β they only activate on the Space; locally they are no-ops requiring a CUDA device (the `device_map={"": "cuda"}` hard-codes GPU). Local CPU runs need that removed.
|
| 22 |
+
- `detect()` returns the inner list (`["points"]` or `["objects"]`) rather than the full dict β callers (including the MCP tool surface) depend on that shape.
|