diff --git a/CHANGELOG.md b/CHANGELOG.md index 8917059..b9ffacc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +#### šŸ”Œ MCP Server +- **Rebuilt `mcp-server/` on [FastMCP](https://gofastmcp.com) 3.x** (up from the low-level `mcp` SDK), replacing ~15 hand-written tool wrappers with 27 tools generated dynamically from `tryon.cli.registry` -- every model reachable via the `opentryon` CLI (Kimi, FLUX VTO/2, GPT Image, Sora, Veo, Nano Banana, BEN2, etc.) is now automatically exposed as an MCP tool, and new registry entries need zero MCP-server changes to show up +- New `tryon.cli.runner.invoke_model()`: a kwargs-based, non-argv equivalent of the CLI's `run_service()`, shared by both the CLI and the MCP server so they can never drift apart; always returns a structured `{"success": ...}` dict instead of raising +- Two discovery tools, `list_opentryon_tools` and `opentryon_status`, report live per-model configuration/readiness straight from the registry and the loaded `.env` +- Every generated tool supports `dry_run` (preview the resolved adapter call, no API/GPU cost) and `output_dir`, matching the CLI's `--dry-run`/`--output-dir` flags +- `mcp-server/test_server.py`: offline test suite covering tool/registry parity, schema generation (required fields, `choices` -> enum), dry-run calls across all six services, and `alt_method_on_image` switching (veo/sora/luma-video) + #### šŸ–„ļø Unified CLI - **`opentryon` command-line interface**: Installable console script exposing every adapter through `opentryon --model [params...]` (services: `vton`, `generate`, `edit`, `understand`, `video-generate`, `bg-remove`) - Data-driven model registry (`tryon/cli/registry.py`) with two-stage argument parsing, `--dry-run`, and automatic `local`-extra detection diff --git a/README.md b/README.md index 4750bbc..e86f92b 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ OpenTryOn is an open-source AI toolkit designed for fashion technology and virtu - [Installation](#installation) - [Quick Start](#quick-start) - [Unified CLI (`opentryon`)](#unified-cli-opentryon) +- [MCP Server](#-mcp-server) - [Usage](#usage) - [Datasets Module](#datasets-module) - [Virtual Try-On with Amazon Nova Canvas](#virtual-try-on-with-amazon-nova-canvas) @@ -299,6 +300,18 @@ adapter (all models above except `flux2-turbo`, `llava-next`, `kimi-vl`, and `pip install opentryon[local]` and, for GPU-accelerated inference, a CUDA-capable GPU. +## šŸ”Œ MCP Server + +Every model above is also available as an [MCP](https://modelcontextprotocol.io) tool, so agents in Claude Desktop, Cursor, or any other MCP client can call them directly. The server is built on [FastMCP](https://gofastmcp.com) and generates one tool per (service, model) straight from the same registry that powers the CLI -- so it's always up to date with every model in this table. + +```bash +cd mcp-server +pip install -r requirements.txt +python server.py # stdio transport, ready for Claude Desktop / Cursor +``` + +See [`mcp-server/README.md`](mcp-server/README.md) for the full tool list, client configuration examples, and architecture notes. + ## šŸ“– Usage ### Datasets Module diff --git a/mcp-server/README.md b/mcp-server/README.md new file mode 100644 index 0000000..4703bf8 --- /dev/null +++ b/mcp-server/README.md @@ -0,0 +1,204 @@ +# OpenTryOn MCP Server + +An [MCP](https://modelcontextprotocol.io) (Model Context Protocol) server that exposes every model in the [`opentryon`](../README.md) toolkit -- virtual try-on, image/video generation & editing, multimodal image & video understanding, and background removal -- as tools an LLM agent (Claude, Cursor, ChatGPT, or any MCP client) can call directly. + +Built on [FastMCP](https://gofastmcp.com) 3.x, the actively-maintained, high-level Python framework for MCP servers (FastMCP 1.0 was folded into the official MCP Python SDK in 2024; this server uses the standalone FastMCP 2/3 project, which adds streamable-HTTP transport, better auth, and much less boilerplate). + +## Why this is registry-driven + +Every tool below is generated **dynamically** at import time from `tryon.cli.registry.SERVICES` -- the exact same data-driven registry that powers the `opentryon` CLI (see [`getting-started/cli.md`](../docs/docs/getting-started/cli.md)). There is no hand-written per-model wrapper function and no JSON schema to maintain by hand. + +This means: + +- **New models require zero MCP-server changes.** Add a model to `tryon/cli/registry.py` (see [`advanced/new-model-checklist.md`](../docs/docs/advanced/new-model-checklist.md)) and it instantly appears here as a new tool, with an accurate schema, the next time the server starts. +- **The CLI and the MCP server can never drift apart.** Both call the same `tryon.cli.runner.invoke_model()` execution path. +- Tool schemas (required/optional fields, enums for `choices`, per-field descriptions) are generated straight from each model's `Arg` definitions via Python's `inspect` + Pydantic, not duplicated JSON. + +## Installation + +```bash +cd opentryon +pip install -e . # core (API-backed) models +# or, to also enable local/GPU models (llava-next, kimi-vl, flux2-turbo, ben2): +pip install -e ".[local]" + +cd mcp-server +pip install -r requirements.txt +``` + +Copy `env.template` (repo root) to `.env` and fill in whichever API keys you plan to use -- the server (and every adapter) reads that same `.env` file, so no separate MCP-specific secret handling is needed. + +## Running + +```bash +# stdio transport (what Claude Desktop / Cursor / most MCP clients expect) +python server.py + +# streamable-HTTP transport, for remote/network clients +python server.py --transport http --host 0.0.0.0 --port 8000 +``` + +On startup the server prints a configuration status report to stderr (which API keys are set, which models are ready) -- the same information the `opentryon_status` tool returns at runtime. + +### Claude Desktop + +Add to `claude_desktop_config.json` (see [`examples/claude_desktop_config.json`](examples/claude_desktop_config.json)): + +```json +{ + "mcpServers": { + "opentryon": { + "command": "python", + "args": ["/absolute/path/to/opentryon/mcp-server/server.py"] + } + } +} +``` + +### Cursor + +Add to `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global) -- see [`examples/cursor_mcp_config.json`](examples/cursor_mcp_config.json): + +```json +{ + "mcpServers": { + "opentryon": { + "command": "python", + "args": ["/absolute/path/to/opentryon/mcp-server/server.py"] + } + } +} +``` + +### Programmatically (FastMCP client) + +See [`examples/example_usage.py`](examples/example_usage.py) for a full walkthrough. Quick version: + +```python +import asyncio +from fastmcp import Client + +async def main(): + async with Client("server.py") as client: + result = await client.call_tool("vton_flux_vto", { + "person": "model.jpg", + "garment": "garment.jpg", + "dry_run": True, # preview the call, spend no API credits + }) + print(result.data) + +asyncio.run(main()) +``` + +## Discovery tools + +Two meta tools are always available regardless of what's configured: + +- **`list_opentryon_tools(service=None)`** -- lists every service/model combination, its MCP tool name, which env var(s) it needs, and whether it's currently configured. Call this first. +- **`opentryon_status()`** -- the same human-readable status report printed on startup. + +## Every generated tool accepts + +- All of the model's own parameters (mirrors `opentryon --model --help` exactly), with the same required/optional-ness, defaults, and choice/enum constraints as the CLI. +- **`dry_run`** (bool, default `false`) -- preview the resolved adapter call (`ClassName(**init_kwargs).method(**call_kwargs)`) without hitting any API, GPU, or network. +- **`output_dir`** (str, default `"outputs"`) -- where to save any resulting images/video/JSON. + +Every tool returns a structured dict: `{"success": true/false, ...}` -- never raises, so an LLM caller always gets a clean result to reason about instead of a stack trace. + +## Available tools (27 models across 6 services) + +### vton -- Virtual try-on: compose a garment onto a person image + +| Tool | Model | Requires | +|---|---|---| +| `vton_flux_vto` | Black Forest Labs FLUX VTO | `BFL_API_KEY` | +| `vton_nova_canvas` | Amazon Nova Canvas | `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | +| `vton_kling_ai` | Kling AI (Kolors Virtual Try-On) | `KLING_AI_API_KEY` / `KLING_AI_SECRET_KEY` | +| `vton_segmind` | Segmind Try-On Diffusion | `SEGMIND_API_KEY` | + +### generate -- Text-to-image generation + +| Tool | Model | Requires | +|---|---|---| +| `generate_nano_banana` | Nano Banana (Gemini 2.5 Flash Image) | `GEMINI_API_KEY` | +| `generate_nano_banana_pro` | Nano Banana Pro (Gemini 3 Pro Image Preview) | `GEMINI_API_KEY` | +| `generate_nano_banana_2` | Nano Banana 2 (Gemini 3.1 Flash Image) | `GEMINI_API_KEY` | +| `generate_flux2_pro` | FLUX.2 [pro] | `BFL_API_KEY` | +| `generate_flux2_flex` | FLUX.2 [flex] | `BFL_API_KEY` | +| `generate_flux2_turbo` | FLUX.2-dev Turbo (local, 8-step) | local/GPU | +| `generate_gpt_image` | OpenAI GPT Image | `OPENAI_API_KEY` | +| `generate_luma_image` | Luma Photon | `LUMA_AI_API_KEY` | + +### edit -- Image editing (image + instruction -> image) + +| Tool | Model | Requires | +|---|---|---| +| `edit_nano_banana` | Nano Banana (Gemini 2.5 Flash Image) | `GEMINI_API_KEY` | +| `edit_nano_banana_pro` | Nano Banana Pro | `GEMINI_API_KEY` | +| `edit_nano_banana_2` | Nano Banana 2 | `GEMINI_API_KEY` | +| `edit_flux2_pro` | FLUX.2 [pro] | `BFL_API_KEY` | +| `edit_flux2_flex` | FLUX.2 [flex] | `BFL_API_KEY` | +| `edit_flux2_turbo` | FLUX.2-dev Turbo (local, image-to-image) | local/GPU | +| `edit_gpt_image` | OpenAI GPT Image | `OPENAI_API_KEY` | + +### understand -- Image & video understanding / captioning + +| Tool | Model | Requires | +|---|---|---| +| `understand_llava_next` | LLaVA-NeXT (local VLM captioning) | local/GPU | +| `understand_kimi_k2_6` | Kimi K2.6 (Moonshot AI multimodal understanding) | `MOONSHOT_API_KEY` | +| `understand_kimi_k2_7_code` | Kimi K2.7 Code (coding + multimodal understanding) | `MOONSHOT_API_KEY` | +| `understand_kimi_vl` | Kimi-VL (open-weight, local) | local/GPU | + +### video-generate -- Text/image-to-video generation + +| Tool | Model | Requires | +|---|---|---| +| `video_generate_veo` | Google Veo | `GEMINI_API_KEY` | +| `video_generate_sora` | OpenAI Sora | `OPENAI_API_KEY` | +| `video_generate_luma_video` | Luma Dream Machine | `LUMA_AI_API_KEY` | + +### bg-remove -- Background removal + +| Tool | Model | Requires | +|---|---|---| +| `bg_remove_ben2` | BEN2 background remover (local) | local/GPU | + +Run `list_opentryon_tools()` at any time for the live, authoritative version of this table (including per-model parameter docs) plus real-time configuration status. + +## Architecture + +``` +mcp-server/ +ā”œā”€ā”€ server.py # FastMCP app; builds one tool per (service, model) from the registry +ā”œā”€ā”€ config.py # .env loading + registry-driven readiness/status report +ā”œā”€ā”€ pyproject.toml +ā”œā”€ā”€ requirements.txt +ā”œā”€ā”€ test_server.py # offline dry-run tests (no pytest, no network/API keys needed) +└── examples/ + ā”œā”€ā”€ claude_desktop_config.json + ā”œā”€ā”€ cursor_mcp_config.json + └── example_usage.py +``` + +`server.py`'s core trick (`_build_tool_fn`): for each `ModelSpec` in the registry, it builds a real Python function whose `inspect.signature()` has one keyword-only parameter per `Arg` (correct type -- `str`/`int`/`float`/`List[str]`/`Literal[...]` for `choices`/`bool` for flags -- and correct required-ness/default), then registers it with `@mcp.tool`. FastMCP inspects that signature to generate the tool's JSON schema, so the schema is always in sync with the registry with no manual duplication. The function body itself is a one-liner that forwards everything to `tryon.cli.runner.invoke_model(service, model, **kwargs)` -- the same execution path `opentryon --model ` uses on the CLI. + +## Testing + +```bash +python test_server.py +``` + +Checks (all offline, no API keys or GPU required): +- every registry (service, model) has a corresponding MCP tool, and vice versa +- generated schemas have the right required/optional fields and turn `choices` into JSON schema enums +- representative `dry_run` calls resolve to the expected adapter call across services (vton, generate, edit, understand, video-generate, bg-remove) +- `alt_method_on_image` models (veo/sora/luma-video) switch from text-to-video to image-to-video only when an image is supplied +- unknown service/model and missing-local-extra cases return structured errors instead of raising +- the two discovery tools (`list_opentryon_tools`, `opentryon_status`) work and reject invalid input + +## Troubleshooting + +- **"requires local ML dependencies that aren't installed"** -- run `pip install opentryon[local]` in the same environment the server runs in, and make sure you have a CUDA GPU for the local models that need one. +- **A tool call returns `{"success": false, "error": "..."}`** -- this is by design: adapter/API errors are caught and returned as structured data rather than crashing the MCP connection. Check `error` (and `traceback` for real failures) for details, or call `opentryon_status` to check whether the required API key is set. +- **Tool not appearing** -- restart the server after editing `.env` or the registry; tools are built once at import time. diff --git a/mcp-server/config.py b/mcp-server/config.py new file mode 100644 index 0000000..7cc43e9 --- /dev/null +++ b/mcp-server/config.py @@ -0,0 +1,83 @@ +"""Environment configuration for the OpenTryOn MCP server. + +Loads the parent repo's ``.env`` (same file the ``opentryon`` CLI and the +adapters themselves read via ``python-dotenv``/``os.getenv``) and derives a +readiness report straight from :data:`tryon.cli.registry.SERVICES`, so this +never drifts out of sync with which models actually need which keys. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any, Dict, Optional + +from dotenv import load_dotenv + +_PARENT_DIR = Path(__file__).resolve().parent.parent +_ENV_PATH = _PARENT_DIR / ".env" +if _ENV_PATH.exists(): + load_dotenv(_ENV_PATH) + + +def is_configured(env_hint: Optional[str]) -> Optional[bool]: + """Return True/False for whether the env var(s) in ``env_hint`` (e.g. + ``"AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY"``) are all set, or None if + the model needs no API key at all (local/open-weight models).""" + if not env_hint: + return None + names = [v.strip() for v in env_hint.split("/")] + return all(os.getenv(name) for name in names) + + +def status_report() -> Dict[str, Any]: + """Build a ``{service: {model: {...}}}`` readiness map straight from the + registry.""" + from tryon.cli.registry import SERVICES + + report: Dict[str, Any] = {} + for service, models in SERVICES.items(): + report[service] = {} + for model_id, spec in models.items(): + report[service][model_id] = { + "label": spec.label, + "requires_env": spec.env_hint, + "configured": is_configured(spec.env_hint), + "runs_locally": spec.extra == "local", + } + return report + + +def status_message() -> str: + """Human-readable configuration summary, printed to stderr on server + startup and returned by the ``opentryon_status`` MCP tool.""" + report = status_report() + + lines = ["OpenTryOn MCP Server Configuration Status:", ""] + ready = 0 + total_api = 0 + local_count = 0 + + for service, models in report.items(): + lines.append(f"[{service}]") + for model_id, info in models.items(): + if info["runs_locally"]: + local_count += 1 + lines.append(f" {model_id:<20} local (no API key needed)") + continue + total_api += 1 + if info["configured"]: + ready += 1 + mark = "\u2713 configured" + else: + mark = f"\u2717 missing {info['requires_env']}" + lines.append(f" {model_id:<20} {mark}") + lines.append("") + + lines.append(f"API-backed models ready: {ready}/{total_api}") + lines.append(f"Local models available (need `pip install opentryon[local]` + GPU): {local_count}") + if ready == 0: + lines.append("") + lines.append("\u26a0\ufe0f No API keys configured yet.") + lines.append(" Copy env.template to .env in the repo root and add your API keys.") + return "\n".join(lines) diff --git a/mcp-server/examples/claude_desktop_config.json b/mcp-server/examples/claude_desktop_config.json new file mode 100644 index 0000000..ae237eb --- /dev/null +++ b/mcp-server/examples/claude_desktop_config.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "opentryon": { + "command": "python", + "args": ["/absolute/path/to/opentryon/mcp-server/server.py"], + "env": { + "PYTHONUNBUFFERED": "1" + } + } + } +} diff --git a/mcp-server/examples/cursor_mcp_config.json b/mcp-server/examples/cursor_mcp_config.json new file mode 100644 index 0000000..89ec257 --- /dev/null +++ b/mcp-server/examples/cursor_mcp_config.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "opentryon": { + "command": "python", + "args": ["/absolute/path/to/opentryon/mcp-server/server.py"] + } + } +} diff --git a/mcp-server/examples/example_usage.py b/mcp-server/examples/example_usage.py new file mode 100644 index 0000000..ec52b5e --- /dev/null +++ b/mcp-server/examples/example_usage.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Example: talk to the OpenTryOn MCP server programmatically with the +FastMCP client, without going through Claude Desktop / Cursor. + +Run from the ``mcp-server`` directory: + + python examples/example_usage.py +""" + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path + +from fastmcp import Client + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + + +async def main() -> None: + # Connecting with the server module path spawns `server.py` as a + # subprocess over stdio -- the same way Claude Desktop/Cursor do it. + server_path = Path(__file__).resolve().parent.parent / "server.py" + + async with Client(str(server_path)) as client: + # 1. Discover what's available and what's configured. + status = await client.call_tool("opentryon_status", {}) + print("=== opentryon_status ===") + print(status.data) + + tools = await client.list_tools() + print(f"\n{len(tools)} tools available, e.g.:") + for t in tools[:5]: + print(f" - {t.name}: {t.description.splitlines()[0]}") + + # 2. Preview a call with dry_run=True (no API credits spent). + print("\n=== vton_flux_vto (dry_run) ===") + result = await client.call_tool( + "vton_flux_vto", + { + "person": "https://example.com/model.jpg", + "garment": "https://example.com/garment.jpg", + "dry_run": True, + }, + ) + print(json.dumps(result.data, indent=2)) + + # 3. A real call (requires MOONSHOT_API_KEY in the repo's .env). + print("\n=== understand_kimi_k2_6 (dry_run) ===") + result = await client.call_tool( + "understand_kimi_k2_6", + { + "image": "https://example.com/outfit.jpg", + "prompt": "Describe this outfit in detail.", + "dry_run": True, + }, + ) + print(json.dumps(result.data, indent=2)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/mcp-server/pyproject.toml b/mcp-server/pyproject.toml new file mode 100644 index 0000000..ff63156 --- /dev/null +++ b/mcp-server/pyproject.toml @@ -0,0 +1,40 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "opentryon-mcp-server" +version = "0.1.0" +description = "MCP server exposing OpenTryOn's models (virtual try-on, image/video generation & editing, multimodal understanding, background removal) as tools for LLM agents" +authors = [ + {name = "TryOn Labs", email = "contact@tryonlabs.ai"} +] +readme = "README.md" +requires-python = ">=3.10" +license = {text = "CC-BY-NC-4.0"} +keywords = ["mcp", "fastmcp", "model-context-protocol", "fashion", "virtual-try-on", "ai", "agents"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] + +dependencies = [ + "fastmcp>=3.4.0", + "python-dotenv>=1.0.0", +] + +[project.optional-dependencies] +local = ["opentryon[local]"] + +[project.urls] +Homepage = "https://github.com/tryonlabs/opentryon" +Documentation = "https://tryonlabs.github.io/opentryon/" +Repository = "https://github.com/tryonlabs/opentryon" +Issues = "https://github.com/tryonlabs/opentryon/issues" + +[project.scripts] +opentryon-mcp = "server:main" diff --git a/mcp-server/requirements.txt b/mcp-server/requirements.txt new file mode 100644 index 0000000..9688529 --- /dev/null +++ b/mcp-server/requirements.txt @@ -0,0 +1,9 @@ +# MCP Server Dependencies +fastmcp>=3.4.0 +python-dotenv>=1.0.0 + +# OpenTryOn is installed from the parent directory. +# Core (API-backed) models only: +# pip install -e .. +# To also enable local/GPU models (llava-next, kimi-vl, flux2-turbo, ben2): +# pip install -e "..[local]" diff --git a/mcp-server/server.py b/mcp-server/server.py new file mode 100644 index 0000000..b7254df --- /dev/null +++ b/mcp-server/server.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +"""OpenTryOn MCP Server. + +Exposes every ``opentryon --model `` combination as an MCP +tool, built on `FastMCP `_ (>= 3.4). + +Tools are generated **dynamically** from :data:`tryon.cli.registry.SERVICES` +-- the same data-driven registry that powers the ``opentryon`` CLI -- rather +than hand-written one at a time. That means every model reachable via +``opentryon --model ...`` (virtual try-on, image +generation/editing, image & video understanding, video generation, +background removal -- including Kimi, FLUX VTO/2, GPT Image, Sora, Veo, +Nano Banana, BEN2, etc.) is automatically exposed here too, and any new +model added to the registry shows up as an MCP tool with zero changes to +this file. See ``docs/docs/advanced/new-model-checklist.md`` in the parent +repo for how to add a new model. + +Usage +----- + # stdio transport (default -- what Claude Desktop / Cursor expect) + python server.py + + # streamable-http transport, for remote clients + python server.py --transport http --host 0.0.0.0 --port 8000 +""" + +from __future__ import annotations + +import argparse +import inspect +import sys +from pathlib import Path +from typing import Any, Dict, List, Literal, Optional + +# Allow running this file directly (``python mcp-server/server.py``) without +# having run ``pip install -e ..`` first. +_REPO_ROOT = Path(__file__).resolve().parent.parent +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) +if str(Path(__file__).resolve().parent) not in sys.path: + sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from fastmcp import FastMCP + +import config +from tryon.cli.registry import Arg, ModelSpec, SERVICE_HELP, SERVICES +from tryon.cli.runner import invoke_model + +__version__ = "0.1.0" + + +mcp = FastMCP( + name="opentryon", + version=__version__, + instructions=( + "OpenTryOn is an open-source AI toolkit for fashion tech, virtual " + "try-on, and general multimodal understanding. Every tool here maps " + "1:1 to `opentryon --model ` from the opentryon " + "CLI and is named '_' (dashes/dots become " + "underscores), e.g. 'vton_flux_vto', 'understand_kimi_k2_6', " + "'generate_nano_banana_pro', 'video_generate_veo'. " + "Call `list_opentryon_tools` first to discover every available " + "service/model combination, which environment variable(s) each one " + "needs, and whether it is already configured in this environment. " + "Every generated tool accepts a `dry_run` boolean to preview the " + "resolved adapter call without spending API credits or GPU time, " + "and an `output_dir` string (default 'outputs') for where to save " + "any resulting images/video/JSON." + ), +) + + +# -------------------------------------------------------------------------- +# Registry Arg -> dynamic Python function signature +# -------------------------------------------------------------------------- + +def _annotation_for(arg: Arg) -> Any: + """Map a registry ``Arg`` to a Python type annotation suitable for + building an MCP JSON schema (enums via ``Literal``, lists via + ``nargs``, optionality via ``required``).""" + if arg.action in ("store_true", "store_false"): + return bool + + base: Any = {int: int, float: float}.get(arg.type, str) + + if arg.nargs in ("+", "*"): + base = List[base] + + if arg.choices: + try: + base = Literal[tuple(arg.choices)] # type: ignore[valid-type] + except TypeError: + pass # non-hashable choices -- fall back to the plain base type + + if not arg.required: + base = Optional[base] + + return base + + +def _default_for(arg: Arg) -> Any: + if arg.action == "store_true": + return arg.default if arg.default is not None else False + if arg.action == "store_false": + return arg.default if arg.default is not None else True + return arg.default + + +def _tool_name(service: str, model_id: str) -> str: + return f"{service}_{model_id}".replace("-", "_").replace(".", "_") + + +def _build_docstring(spec: ModelSpec) -> str: + lines = [f"{spec.label}."] + if spec.notes: + lines.append(spec.notes) + if spec.env_hint: + lines.append(f"Requires environment variable(s): {spec.env_hint}.") + if spec.extra == "local": + lines.append( + "Runs locally on this machine's GPU/CPU; requires " + "`pip install opentryon[local]`." + ) + lines.append("") + for arg in spec.args: + if arg.help: + lines.append(f":param {arg.dest}: {arg.help}") + lines.append( + ":param output_dir: Directory to save output artifacts " + "(images/video/json) to. Default: 'outputs'." + ) + lines.append( + ":param dry_run: If true, return the resolved adapter call without " + "invoking the API/GPU/network. Default: false." + ) + return "\n".join(lines) + + +def _build_tool_fn(service: str, model_id: str, spec: ModelSpec): + """Build a function whose *real* ``inspect.signature()`` has one + keyword-only parameter per registry ``Arg`` (plus ``output_dir`` / + ``dry_run``), so FastMCP generates an accurate, flat JSON schema + straight from introspection -- no per-model wrapper needed. + """ + params: List[inspect.Parameter] = [] + for arg in spec.args: + annotation = _annotation_for(arg) + if arg.required: + params.append( + inspect.Parameter(arg.dest, inspect.Parameter.KEYWORD_ONLY, annotation=annotation) + ) + else: + params.append( + inspect.Parameter( + arg.dest, + inspect.Parameter.KEYWORD_ONLY, + annotation=annotation, + default=_default_for(arg), + ) + ) + + params.append( + inspect.Parameter("output_dir", inspect.Parameter.KEYWORD_ONLY, annotation=str, default="outputs") + ) + params.append( + inspect.Parameter("dry_run", inspect.Parameter.KEYWORD_ONLY, annotation=bool, default=False) + ) + + signature = inspect.Signature(params, return_annotation=Dict[str, Any]) + + async def _tool(**kwargs: Any) -> Dict[str, Any]: + return invoke_model(service, model_id, **kwargs) + + _tool.__signature__ = signature # type: ignore[attr-defined] + _tool.__name__ = _tool_name(service, model_id) + _tool.__annotations__ = {p.name: p.annotation for p in params} + _tool.__annotations__["return"] = Dict[str, Any] + _tool.__doc__ = _build_docstring(spec) + return _tool + + +def register_model_tools(app: FastMCP) -> int: + """Register one MCP tool per (service, model) in the registry. Returns + the number of tools registered.""" + count = 0 + for service, models in SERVICES.items(): + for model_id, spec in models.items(): + fn = _build_tool_fn(service, model_id, spec) + app.tool(name=_tool_name(service, model_id))(fn) + count += 1 + return count + + +# -------------------------------------------------------------------------- +# Discovery / meta tools +# -------------------------------------------------------------------------- + +@mcp.tool +def list_opentryon_tools(service: Optional[str] = None) -> Dict[str, Any]: + """List every OpenTryOn service/model combination available as an MCP + tool, along with which environment variable(s) each one needs and + whether it is currently configured in this server's environment. + + Call this first to decide which tool to use for a task, and to check + readiness before attempting a real (non dry-run) call. + + :param service: Optional filter, one of: vton, generate, edit, + understand, video-generate, bg-remove. Omit to list all services. + """ + if service and service not in SERVICES: + return { + "success": False, + "error": f"Unknown service '{service}'. Available: {', '.join(SERVICES)}", + } + services = {service: SERVICES[service]} if service else SERVICES + + result: Dict[str, Any] = {"services": {}} + for svc, models in services.items(): + result["services"][svc] = { + "description": SERVICE_HELP.get(svc, ""), + "models": { + model_id: { + "tool_name": _tool_name(svc, model_id), + "label": spec.label, + "notes": spec.notes, + "requires_env": spec.env_hint, + "configured": config.is_configured(spec.env_hint), + "runs_locally": spec.extra == "local", + } + for model_id, spec in models.items() + }, + } + return result + + +@mcp.tool +def opentryon_status() -> str: + """Human-readable configuration status: which API keys are set, which + services/models are ready to use, and setup hints for anything missing. + """ + return config.status_message() + + +TOOL_COUNT = register_model_tools(mcp) + + +def _build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="OpenTryOn MCP server") + parser.add_argument( + "--transport", + choices=["stdio", "http", "sse"], + default="stdio", + help="MCP transport to serve on. Default: stdio (for Claude Desktop / Cursor).", + ) + parser.add_argument("--host", default="127.0.0.1", help="Host to bind to (http/sse transports only)") + parser.add_argument("--port", type=int, default=8000, help="Port to bind to (http/sse transports only)") + return parser + + +def main() -> None: + args = _build_arg_parser().parse_args() + + print(config.status_message(), file=sys.stderr) + print(f"\nStarting OpenTryOn MCP Server ({TOOL_COUNT} model tools + 2 discovery tools)...", file=sys.stderr) + + if args.transport == "stdio": + mcp.run() + else: + mcp.run(transport=args.transport, host=args.host, port=args.port) + + +if __name__ == "__main__": + main() diff --git a/mcp-server/test_server.py b/mcp-server/test_server.py new file mode 100644 index 0000000..d2a31ed --- /dev/null +++ b/mcp-server/test_server.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Lightweight, offline sanity checks for the OpenTryOn MCP server. + +Mirrors the structure of ``../tests/test_cli.py``: plain functions (no +pytest dependency), run top to bottom, each asserting one thing and +printing a checkmark. Everything here is a dry-run / in-process check -- +no network calls, no API keys required. + +Run with the same interpreter that has ``fastmcp`` and ``opentryon`` +installed: + + python test_server.py +""" + +from __future__ import annotations + +import asyncio +import importlib.util +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import server +from tryon.cli.registry import SERVICES + +_HAS_TORCH = importlib.util.find_spec("torch") is not None + + +def _count_registry_models() -> int: + return sum(len(models) for models in SERVICES.values()) + + +async def check_tool_count_matches_registry() -> None: + tools = await server.mcp._list_tools() + expected = _count_registry_models() + 2 # + list_opentryon_tools, opentryon_status + assert len(tools) == expected, f"expected {expected} tools, got {len(tools)}" + print(f"\u2713 {len(tools)} MCP tools registered ({_count_registry_models()} models + 2 discovery tools)") + + +async def check_every_model_has_a_tool() -> None: + tools = await server.mcp._list_tools() + names = {t.name for t in tools} + missing = [] + for service, models in SERVICES.items(): + for model_id in models: + name = server._tool_name(service, model_id) + if name not in names: + missing.append(name) + assert not missing, f"missing MCP tools for: {missing}" + print("\u2713 every registry (service, model) has a matching MCP tool") + + +async def check_required_args_are_required() -> None: + tool = await server.mcp.get_tool("vton_flux_vto") + schema = tool.parameters + assert schema["required"] == ["person", "garment"], schema["required"] + assert "output_dir" in schema["properties"] and "dry_run" in schema["properties"] + print("\u2713 vton_flux_vto schema has the right required/optional fields") + + +async def check_choices_become_enum() -> None: + tool = await server.mcp.get_tool("vton_flux_vto") + prop = tool.parameters["properties"]["output_format"] + # optional field with choices -> {"anyOf": [{"enum": [...]}, {"type": "null"}]} + enums = [b["enum"] for b in prop.get("anyOf", [prop]) if "enum" in b] + assert enums and set(enums[0]) == {"jpeg", "png", "webp"}, prop + print("\u2713 Arg.choices are exposed as a JSON schema enum") + + +async def check_dry_run_calls() -> None: + # (tool_name, args, needs_local_extra) + cases = [ + ("vton_flux_vto", {"person": "p.jpg", "garment": "g.jpg", "dry_run": True}, False), + ("generate_nano_banana", {"prompt": "a red dress", "dry_run": True}, False), + ("edit_gpt_image", {"images": ["a.jpg", "b.jpg"], "prompt": "make it blue", "dry_run": True}, False), + ("understand_kimi_k2_6", {"image": "i.jpg", "dry_run": True}, False), + ("understand_kimi_k2_7_code", {"image": "i.jpg", "dry_run": True}, False), + ("video_generate_veo", {"prompt": "a cat", "image": "cat.jpg", "dry_run": True}, False), + ("bg_remove_ben2", {"image": "i.jpg", "dry_run": True}, True), + ] + checked = 0 + for name, args, needs_local in cases: + if needs_local and not _HAS_TORCH: + # `_extra_missing` intentionally runs before the dry-run check + # (matches the CLI's `--dry-run` behavior) so a local-only model + # dry-runs as a clean failure, not a crash, when torch isn't + # installed. Confirm that instead of the success path. + tool = await server.mcp.get_tool(name) + result = await tool.run(args) + data = result.structured_content + assert data["success"] is False and "opentryon[local]" in data["error"], f"{name}: {data}" + checked += 1 + continue + tool = await server.mcp.get_tool(name) + result = await tool.run(args) + data = result.structured_content + assert data["success"] is True, f"{name}: {data}" + assert data["dry_run"] is True, f"{name}: {data}" + checked += 1 + print(f"\u2713 {checked} representative tools resolve dry-run calls correctly") + + +async def check_alt_method_on_image_switches_method() -> None: + tool = await server.mcp.get_tool("video_generate_veo") + text_to_video = await tool.run({"prompt": "a cat", "dry_run": True}) + image_to_video = await tool.run({"prompt": "a cat", "image": "cat.jpg", "dry_run": True}) + assert "generate_text_to_video" in text_to_video.structured_content["call"] + assert "generate_image_to_video" in image_to_video.structured_content["call"] + print("\u2713 veo switches to generate_image_to_video only when --image is set") + + +async def check_unknown_service_and_model_errors() -> None: + from tryon.cli.runner import invoke_model + + r1 = invoke_model("not-a-service", "x", dry_run=True) + assert r1["success"] is False and "Unknown service" in r1["error"], r1 + + r2 = invoke_model("vton", "not-a-model", dry_run=True) + assert r2["success"] is False and "Unknown model" in r2["error"], r2 + print("\u2713 invoke_model returns structured errors for unknown service/model") + + +async def check_list_opentryon_tools() -> None: + tool = await server.mcp.get_tool("list_opentryon_tools") + result = await tool.run({"service": "understand"}) + data = result.structured_content + assert "kimi-k2.6" in data["services"]["understand"]["models"] + assert "kimi-vl" in data["services"]["understand"]["models"] + + bad = await tool.run({"service": "not-a-service"}) + assert bad.structured_content["success"] is False + print("\u2713 list_opentryon_tools lists models and rejects unknown services") + + +async def check_status_message_mentions_every_service() -> None: + msg = server.config.status_message() + for service in SERVICES: + assert f"[{service}]" in msg, f"missing section for {service}" + print("\u2713 opentryon_status covers every service") + + +async def main() -> None: + checks = [ + check_tool_count_matches_registry, + check_every_model_has_a_tool, + check_required_args_are_required, + check_choices_become_enum, + check_dry_run_calls, + check_alt_method_on_image_switches_method, + check_unknown_service_and_model_errors, + check_list_opentryon_tools, + check_status_message_mentions_every_service, + ] + for check in checks: + await check() + print("\nAll MCP server checks passed.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tryon/cli/runner.py b/tryon/cli/runner.py index 1b1521a..964726a 100644 --- a/tryon/cli/runner.py +++ b/tryon/cli/runner.py @@ -19,10 +19,11 @@ import json import os import sys +import traceback from pathlib import Path -from typing import Dict, List +from typing import Any, Dict, List, Mapping, Optional -from tryon.cli.registry import Arg, ModelSpec, SERVICE_HELP, get_service +from tryon.cli.registry import Arg, ModelSpec, SERVICE_HELP, get_model, get_service def add_model_selector(parser: argparse.ArgumentParser, service: str) -> None: @@ -90,12 +91,15 @@ def parse_service_args(service: str, argv: List[str]) -> argparse.Namespace: return parser.parse_args(argv) -def _split_kwargs(spec: ModelSpec, args: argparse.Namespace, using_alt: bool): +def _split_kwargs(spec: ModelSpec, values: Mapping[str, Any], using_alt: bool): + """Split a flat mapping of ``dest -> value`` (an argparse ``Namespace`` + via ``vars()``, or a plain kwargs dict from a non-CLI caller like the MCP + server) into constructor kwargs and method-call kwargs.""" init_kwargs, call_kwargs = {}, {} for arg in spec.args: if arg.alt_only and not using_alt: continue - value = getattr(args, arg.dest, None) + value = values.get(arg.dest) if value is None: continue name = arg.call_name or arg.dest @@ -111,18 +115,56 @@ def _load_class(spec: ModelSpec): return getattr(module, spec.class_name) -def _check_extra(spec: ModelSpec) -> None: +def _extra_missing(spec: ModelSpec) -> Optional[str]: + """Return a human-readable error message if ``spec`` needs the `local` + extra and it isn't installed, else ``None``.""" if spec.extra != "local": - return + return None if importlib.util.find_spec("torch") is None: - print( - f"\nāœ— '{spec.label}' requires local ML dependencies that aren't installed.\n" - f" Install them with: pip install opentryon[local]\n", - file=sys.stderr, + return ( + f"'{spec.label}' requires local ML dependencies that aren't installed. " + "Install them with: pip install opentryon[local]" ) + return None + + +def _check_extra(spec: ModelSpec) -> None: + msg = _extra_missing(spec) + if msg: + print(f"\n\u2717 {msg}\n", file=sys.stderr) raise SystemExit(1) +def _package_result(spec: ModelSpec, result: Any, output_dir: Path, prefix: str) -> Dict[str, Any]: + """Turn a raw adapter return value into a structured, JSON-serializable + dict, saving any images/video/text artifacts to ``output_dir`` along the + way. Shared by the CLI (`run_service`) and the MCP server + (`invoke_model`) so both surfaces produce identical output shapes.""" + if spec.output_kind in ("images", "image_bytes"): + images = result if isinstance(result, (list, tuple)) else [result] + saved = _save_images(images, output_dir, prefix) + return { + "output_kind": spec.output_kind, + "output_paths": [str(p) for p in saved], + "count": len(saved), + } + + if spec.output_kind == "video_bytes": + if isinstance(result, str): + return {"output_kind": "video_bytes", "video_id": result, "downloaded": False} + path = _save_video(result, output_dir, prefix) + return {"output_kind": "video_bytes", "output_path": str(path), "downloaded": True} + + if spec.output_kind == "text": + output_dir.mkdir(parents=True, exist_ok=True) + path = output_dir / f"{prefix}.json" + with open(path, "w", encoding="utf-8") as f: + json.dump(result, f, indent=2, default=str) + return {"output_kind": "text", "result": result, "output_path": str(path)} + + return {"output_kind": "raw", "result": repr(result)} + + def _save_images(images, output_dir: Path, prefix: str) -> List[Path]: from PIL import Image @@ -150,11 +192,10 @@ def run_service(service: str, argv: List[str]) -> int: spec = get_service(service)[args.model] _check_extra(spec) - using_alt = bool( - spec.alt_method_on_image and getattr(args, spec.alt_image_dest, None) - ) + values = vars(args) + using_alt = bool(spec.alt_method_on_image and values.get(spec.alt_image_dest)) method = spec.alt_method_on_image if using_alt else spec.method - init_kwargs, call_kwargs = _split_kwargs(spec, args, using_alt) + init_kwargs, call_kwargs = _split_kwargs(spec, values, using_alt) if args.dry_run: print(f"[dry-run] {spec.class_name}(**{init_kwargs}).{method}(**{call_kwargs})") @@ -171,31 +212,76 @@ def run_service(service: str, argv: List[str]) -> int: output_dir = Path(args.output_dir) prefix = f"{service}_{args.model}" + packaged = _package_result(spec, result, output_dir, prefix) - if spec.output_kind in ("images", "image_bytes"): - images = result if isinstance(result, (list, tuple)) else [result] - saved = _save_images(images, output_dir, prefix) - for path in saved: + if packaged["output_kind"] in ("images", "image_bytes"): + for path in packaged["output_paths"]: print(f"\u2713 Saved: {path}") - print(f"\n\u2713 Done: {len(saved)} image(s)") + print(f"\n\u2713 Done: {packaged['count']} image(s)") return 0 - if spec.output_kind == "video_bytes": - if isinstance(result, str): - print(f"\u2713 Video generation ID (not yet downloaded): {result}") - return 0 - path = _save_video(result, output_dir, prefix) - print(f"\u2713 Saved: {path}") + if packaged["output_kind"] == "video_bytes": + if not packaged["downloaded"]: + print(f"\u2713 Video generation ID (not yet downloaded): {packaged['video_id']}") + else: + print(f"\u2713 Saved: {packaged['output_path']}") return 0 - if spec.output_kind == "text": - output_dir.mkdir(parents=True, exist_ok=True) - path = output_dir / f"{prefix}.json" - with open(path, "w", encoding="utf-8") as f: - json.dump(result, f, indent=2, default=str) - print(json.dumps(result, indent=2, default=str)) - print(f"\n\u2713 Saved: {path}") + if packaged["output_kind"] == "text": + print(json.dumps(packaged["result"], indent=2, default=str)) + print(f"\n\u2713 Saved: {packaged['output_path']}") return 0 - print(f"Result: {result!r}") + print(f"Result: {packaged['result']}") return 0 + + +def invoke_model( + service: str, + model: str, + output_dir: str = "outputs", + dry_run: bool = False, + **kwargs: Any, +) -> Dict[str, Any]: + """Programmatic (non-argv) equivalent of ``run_service``, used by + non-CLI callers -- currently the MCP server (``mcp-server/server.py``), + which builds one FastMCP tool per (service, model) directly from this + same registry and calls straight into this function. + + Unlike ``run_service`` this never raises for expected failure modes + (missing extra, unknown model, adapter errors): it always returns a + dict with a ``success`` key so callers (in particular LLM tool-calling + clients) get a structured result instead of a stack trace. + """ + try: + spec = get_model(service, model) + except KeyError as e: + return {"success": False, "error": e.args[0] if e.args else str(e)} + + extra_error = _extra_missing(spec) + if extra_error: + return {"success": False, "error": extra_error} + + using_alt = bool(spec.alt_method_on_image and kwargs.get(spec.alt_image_dest)) + method = spec.alt_method_on_image if using_alt else spec.method + init_kwargs, call_kwargs = _split_kwargs(spec, kwargs, using_alt) + + if dry_run: + return { + "success": True, + "dry_run": True, + "call": f"{spec.class_name}(**{init_kwargs}).{method}(**{call_kwargs})", + } + + try: + cls = _load_class(spec) + adapter = cls(**init_kwargs) + result = getattr(adapter, method)(**call_kwargs) + packaged = _package_result(spec, result, Path(output_dir), f"{service}_{model}") + return {"success": True, **packaged} + except Exception as e: # noqa: BLE001 - surfaced to the caller, not raised + return { + "success": False, + "error": f"{type(e).__name__}: {e}", + "traceback": traceback.format_exc(), + }