diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5545ba205..5c832c4db1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -822,6 +822,52 @@ jobs: sdk/python/dist/*.whl sdk/python/dist/*.tar.gz + # ============== + # Build flet-mcp + # ============== + build_flet_mcp: + name: Build flet-mcp Python package + runs-on: ubuntu-latest + env: + PYPI_VER: ${{ needs.build_flet_package.outputs.PYPI_VER }} + needs: + - python_tests + - build_flet_package + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup uv + uses: astral-sh/setup-uv@v6 + + - name: Build docs search index + working-directory: ${{ env.SDK_PYTHON }}/packages/flet + run: uv run --group docs mkdocs build + + - name: Build MCP data + working-directory: ${{ env.SDK_PYTHON }} + run: | + uv run --group mcp-build flet mcp build \ + --examples examples \ + --docs packages/flet/site/search/search_index.json \ + --output packages/flet-mcp/src/flet_mcp/data/ + + - name: Build Python package + working-directory: ${{ env.SDK_PYTHON }} + run: | + source "$SCRIPTS/common.sh" + patch_python_package_versions + uv build --package flet-mcp + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: flet-mcp-python-distribution + if-no-files-found: error + path: | + sdk/python/dist/*.whl + sdk/python/dist/*.tar.gz + # =============================== # Publish Python packages to PyPI # =============================== @@ -838,6 +884,7 @@ jobs: - build_linux - build_web - build_flet_extensions + - build_flet_mcp steps: - name: Setup uv uses: astral-sh/setup-uv@v6 @@ -878,7 +925,8 @@ jobs: flet_rive \ flet_secure_storage \ flet_video \ - flet_webview; do + flet_webview \ + flet_mcp; do uv publish dist/**/${pkg}-* done diff --git a/sdk/python/packages/flet-cli/pyproject.toml b/sdk/python/packages/flet-cli/pyproject.toml index 4b65881aa8..19f9e1badc 100644 --- a/sdk/python/packages/flet-cli/pyproject.toml +++ b/sdk/python/packages/flet-cli/pyproject.toml @@ -14,9 +14,12 @@ dependencies = [ "tomli >= 1.1.0 ; python_version < '3.11'", "cookiecutter >=2.6.0", "binaryornot <0.5", - "chardet <6" + "chardet <6", ] +[project.optional-dependencies] +mcp = ["flet-mcp"] + [project.urls] Homepage = "https://flet.dev" Repository = "https://github.com/flet-dev/flet" diff --git a/sdk/python/packages/flet-cli/src/flet_cli/cli.py b/sdk/python/packages/flet-cli/src/flet_cli/cli.py index 1a7b3e1a8e..69d9dcc17f 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/cli.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/cli.py @@ -94,6 +94,16 @@ def get_parser() -> argparse.ArgumentParser: flet_cli.commands.devices.Command.register_to(sp, "devices") flet_cli.commands.doctor.Command.register_to(sp, "doctor") + # Register MCP command only if flet-mcp is installed + try: + from importlib import import_module + + import_module("flet_mcp") + mcp_cmd = import_module("flet_cli.commands.mcp") + mcp_cmd.Command.register_to(sp, "mcp") + except ImportError: + pass + # set "run" as the default subparser set_default_subparser(parser, name="run", index=1) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/mcp.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/mcp.py new file mode 100644 index 0000000000..a4e66c98a1 --- /dev/null +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/mcp.py @@ -0,0 +1,66 @@ +import argparse +from pathlib import Path + +from flet_cli.commands.base import BaseCommand + + +class Command(BaseCommand): + """Flet MCP server for LLM agents.""" + + def add_arguments(self, parser: argparse.ArgumentParser) -> None: + sub = parser.add_subparsers(dest="mcp_command") + + # `flet mcp build` sub-subcommand + build_parser = sub.add_parser("build", help="Build MCP index data") + build_parser.add_argument( + "--examples", + type=Path, + help="Path to examples directory", + ) + build_parser.add_argument( + "--docs", + type=Path, + help="Path to search_index.json", + ) + build_parser.add_argument( + "--output", + type=Path, + help="Output directory (default: flet_mcp/data/)", + ) + + # `flet mcp` (serve) options — applied to the mcp parser itself + parser.add_argument( + "--transport", + choices=["stdio", "streamable-http"], + default="stdio", + help="MCP transport mode", + ) + parser.add_argument( + "--port", + type=int, + default=8000, + help="Port for HTTP transport mode", + ) + + def handle(self, options: argparse.Namespace) -> None: + if options.mcp_command == "build": + self._handle_build(options) + else: + self._handle_serve(options) + + def _handle_serve(self, options: argparse.Namespace) -> None: + from flet_mcp.server import mcp + + if options.transport == "streamable-http": + mcp.run(transport="streamable-http", port=options.port) + else: + mcp.run(transport="stdio") + + def _handle_build(self, options: argparse.Namespace) -> None: + from flet_mcp.build.indexer import build_all + + build_all( + examples_dir=options.examples, + docs_index=options.docs, + output_dir=options.output, + ) diff --git a/sdk/python/packages/flet-mcp/.gitignore b/sdk/python/packages/flet-mcp/.gitignore new file mode 100644 index 0000000000..5a3d1ff640 --- /dev/null +++ b/sdk/python/packages/flet-mcp/.gitignore @@ -0,0 +1,6 @@ +# Build artifacts (generated by `flet mcp build`) +src/flet_mcp/data/api.json +src/flet_mcp/data/mcp.db + +# Un-ignore the build subpackage (matched by sdk/python/.gitignore "build/" rule) +!src/flet_mcp/build/ diff --git a/sdk/python/packages/flet-mcp/README.md b/sdk/python/packages/flet-mcp/README.md new file mode 100644 index 0000000000..2cce422056 --- /dev/null +++ b/sdk/python/packages/flet-mcp/README.md @@ -0,0 +1,95 @@ +# flet-mcp + +MCP (Model Context Protocol) server that gives LLM agents access to Flet examples, documentation, and API reference. + +## Installation + +```bash +pip install flet-mcp +``` + +## Usage + +### Start the MCP server + +```bash +# stdio transport (default, for use with Claude Desktop, Cursor, etc.) +flet mcp + +# HTTP transport +flet mcp --transport streamable-http --port 8000 +``` + +### Configure in Claude Desktop + +Add to your `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "flet": { + "command": "flet", + "args": ["mcp"] + } + } +} +``` + +### List available tools + +```bash +fastmcp list packages/flet-mcp/src/flet_mcp/server.py +``` + +### Call tools from the command line + +```bash +# Search examples +fastmcp call packages/flet-mcp/src/flet_mcp/server.py search_examples '{"query": "dropdown"}' + +# Get full example code +fastmcp call packages/flet-mcp/src/flet_mcp/server.py get_example '{"example_id": "controls_dropdown_styled"}' + +# Search documentation +fastmcp call packages/flet-mcp/src/flet_mcp/server.py search_docs '{"query": "TextField validation"}' + +# Get control API reference +fastmcp call packages/flet-mcp/src/flet_mcp/server.py get_control_api '{"name": "TextField"}' + +# Find an icon +fastmcp call packages/flet-mcp/src/flet_mcp/server.py find_icon '{"query": "settings"}' + +# Search large enums +fastmcp call packages/flet-mcp/src/flet_mcp/server.py search_enum_members '{"name": "Icons", "query": "arrow"}' + +# Get CLI help +fastmcp call packages/flet-mcp/src/flet_mcp/server.py get_cli_help '{"command": "run"}' +``` + +### Use with Pydantic AI + +```python +from pydantic_ai import Agent +from pydantic_ai.toolsets import MCPToolset +from flet_mcp import mcp + +agent = Agent("claude-sonnet-4-20250514", toolsets=[MCPToolset(mcp)]) +result = agent.run_sync("Create a Flet app with a login form") +``` + +## Tools + +| Tool | Description | +|------|-------------| +| `search_examples` | Search example projects by keyword | +| `get_example` | Get full source code for an example | +| `search_docs` | Search documentation by keyword | +| `get_doc` | Get full content of a doc section | +| `list_controls` | List controls and services, with optional filtering | +| `get_control_api` | Get properties, events, and methods for a control | +| `get_type_api` | Get fields and methods for a type (ButtonStyle, Padding, etc.) | +| `get_enum` | Get enum members | +| `search_enum_members` | Search large enums (Icons, CupertinoIcons) | +| `enum_has_member` | Check if an enum value exists | +| `find_icon` | Search Material and Cupertino icons by keyword | +| `get_cli_help` | Get structured CLI command options | diff --git a/sdk/python/packages/flet-mcp/pyproject.toml b/sdk/python/packages/flet-mcp/pyproject.toml new file mode 100644 index 0000000000..b4d13584a8 --- /dev/null +++ b/sdk/python/packages/flet-mcp/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "flet-mcp" +version = "0.1.0" +description = "Flet MCP server for LLM agents" +authors = [{ name = "Appveyor Systems Inc.", email = "hello@flet.dev" }] +license = "Apache-2.0" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "fastmcp >=2.0.0", + "pyyaml >=6.0", +] + +[project.optional-dependencies] +build = [ + "markdownify >=0.14.1", + "griffe >=1.6.2", + "flet", + "flet-cli", +] + +[project.urls] +Homepage = "https://flet.dev" +Repository = "https://github.com/flet-dev/flet" +Documentation = "https://flet.dev/docs" + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.package-data] +flet_mcp = ["data/*"] diff --git a/sdk/python/packages/flet-mcp/src/flet_mcp/__init__.py b/sdk/python/packages/flet-mcp/src/flet_mcp/__init__.py new file mode 100644 index 0000000000..66213528cb --- /dev/null +++ b/sdk/python/packages/flet-mcp/src/flet_mcp/__init__.py @@ -0,0 +1,3 @@ +from flet_mcp.server import mcp + +__all__ = ["mcp"] diff --git a/sdk/python/packages/flet-mcp/src/flet_mcp/api_store.py b/sdk/python/packages/flet-mcp/src/flet_mcp/api_store.py new file mode 100644 index 0000000000..59f5a32c22 --- /dev/null +++ b/sdk/python/packages/flet-mcp/src/flet_mcp/api_store.py @@ -0,0 +1,147 @@ +"""Load and query the Griffe-generated API reference (api.json).""" + +from __future__ import annotations + +import importlib.resources +import json +from pathlib import Path +from typing import Any + + +class ApiStore: + """Lazy-loading store for the bundled api.json data. + + The api.json file uses lists for controls/events/types/enums, + each entry having a "name" key. This store builds name-keyed dicts + for fast lookup. + """ + + def __init__(self) -> None: + self._raw: dict[str, Any] | None = None + self._controls: dict[str, dict] | None = None + self._events: dict[str, dict] | None = None + self._types: dict[str, dict] | None = None + self._enums: dict[str, dict] | None = None + + def _load(self) -> dict[str, Any]: + if self._raw is None: + ref = importlib.resources.files("flet_mcp").joinpath("data/api.json") + self._raw = json.loads(Path(str(ref)).read_text(encoding="utf-8")) + # Build name-keyed dicts from lists + self._controls = {c["name"]: c for c in self._raw.get("controls", [])} + self._events = {e["name"]: e for e in self._raw.get("events", [])} + self._types = {t["name"]: t for t in self._raw.get("types", [])} + self._enums = {e["name"]: e for e in self._raw.get("enums", [])} + return self._raw + + # ------------------------------------------------------------------ + # Controls + # ------------------------------------------------------------------ + + def list_controls( + self, + category: str | None = None, + kind: str | None = None, + limit: int = 50, + ) -> list[dict[str, Any]]: + """List controls, optionally filtered by category or kind.""" + self._load() + results: list[dict[str, Any]] = [] + for name, ctrl in self._controls.items(): + if kind and ctrl.get("kind") != kind: + continue + if category and category not in ctrl.get("categories", []): + continue + results.append( + { + "name": name, + "kind": ctrl.get("kind"), + "summary": ctrl.get("summary", ""), + "categories": ctrl.get("categories", []), + } + ) + if len(results) >= limit: + break + return results + + def get_control(self, name: str) -> dict[str, Any] | None: + """Return full control dict by name, or None.""" + self._load() + return self._controls.get(name) + + # ------------------------------------------------------------------ + # Types & Events + # ------------------------------------------------------------------ + + def get_type(self, name: str) -> dict[str, Any] | None: + """Return full type dict by name, or None.""" + self._load() + return self._types.get(name) or self._events.get(name) + + # ------------------------------------------------------------------ + # Enums + # ------------------------------------------------------------------ + + def get_enum(self, name: str) -> dict[str, Any] | None: + """Return enum data. Large enums are truncated with a hint to search.""" + self._load() + enum = self._enums.get(name) + if enum is None: + return None + + if enum.get("kind") == "large_enum": + members = enum.get("members", []) + return { + "name": name, + "kind": "large_enum", + "total_members": len(members), + "sample_members": members[:10], + "note": ( + f"This enum has {len(members)} members. " + "Use search_enum_members() to find specific values." + ), + } + + return enum + + def search_enum_members(self, name: str, query: str, limit: int = 10) -> list[str]: + """Search enum members by case-insensitive substring match.""" + self._load() + enum = self._enums.get(name) + if enum is None: + return [] + + query_lower = query.lower() + results: list[str] = [] + for member in enum.get("members", []): + member_name = member["name"] if isinstance(member, dict) else str(member) + if query_lower in member_name.lower(): + results.append(member_name) + if len(results) >= limit: + break + return results + + def enum_has_member(self, name: str, member: str) -> bool: + """Check whether an enum contains a specific member.""" + self._load() + enum = self._enums.get(name) + if enum is None: + return False + members = enum.get("members", []) + member_lower = member.lower() + return any( + (m["name"] if isinstance(m, dict) else str(m)).lower() == member_lower + for m in members + ) + + # ------------------------------------------------------------------ + # CLI + # ------------------------------------------------------------------ + + def get_cli_help(self, command: str | None = None) -> Any: + """Return CLI help. None -> list commands; otherwise command help text.""" + self._load() + cli = self._raw.get("cli", {}) + if command is None: + return list(cli.keys()) + return cli.get(command) diff --git a/sdk/python/packages/flet-mcp/src/flet_mcp/build/__init__.py b/sdk/python/packages/flet-mcp/src/flet_mcp/build/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sdk/python/packages/flet-mcp/src/flet_mcp/build/api.py b/sdk/python/packages/flet-mcp/src/flet_mcp/build/api.py new file mode 100644 index 0000000000..fbb6cfb438 --- /dev/null +++ b/sdk/python/packages/flet-mcp/src/flet_mcp/build/api.py @@ -0,0 +1,528 @@ +"""API reference builder using Griffe. + +Scans Flet packages with Griffe to extract controls, events, types, enums, +and CLI help, then writes a consolidated api.json file. +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any + +import griffe + +logger = logging.getLogger(__name__) + +DEFAULT_PACKAGES: list[str] = [ + "flet", + "flet_ads", + "flet_audio", + "flet_audio_recorder", + "flet_camera", + "flet_charts", + "flet_code_editor", + "flet_color_pickers", + "flet_datatable2", + "flet_flashlight", + "flet_geolocator", + "flet_lottie", + "flet_map", + "flet_permission_handler", + "flet_rive", + "flet_secure_storage", + "flet_video", + "flet_webview", +] + +_CONTROL_BASE_NAMES = {"BaseControl", "LayoutControl", "AdaptiveControl", "Service"} + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _first_line(docstring: griffe.Docstring | None) -> str: + if docstring is None: + return "" + text = docstring.value.strip() + return text.split("\n", 1)[0].strip() + + +def _full_docstring(docstring: griffe.Docstring | None) -> str: + if docstring is None: + return "" + return docstring.value.strip() + + +def _annotation_str(annotation: Any) -> str: + if annotation is None: + return "" + return str(annotation) + + +def _default_str(default: Any) -> str: + if default is None: + return "" + return str(default) + + +def _is_control_class(cls: griffe.Class) -> bool: + """Check if any base class name matches a known control base.""" + for base in cls.bases: + base_name = str(base).rsplit(".", 1)[-1] + if base_name in _CONTROL_BASE_NAMES: + return True + return False + + +def _is_service(cls: griffe.Class) -> bool: + return any("Service" in str(base) for base in cls.bases) + + +def _is_enum(cls: griffe.Class) -> bool: + for base in cls.bases: + base_name = str(base).rsplit(".", 1)[-1] + if base_name == "Enum" or base_name.endswith("Enum"): + return True + return False + + +def _is_event_class(cls: griffe.Class) -> bool: + return any("Event" in str(base).rsplit(".", 1)[-1] for base in cls.bases) + + +def _is_dataclass(cls: griffe.Class) -> bool: + for dec in cls.decorators: + dec_str = str(dec.value) + if "dataclass" in dec_str: + return True + return False + + +def _has_control_decorator(cls: griffe.Class) -> tuple[list[str], list[str]]: + """Return (categories, tags) from @control decorator if present.""" + categories: list[str] = [] + tags: list[str] = [] + for dec in cls.decorators: + dec_str = str(dec.value) + if "control" not in dec_str: + continue + # Try to extract keyword args from the decorator's AST node + try: + node = dec.value + # Griffe stores decorator values as Expressions or AST nodes + if hasattr(node, "keywords"): + for kw in node.keywords: + if kw.arg == "categories" and hasattr(kw.value, "elts"): + categories = [ + str(getattr(e, "value", e)) for e in kw.value.elts + ] + elif kw.arg == "tags" and hasattr(kw.value, "elts"): + tags = [str(getattr(e, "value", e)) for e in kw.value.elts] + except Exception: + pass + # Fallback: parse from string representation + if not categories and "categories=" in dec_str: + categories = _parse_tuple_from_str(dec_str, "categories") + if not tags and "tags=" in dec_str: + tags = _parse_tuple_from_str(dec_str, "tags") + return categories, tags + + +def _parse_tuple_from_str(dec_str: str, key: str) -> list[str]: + """Parse a keyword arg tuple from decorator string representation. + + e.g. 'control("TextField", categories=("input", "form"))' -> ["input", "form"] + """ + import re + + pattern = rf"{key}\s*=\s*\(([^)]*)\)" + match = re.search(pattern, dec_str) + if not match: + return [] + inner = match.group(1) + return [s.strip().strip("\"'") for s in inner.split(",") if s.strip().strip("\"'")] + + +def _extract_properties(cls: griffe.Class) -> list[dict[str, Any]]: + """Extract dataclass-style fields as properties.""" + props: list[dict[str, Any]] = [] + for name, member in cls.members.items(): + if name.startswith("_"): + continue + if isinstance(member, griffe.Attribute): + annotation = _annotation_str(member.annotation) + is_event = ( + "EventHandler" in annotation or "ControlEventHandler" in annotation + ) + props.append( + { + "name": name, + "type": annotation, + "default": _default_str(member.value), + "docstring": _full_docstring(member.docstring), + "is_event": is_event, + } + ) + return props + + +def _extract_methods(cls: griffe.Class) -> list[dict[str, Any]]: + """Extract public methods.""" + methods: list[dict[str, Any]] = [] + for name, member in cls.members.items(): + if name.startswith("_"): + continue + if isinstance(member, griffe.Function): + args = [] + for param in member.parameters: + if param.name == "self": + continue + args.append( + { + "name": param.name, + "type": _annotation_str(param.annotation), + "default": _default_str(param.default), + } + ) + methods.append( + { + "name": name, + "args": args, + "return_type": _annotation_str(member.annotation), + "docstring": _full_docstring(member.docstring), + } + ) + return methods + + +def _extract_enum_members(cls: griffe.Class) -> list[dict[str, str]]: + members: list[dict[str, str]] = [] + for name, member in cls.members.items(): + if name.startswith("_"): + continue + if isinstance(member, griffe.Attribute): + entry: dict[str, str] = {"name": name, "value": _default_str(member.value)} + doc = _full_docstring(member.docstring) + if doc: + entry["docstring"] = doc + members.append(entry) + return members + + +# --------------------------------------------------------------------------- +# Module walker +# --------------------------------------------------------------------------- + + +def _walk_module( + module: griffe.Module, + controls: list[dict], + events: list[dict], + types: list[dict], + enums: list[dict], +) -> None: + """Recursively walk a griffe Module and classify its members.""" + for name, obj in module.members.items(): + if isinstance(obj, griffe.Module): + _walk_module(obj, controls, events, types, enums) + + elif isinstance(obj, griffe.Class): + try: + if _is_enum(obj): + members = _extract_enum_members(obj) + entry: dict[str, Any] = { + "name": obj.name, + "module": obj.canonical_path.rsplit(".", 1)[0], + "docstring": _full_docstring(obj.docstring), + "members": members, + } + if len(members) > 50: + entry["kind"] = "large_enum" + enums.append(entry) + + elif _is_control_class(obj): + categories, tags = _has_control_decorator(obj) + props = _extract_properties(obj) + event_fields = [p for p in props if p.get("is_event")] + regular_props = [p for p in props if not p.get("is_event")] + # Remove is_event flag from output + for p in regular_props: + p.pop("is_event", None) + for e in event_fields: + e.pop("is_event", None) + + kind = "service" if _is_service(obj) else "control" + controls.append( + { + "name": obj.name, + "module": obj.canonical_path.rsplit(".", 1)[0], + "kind": kind, + "summary": _first_line(obj.docstring), + "bases": [str(b) for b in obj.bases], + "categories": categories, + "tags": tags, + "properties": regular_props, + "events": event_fields, + "methods": _extract_methods(obj), + } + ) + + elif _is_event_class(obj): + fields = _extract_properties(obj) + for f in fields: + f.pop("is_event", None) + events.append( + { + "name": obj.name, + "module": obj.canonical_path.rsplit(".", 1)[0], + "docstring": _full_docstring(obj.docstring), + "fields": fields, + } + ) + + elif _is_dataclass(obj): + fields = _extract_properties(obj) + for f in fields: + f.pop("is_event", None) + class_methods = [] + for mname, member in obj.members.items(): + if mname.startswith("_"): + continue + if isinstance(member, griffe.Function) and ( + "classmethod" in member.labels + or "staticmethod" in member.labels + ): + args = [] + for param in member.parameters: + if param.name in ("self", "cls"): + continue + args.append( + { + "name": param.name, + "type": _annotation_str(param.annotation), + "default": _default_str(param.default), + } + ) + class_methods.append( + { + "name": mname, + "args": args, + "return_type": _annotation_str(member.annotation), + "docstring": _full_docstring(member.docstring), + } + ) + types.append( + { + "name": obj.name, + "module": obj.canonical_path.rsplit(".", 1)[0], + "docstring": _full_docstring(obj.docstring), + "fields": fields, + "methods": class_methods, + } + ) + except Exception as exc: + logger.warning("Error processing class %s: %s", name, exc) + + +# --------------------------------------------------------------------------- +# CLI help extraction +# --------------------------------------------------------------------------- + + +def _extract_cli_help() -> dict[str, Any]: + """Try to extract structured CLI help from flet_cli.""" + import argparse as _argparse + + cli_help: dict[str, Any] = {} + + def _parse_parser(parser: _argparse.ArgumentParser) -> dict[str, Any]: + """Extract structured option data from an ArgumentParser.""" + options: list[dict[str, Any]] = [] + for action in parser._actions: + if isinstance(action, _argparse._HelpAction): + continue + if isinstance(action, _argparse._SubParsersAction): + continue + + opt: dict[str, Any] = {} + + if action.option_strings: + opt["name"] = action.option_strings[-1] # --long form + if len(action.option_strings) > 1: + opt["alias"] = action.option_strings[0] # -short form + else: + opt["name"] = action.dest + opt["positional"] = True + + # Type + if isinstance( + action, (_argparse._StoreTrueAction, _argparse._StoreFalseAction) + ): + opt["type"] = "bool" + elif isinstance(action, _argparse._CountAction): + opt["type"] = "count" + elif action.type is not None: + opt["type"] = action.type.__name__ + else: + opt["type"] = "str" + + if action.nargs in ("*", "+"): + opt["multiple"] = True + + if action.default is not None and action.default != _argparse.SUPPRESS: + opt["default"] = action.default + + if action.required: + opt["required"] = True + + if action.choices: + opt["choices"] = list(action.choices) + + if action.help and action.help != _argparse.SUPPRESS: + opt["help"] = action.help + + options.append(opt) + + result: dict[str, Any] = {"options": options} + if parser.description: + result["description"] = parser.description + return result + + try: + from flet_cli.cli import get_parser + + parser = get_parser() + cli_help["flet"] = _parse_parser(parser) + for action in parser._subparsers._actions: + if hasattr(action, "choices") and action.choices: + for cmd_name, cmd_parser in action.choices.items(): + try: + cmd_data = _parse_parser(cmd_parser) + if cmd_parser.description: + cmd_data["description"] = cmd_parser.description + cli_help[cmd_name] = cmd_data + except Exception: + pass + except Exception as exc: + logger.warning("Could not extract CLI help: %s", exc) + return cli_help + + +# --------------------------------------------------------------------------- +# Icon enum injection +# --------------------------------------------------------------------------- + + +def _inject_icon_enums(enums: list[dict]) -> None: + """Add Icons and CupertinoIcons as large_enum entries from JSON files.""" + try: + import importlib.resources + + material_path = ( + importlib.resources.files("flet") / "controls" / "material" / "icons.json" + ) + cupertino_path = ( + importlib.resources.files("flet") + / "controls" + / "cupertino" + / "cupertino_icons.json" + ) + + with importlib.resources.as_file(material_path) as p: + material_icons: dict[str, int] = json.loads(p.read_text("utf-8")) + with importlib.resources.as_file(cupertino_path) as p: + cupertino_icons: dict[str, int] = json.loads(p.read_text("utf-8")) + + enums.append( + { + "name": "Icons", + "module": "flet.controls.material.icons", + "docstring": "Material Design icon constants.", + "kind": "large_enum", + "members": [ + {"name": name, "value": str(val)} + for name, val in material_icons.items() + ], + } + ) + enums.append( + { + "name": "CupertinoIcons", + "module": "flet.controls.cupertino.cupertino_icons", + "docstring": "Cupertino (iOS-style) icon constants.", + "kind": "large_enum", + "members": [ + {"name": name, "value": str(val)} + for name, val in cupertino_icons.items() + ], + } + ) + logger.info( + "Injected Icons (%d members) and CupertinoIcons (%d members)", + len(material_icons), + len(cupertino_icons), + ) + except Exception as exc: + logger.warning("Could not inject icon enums: %s", exc) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def build_api(output_path: Path, packages: list[str] | None = None) -> dict[str, Any]: + """Build the API reference JSON from installed Flet packages. + + Parameters: + output_path: Where to write the resulting api.json. + packages: List of package names to scan. Defaults to all known Flet packages. + + Returns: + A stats dict with counts per category. + """ + if packages is None: + packages = DEFAULT_PACKAGES + + controls: list[dict] = [] + events: list[dict] = [] + types: list[dict] = [] + enums: list[dict] = [] + + for pkg in packages: + try: + module = griffe.load(pkg) + except Exception as exc: + logger.warning("Failed to load package %s: %s", pkg, exc) + continue + _walk_module(module, controls, events, types, enums) + + # Inject Icons and CupertinoIcons from JSON files (not Python Enums) + _inject_icon_enums(enums) + + cli_help = _extract_cli_help() + + api_data: dict[str, Any] = { + "controls": controls, + "events": events, + "types": types, + "enums": enums, + "cli": cli_help, + } + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(api_data, indent=2), encoding="utf-8") + + stats = { + "controls": len(controls), + "events": len(events), + "types": len(types), + "enums": len(enums), + "cli_commands": len(cli_help), + } + logger.info("API build stats: %s", stats) + return stats diff --git a/sdk/python/packages/flet-mcp/src/flet_mcp/build/docs.py b/sdk/python/packages/flet-mcp/src/flet_mcp/build/docs.py new file mode 100644 index 0000000000..c331421ec6 --- /dev/null +++ b/sdk/python/packages/flet-mcp/src/flet_mcp/build/docs.py @@ -0,0 +1,132 @@ +"""Docs indexer. Parses search_index.json produced by mkdocs and indexes +the entries into an SQLite FTS5 database.""" + +from __future__ import annotations + +import json +import logging +import re +import sqlite3 +from pathlib import Path + +from markdownify import markdownify + +logger = logging.getLogger(__name__) + +_HTML_TAG_RE = re.compile(r"<[^>]+>") +_WHITESPACE_RE = re.compile(r"\s{3,}") +_LOCATION_SPLIT_RE = re.compile(r"[/\-_\.#]") +# mkdocstrings labels like class-attribute, instance-attribute +_MKDOCS_LABEL_RE = re.compile( + r"\s*(?:class-attribute|instance-attribute|property|cached-property|" + r"writable|static-method|class-method|module-attribute)", + re.IGNORECASE, +) + + +def _create_tables(conn: sqlite3.Connection) -> None: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS docs ( + location TEXT PRIMARY KEY, + location_text TEXT, + title TEXT, + content TEXT + ); + CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5( + title, location_text, content, + tokenize='porter unicode61' + ); + """ + ) + + +def _html_to_markdown(html: str) -> str: + """Convert HTML to markdown and clean up.""" + md = markdownify(html, strip=["img"]) + md = _HTML_TAG_RE.sub("", md) + md = _WHITESPACE_RE.sub("\n\n", md) + return md.strip() + + +def _strip_html(text: str) -> str: + text = _MKDOCS_LABEL_RE.sub("", text) + return _HTML_TAG_RE.sub("", text).strip() + + +def _location_to_text(location: str) -> str: + """Turn a location string into a space-separated searchable string. + + Example: + "ads/bannerad/#flet_ads.banner_ad.BannerAd.on_paid" + -> "ads bannerad flet_ads banner_ad BannerAd on_paid" + """ + parts = _LOCATION_SPLIT_RE.split(location) + return " ".join(p for p in parts if p) + + +def index_docs(conn: sqlite3.Connection, search_index_path: Path) -> int: + """Index mkdocs search_index.json into *conn*. + + Returns the number of doc entries indexed. + """ + _create_tables(conn) + + raw = json.loads(search_index_path.read_text(encoding="utf-8")) + entries: list[dict] = raw.get("docs", []) + + count = 0 + root_title: str = "" + section_title: str = "" + for entry in entries: + location: str = entry.get("location", "") + title: str = entry.get("title", "") + text: str = entry.get("text", "") + + has_anchor = "#" in location + clean_title = _strip_html(title) + + # ---- new page (no anchor) ---- + if not has_anchor: + root_title = clean_title + section_title = "" + if not text: + continue + + # ---- empty-text anchor = section group heading ---- + if has_anchor and not text: + anchor = location.split("#", 1)[1] if "#" in location else "" + # Only track well-known API doc section groups as intermediate titles + if anchor.endswith(("-properties", "-methods", "-events")): + section_title = clean_title + else: + # Reset section title — this is a peer heading, not a group + section_title = "" + continue + + # ---- entry with content: build composite title ---- + parts = [root_title] + if section_title: + parts.append(section_title) + parts.append(clean_title) + composite_title = " - ".join(parts) + + # ---- process content ---- + location_text = _location_to_text(location) + content = _html_to_markdown(text) + + # ---- insert ---- + conn.execute( + "INSERT OR REPLACE INTO docs (location, location_text, title, content) " + "VALUES (?, ?, ?, ?)", + (location, location_text, composite_title, content), + ) + conn.execute( + "INSERT INTO docs_fts (title, location_text, content) VALUES (?, ?, ?)", + (composite_title, location_text, content), + ) + + count += 1 + + conn.commit() + return count diff --git a/sdk/python/packages/flet-mcp/src/flet_mcp/build/examples.py b/sdk/python/packages/flet-mcp/src/flet_mcp/build/examples.py new file mode 100644 index 0000000000..276659a92e --- /dev/null +++ b/sdk/python/packages/flet-mcp/src/flet_mcp/build/examples.py @@ -0,0 +1,172 @@ +"""Examples indexer. Walks a directory tree looking for pyproject.toml files +with [tool.flet.metadata] and indexes them into an SQLite database.""" + +from __future__ import annotations + +import json +import logging +import re +import sqlite3 +import sys +from pathlib import Path + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + +logger = logging.getLogger(__name__) + +_CODE_EXTENSIONS = {".py", ".toml"} +_TEXT_EXTENSIONS = {".py", ".toml", ".md", ".txt"} + + +def _create_tables(conn: sqlite3.Connection) -> None: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS examples ( + id TEXT PRIMARY KEY, + location TEXT, + metadata TEXT + ); + CREATE TABLE IF NOT EXISTS example_files ( + example_id TEXT, + filename TEXT, + content TEXT, + FOREIGN KEY (example_id) REFERENCES examples(id) + ); + CREATE VIRTUAL TABLE IF NOT EXISTS examples_fts USING fts5( + title, description, tags, controls, + layout_pattern, features, search_text, code, + tokenize='porter unicode61' + ); + """ + ) + + +def _derive_id(relative_path: Path) -> str: + return re.sub(r"[\\/]", "_", str(relative_path)) + + +def _read_text_files(directory: Path) -> dict[str, str]: + """Read all indexable text files in *directory* (non-recursive).""" + files: dict[str, str] = {} + for p in sorted(directory.iterdir()): + if p.is_file() and p.suffix in _TEXT_EXTENSIONS: + try: + files[p.name] = p.read_text(encoding="utf-8", errors="replace") + except OSError as exc: + logger.warning("Cannot read %s: %s", p, exc) + return files + + +def index_examples(conn: sqlite3.Connection, examples_dir: Path) -> int: + """Index Flet example projects found under *examples_dir*. + + Returns the number of examples indexed. + """ + _create_tables(conn) + + count = 0 + for pyproject_path in sorted(examples_dir.rglob("pyproject.toml")): + try: + with open(pyproject_path, "rb") as f: + data = tomllib.load(f) + except Exception as exc: + logger.warning("Failed to parse %s: %s", pyproject_path, exc) + continue + + flet_meta = data.get("tool", {}).get("flet", {}).get("metadata") + if flet_meta is None: + continue + + project = data.get("project", {}) + project_dir = pyproject_path.parent + relative = project_dir.relative_to(examples_dir) + + example_id = _derive_id(relative) + location = str(relative) + title = flet_meta.get("title") or project.get("name", example_id) + description = project.get("description", "") + tags: list[str] = project.get("keywords", []) + platforms: list[str] = ( + data.get("tool", {}) + .get("flet", {}) + .get("platforms", ["web", "ios", "android", "macos", "windows", "linux"]) + ) + controls: list[str] = flet_meta.get("controls", []) + layout: str = flet_meta.get("layout_pattern", "") + complexity: str = flet_meta.get("complexity", "basic") + features: list[str] = flet_meta.get("features", []) + + # ---- read files in the example directory ---- + text_files = _read_text_files(project_dir) + + # ---- build search_text ---- + search_text = ( + f"{title}\n\n" + f"{description}\n\n" + f"tags: {', '.join(tags)}\n" + f"platforms: {', '.join(platforms)}\n" + f"controls: {', '.join(controls)}\n" + f"complexity: {complexity}\n" + f"layout_pattern: {layout}\n" + f"features: {', '.join(features)}" + ) + + # ---- build code blob ---- + code_parts: list[str] = [] + for fname, content in text_files.items(): + if Path(fname).suffix in _CODE_EXTENSIONS: + code_parts.append(content) + code = "\n\n".join(code_parts) + + # ---- metadata JSON ---- + metadata = json.dumps( + { + "title": title, + "description": description, + "tags": tags, + "platforms": platforms, + "controls": controls, + "layout_pattern": layout, + "complexity": complexity, + "features": features, + } + ) + + # ---- insert rows ---- + conn.execute( + "INSERT OR REPLACE INTO examples (id, location, metadata) VALUES (?, ?, ?)", + (example_id, location, metadata), + ) + + for fname, content in text_files.items(): + conn.execute( + "INSERT INTO example_files " + "(example_id, filename, content) VALUES (?, ?, ?)", + (example_id, fname, content), + ) + + conn.execute( + "INSERT INTO examples_fts " + "(title, description, tags, controls, " + "layout_pattern, features, search_text, code) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + title, + description, + ", ".join(tags), + ", ".join(controls), + layout, + ", ".join(features), + search_text, + code, + ), + ) + + count += 1 + logger.debug("Indexed example %s (%s)", example_id, title) + + conn.commit() + return count diff --git a/sdk/python/packages/flet-mcp/src/flet_mcp/build/indexer.py b/sdk/python/packages/flet-mcp/src/flet_mcp/build/indexer.py new file mode 100644 index 0000000000..54c4ac5155 --- /dev/null +++ b/sdk/python/packages/flet-mcp/src/flet_mcp/build/indexer.py @@ -0,0 +1,105 @@ +"""Build orchestrator for the Flet MCP data pipeline. + +Coordinates indexing of examples, docs, and API reference into the +data directory consumed by the MCP server. +""" + +from __future__ import annotations + +import logging +import sqlite3 +import time +from pathlib import Path + +from flet_mcp.build.api import build_api +from flet_mcp.build.docs import index_docs +from flet_mcp.build.examples import index_examples + +logger = logging.getLogger(__name__) + +_DATA_DIR = Path(__file__).resolve().parent.parent / "data" + + +def _print_summary(metrics: list[tuple[str, str, float]]) -> None: + """Print a summary table of build steps. + + Each entry is (step_name, result_text, elapsed_seconds). + """ + try: + from rich.console import Console + from rich.table import Table + + console = Console() + table = Table(title="MCP Build Summary") + table.add_column("Step", style="bold") + table.add_column("Result", justify="right") + table.add_column("Time", justify="right", style="dim") + + for step, result, elapsed in metrics: + table.add_row(step, result, f"{elapsed:.2f}s") + + console.print(table) + except ImportError: + # Fallback to plain output + print("\n--- MCP Build Summary ---") + for step, result, elapsed in metrics: + print(f" {step:<20s} {result:>30s} ({elapsed:.2f}s)") + print() + + +def build_all( + examples_dir: Path | None = None, + docs_index: Path | None = None, + output_dir: Path | None = None, +) -> None: + """Run the full MCP build pipeline. + + Parameters: + examples_dir: Root directory containing Flet example projects. + docs_index: Path to the mkdocs search_index.json file. + output_dir: Where to write mcp.db and api.json. + Defaults to the ``flet_mcp/data/`` package directory. + """ + if output_dir is None: + output_dir = _DATA_DIR + output_dir.mkdir(parents=True, exist_ok=True) + + db_path = output_dir / "mcp.db" + api_path = output_dir / "api.json" + + # Remove existing DB so we start fresh + if db_path.exists(): + db_path.unlink() + + metrics: list[tuple[str, str, float]] = [] + conn = sqlite3.connect(str(db_path)) + + try: + # ---- Examples ---- + if examples_dir is not None: + t0 = time.perf_counter() + n = index_examples(conn, examples_dir) + elapsed = time.perf_counter() - t0 + metrics.append(("Examples", f"{n} indexed", elapsed)) + logger.info("Indexed %d examples in %.2fs", n, elapsed) + + # ---- Docs ---- + if docs_index is not None: + t0 = time.perf_counter() + n = index_docs(conn, docs_index) + elapsed = time.perf_counter() - t0 + metrics.append(("Docs", f"{n} indexed", elapsed)) + logger.info("Indexed %d doc entries in %.2fs", n, elapsed) + + # ---- API reference ---- + t0 = time.perf_counter() + stats = build_api(api_path) + elapsed = time.perf_counter() - t0 + parts = [f"{v} {k}" for k, v in stats.items()] + metrics.append(("API", ", ".join(parts), elapsed)) + logger.info("Built API reference in %.2fs: %s", elapsed, stats) + + finally: + conn.close() + + _print_summary(metrics) diff --git a/sdk/python/packages/flet-mcp/src/flet_mcp/data/.gitkeep b/sdk/python/packages/flet-mcp/src/flet_mcp/data/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sdk/python/packages/flet-mcp/src/flet_mcp/data/icons.yml b/sdk/python/packages/flet-mcp/src/flet_mcp/data/icons.yml new file mode 100644 index 0000000000..972c261a77 --- /dev/null +++ b/sdk/python/packages/flet-mcp/src/flet_mcp/data/icons.yml @@ -0,0 +1,2097 @@ +# https://github.com/Christilut/material-icons-aliases/blob/master/icons.yml +action: + 3d rotation: + - arrows + - vr + accessibility: + - user + - person + accessible: + - wheelchair + - user + account balance: + - building + - temple + account balance wallet: [] + account box: + - user + account circle: + - user + add shopping cart: [] + alarm: + - time + - clock + alarm add: + - time + - clock + alarm off: + - time + - clock + alarm on: + - time + - clock + - checkmark + all out: + - arrows + - circle + - directional + - expand + android: + - operating system + announcement: + - comment + - chat + - message + - balloon + - exclamation + aspect ratio: + - screen + - resolution + - monitor + assessment: + - graph + - chart + assignment: + - clipboard + assignment ind: + - user + - clipboard + assignment late: + - exclamation + - clipboard + assignment return: + - clipboard + - arrow + assignment returned: + - clipboard + - arrow + assignment turned in: + - clipboard + - checkmark + autorenew: + - arrows + - loader + - loading + - refresh + backup: + - cloud + - arrow + - upload + book: + - bookmark + bookmark: + - favorite + bookmark border: + - favorite + bug report: + - ticket + - issue + build: + - wrench + - spanner + cached: + - arrows + - refresh + - loading + - loader + camera enhance: + - photo + card giftcard: + - creditcard + card membership: + - creditcard + card travel: + - creditcard + change history: + - triangle + check circle: [] + chrome reader mode: [] + class: [] + code: + - parenthesis + - brackets + compare arrows: + - directional + copyright: [] + credit card: + - creditcard + dashboard: [] + date range: + - calendar + - agenda + delete: + - trashcan + - bin + - recycle + - junk + delete forever: + - trashcan + - bin + - recycle + - junk + description: + - file + - document + dns: + - server + - network + done: + - checkmark + done all: + - checkmark + donut large: + - circle + donut small: + - circle + eject: [] + euro symbol: + - financial + - money + event: + - calendar + - agenda + event seat: + - chair + - bench + - sit + exit to app: + - quit + explore: + - compass + - map + extension: + - puzzle + face: + - person + - user + favorite: + - heart + - love + favorite border: + - heart + - love + feedback: + - comment + - message + - chat + find in page: + - search + find replace: + - search + fingerprint: + - biometrics + - touchid + flight land: + - airplane + - flying + flight takeoff: + - airplane + - flying + flip to back: [] + flip to front: [] + g translate: + - google + gavel: + - hammer + get app: + - download + gif: + - animated + grade: + - star + - rating + - rate + group work: + - circle + - film + - reel + help: + - question mark + help outline: + - question mark + highlight off: + - delete + - disable + - clear + - remove + - circle + - times + history: + - time + - revert + - undo + - clock + home: + - building + - house + hourglass empty: + - time + hourglass full: + - time + http: + - network + - internet + - web + https: + - network + - internet + - web + - security + - lock + - ssl + - encrypt + important devices: + - computer + - phone + - monitor + info: + - circle + - information + info outline: + - circle + - information + input: + - arrow + invert colors: + - drop + - water + - liquid + label: + - tag + - badge + label outline: + - tag + - badge + language: + - earth + - world + - web + launch: + - arrow + lightbulb outline: [] + line style: + - editor + line weight: + - editor + list: + - editor + lock: + - security + lock open: + - security + lock outline: + - security + loyalty: + - tag + - badge + - heart + - love + markunread mailbox: [] + motorcycle: + - motorbike + note add: + - document + - create + offline pin: + - checkmark + - circle + opacity: + - drop + - water + - liquid + open in browser: [] + open in new: [] + open with: + - arrows + - directional + - expand + pageview: + - search + - find + - looking glass + pan tool: + - hand + - touch + - drag + payment: + - creditcard + - financial + - money + perm camera mic: [] + perm contact calendar: + - user + - agenda + perm data setting: + - wifi + - wireless + - network + - cellular + - configure + perm device information: + - phone + - mobile + perm identity: + - user + perm media: + - folders + - directories + perm phone msg: + - mobile + - message + perm scan wifi: + - network + - wireless + pets: + - animal + - claw + - paw + - dog + - cat + picture in picture: [] + picture in picture alt: [] + play for work: + - arrow + polymer: [] + power settings new: + - 'off' + pregnant woman: + - user + - person + print: + - printer + - paper + query builder: + - clock + - time + question answer: + - comment + - message + - chat + receipt: + - transaction + record voice over: + - user + - sound + redeem: + - present + - giftcard + remove shopping cart: [] + reorder: + - lines + report problem: + - triangle + - warning + - danger + - exclamation + restore: + - clock + - undo + - time + - history + restore page: + - history + - web + - undo + room: + - location + - gps + rounded corner: [] + rowing: + - sports + schedule: + - time + - clock + search: + - find + - looking glass + settings: + - gear + settings applications: + - gear + settings backup restore: + - time + - history + - undo + settings bluetooth: + - network + settings brightness: + - sun + settings cell: + - mobile + - phone + settings ethernet: + - brackets + - parenthesis + - network + settings input antenna: + - wifi + - network + settings input component: + - plugs + - cable + settings input composite: + - plugs + - cable + settings input hdmi: + - cable + settings input svideo: + - plugs + settings overscan: + - expand + settings phone: [] + settings power: [] + settings remote: + - control + settings voice: + - microphone + shop: + - briefcase + shop two: + - briefcase + shopping basket: [] + shopping cart: [] + speaker notes: + - message + - chat + - comment + speaker notes off: + - message + - chat + - comment + spellcheck: + - letter + - alphabet + - checkmark + stars: + - rating + store: + - building + subject: + - lines + - text + supervisor account: + - user + - administrator + - person + swap horiz: + - arrows + swap vert: + - arrows + swap vertical circle: + - arrows + system update alt: + - arrow + tab: [] + tab unselected: [] + theaters: + - video + - media + - photography + thumb down: + - hand + thumb up: + - hand + thumbs up down: + - hand + timeline: + - chart + - graph + - line + toc: + - lines + - text + today: + - agenda + - calendar + toll: + - circles + touch app: + - hand + track changes: + - radar + - circle + translate: + - alphabet + - text + - letter + trending down: + - chart + - graph + - arrow + trending flat: + - arrow + - chart + - graph + trending up: + - arrow + - chart + - graph + turned in: [] + turned in not: [] + update: + - clock + - time + - arrow + - future + verified user: + - shield + - checkmark + view agenda: + - blocks + view array: + - blocks + view carousel: + - blocks + view column: + - blocks + view day: + - blocks + view headline: + - blocks + view list: + - blocks + view module: + - blocks + view quilt: + - blocks + view stream: + - blocks + view week: + - blocks + visibility: + - eye + visibility off: + - eye + - invisible + watch later: + - clock + - time + work: + - briefcase + - suitcase + youtube searched for: + - arrow + - looking glass + zoom in: + - looking glass + zoom out: + - looking glass +alert: + add alert: + - bell + - notification + - reminder + error: + - warning + - danger + - exclamation + - circle + error outline: + - warning + - danger + - exclamation + - circle + warning: + - danger + - exclamation + - triangle +av: + add to queue: + - television + - monitor + - plus + airplay: + - apple + - television + - monitor + album: + - vinyl + - cd + - record + art track: [] + av timer: + - stopwatch + branding watermark: [] + call to action: [] + closed caption: + - subtitles + equalizer: + - graph + - chart + explicit: [] + fast forward: [] + fast rewind: [] + featured play list: [] + featured video: [] + fiber dvr: [] + fiber manual record: + - dot + - circle + fiber new: [] + fiber pin: [] + fiber smart record: [] + forward 10: + - arrow + - circle + forward 30: + - arrow + - circle + forward 5: + - arrow + - circle + games: + - playstation + - nintendo + - xbox + - dpad + - controller + hd: [] + hearing: [] + high quality: + - hq + library add: + - collection + library books: + - collection + library music: + - collection + loop: + - refresh + - loader + - loading + mic: + - microphone + mic none: + - microphone + mic off: + - microphone + movie: + - video + - film + music video: [] + new releases: + - exclamation + not interested: + - disabled + - not allowed + - banned + - prohibited + note: [] + pause: [] + pause circle filled: [] + pause circle outline: [] + play arrow: [] + play circle filled: [] + play circle outline: [] + playlist add: [] + playlist add check: + - checkmark + playlist play: [] + queue: [] + queue music: [] + queue play next: [] + radio: [] + recent actors: + - contacts + remove from queue: + - television + - monitor + repeat: + - arrows + repeat one: + - arrows + replay: + - undo + - rewind + - arrow + replay 10: + - rewind + - arrow + replay 30: + - rewind + - arrow + replay 5: + - rewind + - arrow + shuffle: + - arrows + skip next: [] + skip previous: [] + slow motion video: + - circle + - time + snooze: + - alarm + sort by alpha: + - alphabet + - letters + stop: + - square + subscriptions: + - youtube + - playlist + - queue + subtitles: + - captions + surround sound: + - audio + video call: + - camera + video label: [] + video library: + - collection + videocam: + - camera + videocam off: + - camera + volume down: + - sound + volume mute: + - sound + volume off: + - sound + volume up: + - sound + web: + - blocks + web asset: [] +communication: + business: + - building + - flat + - apartment + call: + - phone + call end: + - phone + call made: + - arrow + call merge: + - arrow + call missed: + - arrow + call missed outgoing: + - arrow + call received: + - arrow + call split: + - arrow + chat: + - message + - comment + chat bubble: + - message + - comment + chat bubble outline: + - message + - comment + clear all: + - lines + - notifications + comment: + - chat + - message + contact mail: + - user + - person + contact phone: + - user + - person + contacts: + - user + - person + dialer sip: + - phone + dialpad: + - dots + - phone + email: + - letter + - envelope + forum: + - chat + - messages + - conversation + import contacts: + - book + - magazine + import export: + - arrows + invert colors off: + - drop + - liquid + - water + live help: + - comment + - question + - faq + location off: + - gps + location on: + - gps + mail outline: + - letter + - envelope + message: + - chat + - comment + no sim: [] + phone: + - mobile + phonelink erase: + - mobile + phonelink lock: + - mobile + - security + phonelink ring: + - mobile + phonelink setup: + - mobile + - settings + portable wifi off: + - network + present to all: + - arrow + ring volume: + - phone + - mobile + rss feed: + - wifi + - network + screen share: + - monitor + - laptop + speaker phone: + - mobile + stay current landscape: + - mobile + - phone + stay current portrait: + - mobile + - phone + stay primary landscape: + - mobile + - phone + stay primary portrait: + - mobile + - phone + stop screen share: + - monitor + - laptop + swap calls: + - arrow + textsms: + - message + - chat + - comment + voicemail: [] + vpn key: + - security +content: + add: + - plus + - new + - create + add box: + - plus + - new + - create + - square + add circle: + - plus + - new + - create + add circle outline: + - plus + - new + - create + archive: [] + backspace: + - delete + - clear + - remove + block: + - disable + - not allowed + - cancel + - banned + clear: + - disable + - cancel + - cross + - times + - x + content copy: + - files + content cut: + - scissor + content paste: + - clipboard + create: + - pencil + - new + delete sweep: + - trashcan + - bin + drafts: + - email + - letter + - envelope + filter list: + - lines + flag: [] + font download: + - letter + - square + forward: + - arrow + gesture: + - line + - drawing + inbox: [] + link: [] + low priority: + - arrow + - list + mail: + - letter + - envelope + markunread: + - letter + - envelope + move to inbox: [] + next week: + - suitcase + - briefcase + redo: + - arrow + remove: + - minus + - line + remove circle: + - not allowed + - disable + - banned + remove circle outline: + - not allowed + - disable + - banned + reply: + - arrow + reply all: + - arrows + report: + - exclamation + save: + - floppy + - diskette + select all: [] + send: + - chat + - message + - telegram + sort: + - lines + text format: + - letter + unarchive: + - arrow + undo: + - arrow + weekend: + - couch + - seat + - chair +device: + access alarm: + - time + - clock + access alarms: + - time + - clock + access time: + - time + - clock + add alarm: + - time + - clock + - plus + airplanemode active: + - flying + airplanemode inactive: + - flying + battery 20: [] + battery 30: [] + battery 50: [] + battery 60: [] + battery 80: [] + battery 90: [] + battery alert: + - exclamation + battery charging 20: [] + battery charging 30: [] + battery charging 50: [] + battery charging 60: [] + battery charging 80: [] + battery charging 90: [] + battery charging full: + - lightning + battery full: [] + battery std: [] + battery unknown: + - question + bluetooth: + - wireless + - network + bluetooth connected: + - wireless + - network + bluetooth disabled: + - wireless + - network + bluetooth searching: + - wireless + - network + brightness auto: [] + brightness high: [] + brightness low: [] + brightness medium: [] + data usage: + - circle + developer mode: + - code + devices: + - laptop + - phone + - mobile + dvr: + - monitor + - laptop + gps fixed: + - location + gps not fixed: [] + gps off: [] + graphic eq: + - equalizer + - audio + location disabled: [] + location searching: [] + network cell: [] + network wifi: [] + nfc: [] + screen lock landscape: + - mobile + - phone + - security + screen lock portrait: + - mobile + - phone + - security + screen lock rotation: + - mobile + - phone + screen rotation: + - mobile + - phone + sd storage: + - microsd + settings system daydream: + - cloud + signal cellular 0 bar: [] + signal cellular 1 bar: [] + signal cellular 2 bar: [] + signal cellular 3 bar: [] + signal cellular 4 bar: + - network + signal cellular connected no internet 0 bar: [] + signal cellular connected no internet 1 bar: [] + signal cellular connected no internet 2 bar: [] + signal cellular connected no internet 3 bar: [] + signal cellular connected no internet 4 bar: + - network + signal cellular no sim: [] + signal cellular null: + - wifi + signal cellular off: + - network + signal wifi 0 bar: [] + signal wifi 1 bar: [] + signal wifi 1 bar lock: [] + signal wifi 2 bar: [] + signal wifi 2 bar lock: [] + signal wifi 3 bar: [] + signal wifi 3 bar lock: [] + signal wifi 4 bar: [] + signal wifi 4 bar lock: + - network + signal wifi off: + - network + signal wifi statusbar 1 bar 26x: [] + signal wifi statusbar 2 bar 26x: [] + signal wifi statusbar 3 bar 26x: [] + signal wifi statusbar 4 bar 26x: [] + signal wifi statusbar connected no internet 1 26x: [] + signal wifi statusbar connected no internet 2 26x: [] + signal wifi statusbar connected no internet 26x: [] + signal wifi statusbar connected no internet 3 26x: [] + signal wifi statusbar connected no internet 4 26x: [] + signal wifi statusbar not connected 26x: [] + signal wifi statusbar null 26x: [] + storage: + - network + - server + - database + usb: [] + wallpaper: + - image + - picture + widgets: + - blocks + wifi lock: + - network + wifi tethering: + - network +editor: + attach file: + - paperclip + attach money: + - financial + - dollar + border all: [] + border bottom: [] + border clear: [] + border color: + - pencil + - marker + - create + border horizontal: [] + border inner: [] + border left: [] + border outer: [] + border right: [] + border style: [] + border top: [] + border vertical: [] + bubble chart: + - graph + drag handle: + - lines + format align center: + - lines + format align justify: + - lines + format align left: + - lines + format align right: + - lines + format bold: [] + format clear: [] + format color fill: + - bucket + format color reset: + - drop + - water + - liquid + format color text: [] + format indent decrease: [] + format indent increase: [] + format italic: [] + format line spacing: [] + format list bulleted: [] + format list numbered: [] + format paint: + - paintroller + format quote: + - quotation + format shapes: [] + format size: [] + format strikethrough: [] + format textdirection l to r: + - paragraph + format textdirection r to l: + - paragraph + format underlined: [] + functions: + - sigma + highlight: + - marker + - flashlight + insert chart: + - graph + insert comment: + - chat + - message + insert drive file: [] + insert emoticon: [] + insert invitation: + - agenda + - calendar + insert link: [] + insert photo: + - image + - wallpaper + linear scale: [] + merge type: + - arrow + mode comment: + - message + - chat + mode edit: + - pencil + - create + - new + monetization on: + - money + - financial + - dollar + money off: + - money + - financial + - dollar + multiline chart: + - graph + pie chart: + - graph + pie chart outlined: + - graph + publish: + - upload + - arrow + short text: + - lines + show chart: + - graph + space bar: [] + strikethrough s: [] + text fields: [] + title: [] + vertical align bottom: + - arrow + vertical align center: + - arrow + vertical align top: + - arrow + wrap text: + - arrow +file: + attachment: + - paperclip + cloud: [] + cloud circle: [] + cloud done: + - checkmark + cloud download: [] + cloud off: [] + cloud queue: [] + cloud upload: [] + create new folder: + - directory + file download: [] + file upload: [] + folder: + - directory + folder open: + - directory + folder shared: + - directory +hardware: + cast: + - chromecast + cast connected: + - chromecast + computer: + - laptop + - pc + - monitor + desktop mac: + - pc + - apple + - monitor + desktop windows: + - pc + - monitor + developer board: + - devkit + device hub: [] + devices other: + - smartwatch + dock: + - charger + gamepad: [] + headset: + - earmuffs + - headphones + headset mic: + - headphones + keyboard: [] + keyboard arrow down: + - chevron + keyboard arrow left: + - chevron + keyboard arrow right: + - chevron + keyboard arrow up: + - chevron + keyboard backspace: [] + keyboard capslock: + - arrow + keyboard hide: [] + keyboard return: + - arrow + keyboard tab: + - arrow + keyboard voice: [] + laptop: [] + laptop chromebook: [] + laptop mac: + - apple + laptop windows: [] + memory: + - chip + mouse: [] + phone android: + - mobile + phone iphone: + - mobile + - apple + phonelink: [] + phonelink off: [] + power input: + - dc + router: + - device + - network + scanner: + - device + security: + - shield + sim card: [] + smartphone: + - mobile + speaker: + - audio + speaker group: + - audio + tablet: [] + tablet android: [] + tablet mac: + - apple + toys: + - fan + tv: + - television + videogame asset: + - gamepad + - controller + - playstation + - xbox + - nintendo + watch: + - smartwatch +image: + add a photo: + - camera + add to photos: + - plus + adjust: + - circle + assistant: + - star + - comment + - chat + assistant photo: [] + audiotrack: + - note + blur circular: + - circle + blur linear: [] + blur off: [] + blur on: [] + brightness 1: [] + brightness 2: + - moon + brightness 3: + - moon + brightness 4: + - moon + - sun + brightness 5: + - sun + brightness 6: + - sun + brightness 7: + - sun + broken image: + - picture + brush: + - paint + burst mode: + - image + - picture + camera: [] + camera alt: [] + camera front: [] + camera rear: [] + camera roll: [] + center focus strong: [] + center focus weak: [] + collections: [] + collections bookmark: [] + color lens: [] + colorize: + - color picker + - pipette + compare: [] + control point: + - circle + - plus + control point duplicate: + - circle + - plus + crop: [] + crop 16 9: + - square + crop 3 2: + - square + crop 5 4: + - square + crop 7 5: + - square + crop din: + - square + crop free: + - square + crop landscape: + - square + crop original: + - square + crop portrait: + - square + crop rotate: [] + crop square: [] + dehaze: [] + details: + - triangle + edit: [] + exposure: + - plus + - minus + exposure neg 1: + - minus + exposure neg 2: + - minus + exposure plus 1: + - plus + exposure plus 2: + - plus + exposure zero: [] + filter: [] + filter 1: [] + filter 2: [] + filter 3: [] + filter 4: [] + filter 5: [] + filter 6: [] + filter 7: [] + filter 8: [] + filter 9: [] + filter 9 plus: [] + filter b and w: [] + filter center focus: [] + filter drama: + - cloud + filter frames: [] + filter hdr: + - mountains + filter none: [] + filter tilt shift: [] + filter vintage: + - flower + flare: + - lensflare + - sun + - star + - light + - bright + - shine + - sparkle + flash auto: + - lightning + flash off: + - lightning + flash on: + - lightning + flip: [] + gradient: [] + grain: [] + grid off: [] + grid on: [] + hdr off: [] + hdr on: [] + hdr strong: [] + hdr weak: [] + healing: + - bandaid + image: + - picture + image aspect ratio: [] + iso: [] + landscape: [] + leak add: [] + leak remove: [] + lens: [] + linked camera: [] + looks: + - rainbow + looks 3: [] + looks 4: [] + looks 5: [] + looks 6: [] + looks one: [] + looks two: [] + loupe: [] + monochrome photos: [] + movie creation: + - video + - film + movie filter: + - video + - film + music note: [] + nature: + - tree + - forest + nature people: + - tree + - forest + - person + navigate before: + - arrow + navigate next: + - arrow + palette: [] + panorama: + - picture + - image + panorama fish eye: [] + panorama horizontal: [] + panorama vertical: [] + panorama wide angle: [] + photo: [] + photo album: + - picture + - image + photo camera: + - picture + - image + photo filter: [] + photo library: + - picture + - image + photo size select actual: [] + photo size select large: [] + photo size select small: [] + picture as pdf: [] + portrait: [] + remove red eye: [] + rotate 90 degrees ccw: + - arrow + rotate left: + - arrow + - circle + rotate right: + - arrow + - circle + slideshow: [] + straighten: + - piano + style: [] + switch camera: [] + switch video: [] + tag faces: [] + texture: [] + timelapse: [] + timer: + - stopwatch + timer 10: [] + timer 3: [] + timer off: + - stopwatch + tonality: [] + transform: [] + tune: + - settings + - sliders + view comfy: + - grid + view compact: + - grid + vignette: [] + wb auto: [] + wb cloudy: [] + wb incandescent: + - lightbulb + wb iridescent: [] + wb sunny: [] +maps: + add location: + - gps + beenhere: + - checkmark + directions: + - naviate + - arrow + directions bike: + - bicycle + directions boat: [] + directions bus: [] + directions car: [] + directions railway: [] + directions run: + - person + directions subway: [] + directions transit: + - subway + - metro + directions walk: + - person + edit location: + - gps + ev station: + - gas station + - gasoline + - fuel + - filling station + flight: + - flying + - airplane + hotel: + - bed + - sleep + layers: [] + layers clear: [] + local activity: + - star + local airport: + - flying + - airplane + local atm: + - financial + - dollar + - money + local bar: + - alcohol + - drink + - martini + local cafe: + - drink + - coffee + local car wash: [] + local convenience store: + - building + local dining: + - cutlery + - knife + - spoon + local drink: + - glass + - water + local florist: + - flower + local gas station: + - gasoline + - fuel + - filling station + local grocery store: [] + local hospital: + - plus + local hotel: [] + local laundry service: [] + local library: + - book + - person + local mall: + - shopping bag + local movies: [] + local offer: [] + local parking: [] + local pharmacy: + - food + local phone: [] + local pizza: [] + local play: [] + local post office: [] + local printshop: [] + local see: [] + local shipping: + - car + - truck + - semi + local taxi: + - car + map: [] + my location: [] + navigation: + - arrow + near me: + - arrow + person pin: + - location + - gps + person pin circle: + - location + - gps + pin drop: + - location + - gps + place: [] + rate review: + - comment + - message + - chat + restaurant: + - cutlery + - knife + - fork + restaurant menu: [] + satellite: [] + store mall directory: + - building + streetview: + - map + - gps + - location + subway: + - metro + terrain: [] + traffic: + - light + train: [] + tram: [] + transfer within a station: + - person + zoom out map: + - arrows +navigation: + apps: + - grid + arrow back: [] + arrow downward: [] + arrow drop down: [] + arrow drop down circle: [] + arrow drop up: [] + arrow forward: [] + arrow upward: [] + cancel: + - disable + - cross + check: + - checkmark + chevron left: + - arrow + chevron right: + - arrow + close: + - cancel + - disable + - not allowed + expand less: + - arrow + expand more: + - arrow + first page: + - arrow + fullscreen: [] + fullscreen exit: [] + last page: + - arrow + menu: [] + more horiz: + - dots + more vert: + - dots + refresh: [] + subdirectory arrow left: + - arrow + subdirectory arrow right: + - arrow + unfold less: + - arrow + unfold more: + - arrow +notification: + adb: + - android + airline seat flat: + - bed + - sleep + airline seat flat angled: + - bed + - sleep + airline seat individual suite: + - bed + - sleep + airline seat legroom extra: [] + airline seat legroom normal: [] + airline seat legroom reduced: [] + airline seat recline extra: [] + airline seat recline normal: [] + bluetooth audio: [] + confirmation number: + - ticket + disc full: + - cd + - vinyl + - exclamation + do not disturb: + - banned + do not disturb alt: + - banned + do not disturb off: + - banned + do not disturb on: + - banned + drive eta: [] + enhanced encryption: + - security + - lock + event available: + - agenda + - calendar + event busy: + - agenda + - calendar + event note: + - agenda + - calendar + folder special: + - directory + live tv: + - monitor + - television + mms: + - chat + - message + - comment + more: + - tag + - badge + network check: + - wifi + network locked: + - wifi + no encryption: + - lock + - security + ondemand video: + - monitor + - television + personal video: + - monitor + - television + phone bluetooth speaker: [] + phone forwarded: [] + phone in talk: [] + phone locked: + - security + phone missed: [] + phone paused: [] + power: + - socket + - plug + priority high: + - exclamation + rv hookup: [] + sd card: [] + sim card alert: + - exclamation + sms: + - chat + - comment + - message + sms failed: + - chat + - comment + - message + sync: [] + sync disabled: + - refresh + - arrows + sync problem: + - refresh + - arrows + system update: + - download + tap and play: + - nfc + - wifi + time to leave: [] + vibration: + - mobile + - phone + voice chat: + - camera + vpn lock: + - world + - security + wc: + - person + - toilet + - unisex + wifi: + - network +places: + ac unit: + - snowflake + - freeze + airport shuttle: + - bus + - car + all inclusive: + - infinite + beach access: + - umbrella + - parasol + business center: + - suitcase + - briefcase + casino: + - dice + child care: + - baby + child friendly: + - baby + fitness center: + - weights + - workout + free breakfast: + - tea + - coffee + - drink + golf course: + - flag + hot tub: + - jacuzzi + kitchen: + - refrigerator + - cabinet + pool: + - water + - swimming + room service: + - bell + - concierge + rv hookup: [] + smoke free: + - cigarette + smoking rooms: + - cigarette + spa: + - flower +social: + cake: + - pie + domain: [] + group: + - person + - user + group add: + - person + - user + - people + location city: + - building + mood: + - smiley + - emoji + - emoticon + mood bad: + - smiley + - emoji + - emoticon + notifications: + - bell + - ring + notifications active: + - bell + - ring + notifications none: + - bell + - ring + notifications off: + - bell + - ring + notifications paused: + - bell + - ring + pages: [] + party mode: [] + people: + - person + - user + people outline: + - person + - user + person: + - user + person add: + - user + person outline: + - user + plus one: + - add + poll: + - graph + - chart + public: + - world + - earth + school: + - university + - college + sentiment very dissatisfied: + - smiley + - emoji + - emoticon + - sad + sentiment dissatisfied: + - smiley + - emoji + - emoticon + - sad + sentiment neutral: + - smiley + - emoji + - emoticon + sentiment satisfied: + - smiley + - emoji + - emoticon + - happy + sentiment very satisfied: + - smiley + - emoji + - emoticon + - happy + share: [] + whatshot: + - fire +toggle: + check box: + - checkmark + - square + check box outline blank: + - square + indeterminate check box: + - square + - minus + radio button checked: + - circle + radio button unchecked: + - circle + star: + - rating + star border: + - rating + star half: + - rating diff --git a/sdk/python/packages/flet-mcp/src/flet_mcp/db.py b/sdk/python/packages/flet-mcp/src/flet_mcp/db.py new file mode 100644 index 0000000000..c02b8fcbc5 --- /dev/null +++ b/sdk/python/packages/flet-mcp/src/flet_mcp/db.py @@ -0,0 +1,42 @@ +"""SQLite helpers for the MCP server.""" + +import importlib.resources +from pathlib import Path + + +def get_db_path() -> Path: + """Return filesystem path to the bundled mcp.db.""" + ref = importlib.resources.files("flet_mcp").joinpath("data/mcp.db") + # For installed packages, this returns a real path + return Path(str(ref)) + + +def snippet(text: str, query: str, length: int = 200) -> str: + """Extract a snippet around the first match of query tokens in text.""" + if not text or not query: + return text[:length] if text else "" + + query_tokens = query.lower().split() + text_lower = text.lower() + + # Find the earliest match position + best_pos = len(text) + for token in query_tokens: + pos = text_lower.find(token) + if pos != -1 and pos < best_pos: + best_pos = pos + + if best_pos == len(text): + return text[:length] + + # Center snippet around match + start = max(0, best_pos - length // 4) + end = min(len(text), start + length) + + result = text[start:end] + if start > 0: + result = "..." + result + if end < len(text): + result = result + "..." + + return result diff --git a/sdk/python/packages/flet-mcp/src/flet_mcp/icons_store.py b/sdk/python/packages/flet-mcp/src/flet_mcp/icons_store.py new file mode 100644 index 0000000000..b87094ba7c --- /dev/null +++ b/sdk/python/packages/flet-mcp/src/flet_mcp/icons_store.py @@ -0,0 +1,138 @@ +"""In-memory icon search across Material and Cupertino icon sets.""" + +from __future__ import annotations + +import json +from collections import defaultdict +from importlib import resources + +import yaml + + +class IconStore: + def __init__(self): + # Load icon JSON files from the flet package + material_path = resources.files("flet") / "controls" / "material" / "icons.json" + cupertino_path = ( + resources.files("flet") / "controls" / "cupertino" / "cupertino_icons.json" + ) + + with resources.as_file(material_path) as p: + self._material: dict[str, int] = json.loads(p.read_text("utf-8")) + + with resources.as_file(cupertino_path) as p: + self._cupertino: dict[str, int] = json.loads(p.read_text("utf-8")) + + # Load synonym data from flet_mcp/data/icons.yml + synonyms_path = resources.files("flet_mcp") / "data" / "icons.yml" + with resources.as_file(synonyms_path) as p: + self._synonyms_yaml: dict = yaml.safe_load(p.read_text("utf-8")) or {} + + # Build a flat lookup: lowercase base name -> list of synonym keywords + self._synonym_map: dict[str, list[str]] = {} + for _category, icons in self._synonyms_yaml.items(): + if not isinstance(icons, dict): + continue + for base_name, keywords in icons.items(): + key = str(base_name).lower().replace(" ", "_") + if isinstance(keywords, list): + self._synonym_map[key] = [str(k).lower() for k in keywords] + + # Inverted index: keyword -> set of (family, icon_name) + self._index: dict[str, set[tuple[str, str]]] = defaultdict(set) + + # Track which (family, icon_name) pairs have synonym-contributed keywords + self._synonym_entries: set[tuple[str, str, str]] = set() + + self._build_index() + + def _build_index(self): + for icon_name in self._material: + self._index_icon("material", icon_name) + for icon_name in self._cupertino: + self._index_icon("cupertino", icon_name) + + def _index_icon(self, family: str, icon_name: str): + entry = (family, icon_name) + tokens = icon_name.lower().split("_") + for token in tokens: + if token: + self._index[token].add(entry) + + # Look up synonyms by the lowercase underscored name + lookup_key = icon_name.lower() + synonyms = self._synonym_map.get(lookup_key, []) + for keyword in synonyms: + self._index[keyword].add(entry) + self._synonym_entries.add((family, icon_name, keyword)) + + def find( + self, + query: str, + family: str | None = None, + limit: int = 10, + ) -> list[str]: + """Search icons by query string. + + Args: + query: Space-separated search terms. + family: Optional filter — "material" or "cupertino". + limit: Maximum number of results to return. + + Returns: + List of qualified icon names like "Icons.ARROW_BACK" + or "CupertinoIcons.BACK". + """ + query_tokens = [t for t in query.lower().split() if t] + if not query_tokens: + return [] + + # Collect all candidate icons that match at least one token + candidates: dict[tuple[str, str], float] = defaultdict(float) + + for token in query_tokens: + for entry in self._index.get(token, set()): + fam, icon_name = entry + if family and fam != family: + continue + + icon_tokens = set(icon_name.lower().split("_")) + + # Check if this token matched via synonym + is_synonym = (fam, icon_name, token) in self._synonym_entries + if is_synonym: + candidates[entry] += 5 + else: + candidates[entry] += 10 + + # Bonus for exact full-name match + query_as_name = "_".join(query_tokens).upper() + for entry in list(candidates): + if entry[1] == query_as_name: + candidates[entry] += 100 + + # Bonus: all query tokens present in icon name tokens + for entry in list(candidates): + icon_tokens = set(entry[1].lower().split("_")) + synonym_keywords = { + kw + for f, n, kw in self._synonym_entries + if f == entry[0] and n == entry[1] + } + all_tokens = icon_tokens | synonym_keywords + if all(qt in all_tokens for qt in query_tokens): + # Already scored per-token above; the per-token +10/+5 covers this + pass + + sorted_results = sorted(candidates.items(), key=lambda x: -x[1]) + + results: list[str] = [] + for (fam, icon_name), _score in sorted_results: + if len(results) >= limit: + break + if fam == "material": + results.append(f"Icons.{icon_name}") + else: + results.append(f"CupertinoIcons.{icon_name}") + + return results diff --git a/sdk/python/packages/flet-mcp/src/flet_mcp/server.py b/sdk/python/packages/flet-mcp/src/flet_mcp/server.py new file mode 100644 index 0000000000..6075b206dc --- /dev/null +++ b/sdk/python/packages/flet-mcp/src/flet_mcp/server.py @@ -0,0 +1,364 @@ +import json +import sqlite3 +from typing import Optional + +from fastmcp import FastMCP + +from flet_mcp.api_store import ApiStore +from flet_mcp.db import get_db_path +from flet_mcp.icons_store import IconStore + +mcp = FastMCP( + "flet-mcp", + instructions=( + "Flet MCP server provides tools to search examples, documentation, " + "and API reference for building Flet applications. " + "Use search tools first to discover relevant content, " + "then use get tools to retrieve full details." + ), +) + +_api_store: Optional[ApiStore] = None +_icon_store: Optional[IconStore] = None + + +def _get_api_store() -> ApiStore: + global _api_store + if _api_store is None: + _api_store = ApiStore() + return _api_store + + +def _get_icon_store() -> IconStore: + global _icon_store + if _icon_store is None: + _icon_store = IconStore() + return _icon_store + + +def _get_db() -> sqlite3.Connection: + db_path = get_db_path() + conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + conn.row_factory = sqlite3.Row + return conn + + +# ── Examples ────────────────────────────────────────────────────────────── + + +@mcp.tool() +def search_examples( + query: str, platform: Optional[str] = None, limit: int = 5 +) -> list[dict]: + """Search Flet example projects by keyword. + + Returns a ranked list of examples with id, title, description, and snippet. + Use get_example() to retrieve full source code. + + Args: + query: Search keywords (e.g. "counter", "form validation", "navigation"). + platform: Optional platform filter (web, ios, android, macos, windows, linux). + limit: Maximum number of results to return. + """ + conn = _get_db() + try: + # Check if examples table exists + table_check = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='examples_fts'" + ).fetchone() + if not table_check: + return [] + + # BM25 weights: title=8, description=5, tags=4, controls=5, + # layout_pattern=6, features=4, search_text=2, code=1 + rows = conn.execute( + """ + SELECT e.id, e.location, e.metadata, + snippet(examples_fts, 6, '**', '**', '...', 40) AS snip, + bm25(examples_fts, 8.0, 5.0, 4.0, 5.0, 6.0, 4.0, 2.0, 1.0) AS rank + FROM examples_fts + JOIN examples e ON examples_fts.rowid = e.rowid + WHERE examples_fts MATCH ? + ORDER BY rank + LIMIT ? + """, + (query, limit), + ).fetchall() + + results = [] + for row in rows: + meta = json.loads(row["metadata"]) + if platform and platform.lower() not in [ + p.lower() for p in meta.get("platforms", []) + ]: + continue + results.append( + { + "id": row["id"], + "title": meta.get("title", ""), + "description": meta.get("description", ""), + "controls": meta.get("controls", []), + "complexity": meta.get("complexity", ""), + "snippet": row["snip"] or "", + } + ) + return results + finally: + conn.close() + + +@mcp.tool() +def get_example(example_id: str) -> dict: + """Get full source code and metadata for a specific example. + + Args: + example_id: The example ID returned by search_examples(). + """ + conn = _get_db() + try: + table_check = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='examples'" + ).fetchone() + if not table_check: + return {"error": "No examples indexed"} + + row = conn.execute( + "SELECT id, location, metadata FROM examples WHERE id = ?", + (example_id,), + ).fetchone() + if not row: + return {"error": f"Example '{example_id}' not found"} + + files = conn.execute( + "SELECT filename, content FROM example_files WHERE example_id = ?", + (example_id,), + ).fetchall() + + meta = json.loads(row["metadata"]) + return { + "id": row["id"], + "location": row["location"], + **meta, + "files": {f["filename"]: f["content"] for f in files}, + } + finally: + conn.close() + + +# ── Documentation ───────────────────────────────────────────────────────── + + +@mcp.tool() +def search_docs(query: str, limit: int = 5) -> list[dict]: + """Search Flet documentation by keyword. + + Returns ranked results with title, location, and snippet. + Use get_doc() to retrieve full content. + + Args: + query: Search keywords (e.g. "TextField validation", "routing", "theme"). + limit: Maximum number of results to return. + """ + conn = _get_db() + try: + # BM25 weights: title=6, location_text=4, content=1 + rows = conn.execute( + """ + SELECT d.location, d.title, + snippet(docs_fts, 2, '**', '**', '...', 40) AS snip, + bm25(docs_fts, 6.0, 4.0, 1.0) AS rank + FROM docs_fts + JOIN docs d ON docs_fts.rowid = d.rowid + WHERE docs_fts MATCH ? + ORDER BY rank + LIMIT ? + """, + (query, limit), + ).fetchall() + + return [ + { + "title": row["title"], + "location": row["location"], + "snippet": row["snip"] or "", + } + for row in rows + ] + finally: + conn.close() + + +@mcp.tool() +def get_doc(location: str) -> dict: + """Get full content of a documentation section by its location path. + + Args: + location: The location path returned by search_docs() + (e.g. "controls/textfield/", "controls/textfield/#validation"). + """ + conn = _get_db() + try: + row = conn.execute( + "SELECT location, title, content FROM docs WHERE location = ?", + (location,), + ).fetchone() + if not row: + return {"error": f"Document '{location}' not found"} + return { + "location": row["location"], + "title": row["title"], + "content": row["content"], + } + finally: + conn.close() + + +# ── API Reference ───────────────────────────────────────────────────────── + + +@mcp.tool() +def list_controls( + category: Optional[str] = None, + kind: Optional[str] = None, + limit: int = 50, +) -> list[dict]: + """List available Flet controls and services. + + Args: + category: Optional category filter (e.g. "input", "layout", "navigation"). + kind: Optional kind filter - "control" for visual controls, + "service" for non-visual services (Audio, FilePicker, sensors, etc.). + limit: Maximum number of results to return. + """ + store = _get_api_store() + return store.list_controls(category=category, kind=kind, limit=limit) + + +@mcp.tool() +def get_control_api(name: str) -> dict: + """Get detailed API reference for a specific control or service. + + Returns properties (with types and docstrings), events, and methods + (with argument signatures). The response includes a 'kind' field + indicating whether it's a visual "control" or a non-visual "service". + + Args: + name: Control or service class name (e.g. "TextField", "FilePicker", "Audio"). + """ + store = _get_api_store() + result = store.get_control(name) + if result is None: + return {"error": f"Control or service '{name}' not found"} + return result + + +@mcp.tool() +def get_type_api(name: str) -> dict: + """Get API reference for a non-control type (dataclass). + + Returns fields, class methods, and factory methods for types like + ButtonStyle, TextStyle, Padding, Border, Theme, ColorScheme, etc. + + Args: + name: Type class name (e.g. "ButtonStyle", "Padding", "TextStyle"). + """ + store = _get_api_store() + result = store.get_type(name) + if result is None: + return {"error": f"Type '{name}' not found"} + return result + + +@mcp.tool() +def get_enum(name: str) -> dict: + """Get enum definition. + + For small enums (< 50 members), returns all members. + For large enums (Icons, CupertinoIcons), returns metadata and sample members. + Use search_enum_members() for large enum lookup. + + Args: + name: Enum class name (e.g. "TextAlign", "Icons", "CupertinoIcons"). + """ + store = _get_api_store() + result = store.get_enum(name) + if result is None: + return {"error": f"Enum '{name}' not found"} + return result + + +# ── Enum Search ─────────────────────────────────────────────────────────── + + +@mcp.tool() +def search_enum_members(name: str, query: str, limit: int = 10) -> dict: + """Search enum members by name pattern. + + Useful for large enums like Icons and CupertinoIcons. + + Args: + name: Enum class name (e.g. "Icons", "CupertinoIcons"). + query: Search pattern (e.g. "arrow", "settings", "home"). + limit: Maximum number of results to return. + """ + store = _get_api_store() + members = store.search_enum_members(name, query, limit=limit) + return {"enum": name, "matches": members} + + +@mcp.tool() +def enum_has_member(name: str, member: str) -> dict: + """Check if an enum has a specific member. + + Use this to verify that an enum value exists before using it in code. + + Args: + name: Enum class name (e.g. "Icons", "TextAlign"). + member: Member name to check (e.g. "ARROW_BACK", "CENTER"). + """ + store = _get_api_store() + return { + "enum": name, + "member": member, + "exists": store.enum_has_member(name, member), + } + + +# ── Icons ───────────────────────────────────────────────────────────────── + + +@mcp.tool() +def find_icon(query: str, family: Optional[str] = None, limit: int = 10) -> dict: + """Search Material and Cupertino icons by keyword. + + Supports synonym matching (e.g. "user" finds "account_circle"). + + Args: + query: Descriptive keywords (e.g. "back arrow", "settings", "user profile"). + family: Optional icon family filter - "material" or "cupertino". + limit: Maximum number of results to return. + """ + store = _get_icon_store() + return {"icons": store.find(query, family=family, limit=limit)} + + +# ── CLI Help ────────────────────────────────────────────────────────────── + + +@mcp.tool() +def get_cli_help(command: Optional[str] = None) -> dict: + """Get Flet CLI usage and options. + + Without arguments, returns an overview of all available commands. + With a command name, returns detailed flags and options. + + Args: + command: Optional command name (e.g. "run", "build", "publish", "create"). + """ + store = _get_api_store() + result = store.get_cli_help(command) + if command is None: + return {"commands": result} + if result is None: + return {"error": f"Unknown command: {command}"} + return result diff --git a/sdk/python/packages/flet/src/flet/controls/base_control.py b/sdk/python/packages/flet/src/flet/controls/base_control.py index 2a0a41c74d..6cb77a05d7 100644 --- a/sdk/python/packages/flet/src/flet/controls/base_control.py +++ b/sdk/python/packages/flet/src/flet/controls/base_control.py @@ -62,6 +62,8 @@ def control( *, isolated: Optional[bool] = None, post_init_args: int = 1, + categories: Optional[tuple[str, ...]] = None, + tags: Optional[tuple[str, ...]] = None, **dataclass_kwargs: Any, ) -> Callable[[type[T]], type[T]]: """ @@ -79,6 +81,8 @@ def control( *, isolated: Optional[bool] = None, post_init_args: int = 1, + categories: Optional[tuple[str, ...]] = None, + tags: Optional[tuple[str, ...]] = None, **dataclass_kwargs: Any, ) -> Union[type[T], Callable[[type[T]], type[T]]]: """ @@ -90,6 +94,8 @@ def control( isolated: If `True`, marks the control as isolated. An isolated control is excluded from page updates when its parent control is updated. post_init_args: Number of InitVar arguments to pass to __post_init__. + categories: MCP metadata — control categories (e.g. ``("input", "form")``). + tags: MCP metadata — descriptive tags (e.g. ``("text", "editable")``). dataclass_kwargs: Additional keyword arguments passed to `@dataclass`. Usage: @@ -97,18 +103,32 @@ def control( - Supports `@control("WidgetName")` (with optional arguments) - Supports `@control("WidgetName", post_init_args=1, isolated=True)` to specify the number of `InitVar` arguments and isolation + - Supports `@control("WidgetName", categories=("input",), tags=("text",))` + to add MCP metadata for discovery """ # Case 1: If used as `@control` (without parentheses) if isinstance(dart_widget_name, type): return _apply_control( - dart_widget_name, None, isolated, post_init_args, **dataclass_kwargs + dart_widget_name, + None, + isolated, + post_init_args, + categories=categories, + tags=tags, + **dataclass_kwargs, ) # Case 2: If used as `@control("custom_type", post_init_args=N, isolated=True)` def wrapper(cls: type[T]) -> type[T]: return _apply_control( - cls, dart_widget_name, isolated, post_init_args, **dataclass_kwargs + cls, + dart_widget_name, + isolated, + post_init_args, + categories=categories, + tags=tags, + **dataclass_kwargs, ) return wrapper @@ -119,11 +139,19 @@ def _apply_control( type_name: Optional[str], isolated: Optional[bool], post_init_args: int, + categories: Optional[tuple[str, ...]] = None, + tags: Optional[tuple[str, ...]] = None, **dataclass_kwargs, ) -> type[T]: """Applies @control logic, ensuring compatibility with @dataclass.""" cls = dataclass(**dataclass_kwargs)(cls) # Apply @dataclass first + # Store MCP metadata for discovery by Flet MCP server + cls.__control_meta__ = { + "categories": tuple(categories) if categories else (), + "tags": tuple(tags) if tags else (), + } + orig_post_init = getattr(cls, "__post_init__", lambda self, *args: None) def new_post_init(self: T, *args): diff --git a/sdk/python/packages/flet/src/flet/controls/core/column.py b/sdk/python/packages/flet/src/flet/controls/core/column.py index 64c537afc8..ae04c572db 100644 --- a/sdk/python/packages/flet/src/flet/controls/core/column.py +++ b/sdk/python/packages/flet/src/flet/controls/core/column.py @@ -10,7 +10,7 @@ __all__ = ["Column"] -@control("Column") +@control("Column", categories=("layout",), tags=("vertical", "flex", "scrollable")) class Column(LayoutControl, ScrollableControl, AdaptiveControl): """ Arranges child controls vertically, optionally aligning and spacing them within \ diff --git a/sdk/python/packages/flet/src/flet/controls/core/row.py b/sdk/python/packages/flet/src/flet/controls/core/row.py index ab2c2724ec..de85293aa7 100644 --- a/sdk/python/packages/flet/src/flet/controls/core/row.py +++ b/sdk/python/packages/flet/src/flet/controls/core/row.py @@ -10,7 +10,7 @@ __all__ = ["Row"] -@control("Row") +@control("Row", categories=("layout",), tags=("horizontal", "flex", "scrollable")) class Row(LayoutControl, ScrollableControl, AdaptiveControl): """ Displays its children in a horizontal array. diff --git a/sdk/python/packages/flet/src/flet/controls/material/checkbox.py b/sdk/python/packages/flet/src/flet/controls/material/checkbox.py index fbcbd8ca02..4c60116a5a 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/checkbox.py +++ b/sdk/python/packages/flet/src/flet/controls/material/checkbox.py @@ -20,7 +20,7 @@ __all__ = ["Checkbox"] -@control("Checkbox") +@control("Checkbox", categories=("input", "selection"), tags=("form", "toggle")) class Checkbox(LayoutControl, AdaptiveControl): """ Checkbox allows to select one or more items from a group, or switch between two \ diff --git a/sdk/python/packages/flet/src/flet/controls/material/tabs.py b/sdk/python/packages/flet/src/flet/controls/material/tabs.py index fd5864618e..cc33bbc9b1 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/tabs.py +++ b/sdk/python/packages/flet/src/flet/controls/material/tabs.py @@ -154,7 +154,7 @@ class UnderlineTabIndicator: """ -@control("Tabs") +@control("Tabs", categories=("navigation", "layout"), tags=("tabbed", "switcher")) class Tabs(LayoutControl, AdaptiveControl): """ Used for navigating frequently accessed, distinct content categories. Tabs allow \ diff --git a/sdk/python/packages/flet/src/flet/controls/material/textfield.py b/sdk/python/packages/flet/src/flet/controls/material/textfield.py index 751afeaac1..811f8ee72c 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/textfield.py +++ b/sdk/python/packages/flet/src/flet/controls/material/textfield.py @@ -268,7 +268,9 @@ def __init__(self): super().__init__(regex_string=r"^[a-zA-Z]*$", allow=True, replacement_string="") -@control("TextField") +@control( + "TextField", categories=("input", "form"), tags=("text", "editable", "validation") +) class TextField(FormFieldControl, AdaptiveControl): """ A text field lets the user enter text, either with hardware keyboard or with an \ diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 94f03f5f98..61c0dbf627 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "flet-geolocator", "flet-lottie", "flet-map", + "flet-mcp", "flet-permission-handler", "flet-rive", "flet-secure-storage", @@ -41,19 +42,21 @@ flet-audio = { workspace = true } flet-audio-recorder = { workspace = true } flet-camera = { workspace = true } flet-charts = { workspace = true } +flet-code-editor = { workspace = true } +flet-color-pickers = { workspace = true } flet-datatable2 = { workspace = true } flet-flashlight = { workspace = true } flet-geolocator = { workspace = true } flet-lottie = { workspace = true } flet-map = { workspace = true } +flet-mcp = { workspace = true } flet-permission-handler = { workspace = true } flet-rive = { workspace = true } flet-secure-storage = { workspace = true } flet-video = { workspace = true } flet-webview = { workspace = true } -flet-code-editor = { workspace = true } mkdocs-external-images = { git = "https://github.com/flet-dev/mkdocs-external-images", tag = "v0.2.0" } -flet-color-pickers = { workspace = true } + [tool.uv.workspace] members = ["packages/*"] @@ -103,6 +106,10 @@ docs = [ "packaging >=23.0", { include-group = 'docs-coverage' }, ] +mcp-build = [ + "markdownify >=0.14.1", + "griffe >=1.6.2", +] all = [ { include-group = 'dev' }, { include-group = 'docs' }, @@ -136,11 +143,12 @@ isort = { known-first-party = [ "flet_geolocator", "flet_lottie", "flet_map", + "flet_mcp", "flet_permission_handler", "flet_rive", "flet_secure_storage", "flet_video", - "flet_webview" + "flet_webview", ] } preview = true select = [