From 87b0c12c0d6423734fef023df60937f0feabde81 Mon Sep 17 00:00:00 2001 From: Samhita Alla Date: Fri, 7 Aug 2026 11:27:15 +0530 Subject: [PATCH] feat: run the published flyte-mcp CLI instead of a local wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin shipped `plugins/flyte/scripts/flyte_mcp_stdio.py` for two reasons, both now gone: 1. `flyte[mcp]` <= 2.5.11 silently served HTTP even with `--transport stdio` (flyteorg/flyte-sdk#1319). Fixed — stdio speaks JSON-RPC on stdin/stdout. 2. The CLI had no way to pick a tool set. It now has `--tool-groups`, `--tools`, and `--read-only`, and reads FLYTE_MCP_PROJECT / FLYTE_MCP_DOMAIN. So `.mcp.json` launches the SDK's own entry point straight from PyPI: uvx --from "flyte[mcp]>=2.5.18" flyte-mcp --transport stdio \ --tool-groups task,run,action,logs,app,trigger,project,secret,condition,identity `>=2.5.18` is the first release capping `mcp<2`. Without the cap `mcp` 2.0.0 resolves, `mcp.server.fastmcp` is gone, and the server dies at import reporting the misleading "mcp is not installed". The three `search` groups are left out on purpose: the hosted `flyte-docs` server already provides them, and enabling them shallow-clones a ~120 MB corpus into `~/.flyte/mcp` on first launch. Dropping the wrapper drops the `${CLAUDE_PLUGIN_ROOT}` path with it, so `.codex-plugin/plugin.json` can finally carry the MCP servers — openai/codex#22842 no longer applies when there is nothing to expand. The key is `mcpServers`, not the `mcp_servers` the Codex docs show; the manifest struct is camelCase (openai/codex#22105). One behaviour change worth knowing: the CLI registers its control-plane tools unconditionally, where the wrapper hid them until a cluster was reachable. The server still starts with no Flyte config at all, so the plugin is still usable while you deploy your first cluster — the tools now fail at call time rather than being absent, and nothing needs a `/reload-plugins` after you log in. Verified against the published package: 29 tools register, the server starts with an empty HOME and no config anywhere, and `list_runs` returns real data with a cached `flyte` CLI login. Signed-off-by: Samhita Alla --- README.md | 112 +++++++---- plugins/flyte/.codex-plugin/plugin.json | 3 +- plugins/flyte/.mcp.json | 13 +- plugins/flyte/README.md | 82 ++++---- plugins/flyte/scripts/flyte_mcp_stdio.py | 244 ----------------------- scripts/smoke_test_mcp.py | 51 +++-- 6 files changed, 156 insertions(+), 349 deletions(-) delete mode 100644 plugins/flyte/scripts/flyte_mcp_stdio.py diff --git a/README.md b/README.md index 5eae412..0a0b2e6 100644 --- a/README.md +++ b/README.md @@ -37,15 +37,16 @@ To switch to a different version later, remove and re-add the marketplace: The skills are plain [Agent Skills](https://agentskills.io) (`SKILL.md` + YAML frontmatter), so they work in any harness that supports the standard. -> **Only Claude Code gets the MCP servers automatically.** They are declared in -> `plugins/flyte/.mcp.json`, which Claude Code reads by convention. Every other harness -> installs the **skills only** — you can still wire the servers up by hand in a few lines, -> see [Adding the MCP servers elsewhere](#adding-the-mcp-servers-elsewhere). +> **Only Claude Code and Codex get the MCP servers automatically.** They are declared in +> `plugins/flyte/.mcp.json` — Claude Code reads that file by convention, Codex is pointed at +> it by `.codex-plugin/plugin.json`. The other harnesses install the **skills only**; you can +> still wire the servers up by hand in a few lines, see +> [Adding the MCP servers elsewhere](#adding-the-mcp-servers-elsewhere). | Harness | Skills | MCP servers | |---|---|---| | Claude Code | all 14 | both, automatically | -| Codex CLI | all 14 | none — add manually | +| Codex CLI | all 14 | both, automatically | | Hermes | per-skill | none — add manually | | opencode | all 14 | none — add manually | | pi | all 14 | none — add manually | @@ -61,12 +62,12 @@ codex plugin marketplace add flyteorg/flyte-agent-plugins # or --ref Then browse and install the plugins via `/plugins` inside Codex. -Codex plugins *can* bundle MCP servers (via an `mcpServers` field pointing at an -`.mcp.json`), but this one deliberately does not: our `.mcp.json` uses -`${CLAUDE_PLUGIN_ROOT}` to locate the local launcher script, and Codex does not expand it -([openai/codex#22842](https://github.com/openai/codex/issues/22842)), so the local server -would fail to start. Add the servers manually instead — the hosted one needs no path and -works fine. +Both MCP servers come with it: `.codex-plugin/plugin.json` carries an `mcpServers` field +pointing at the same `.mcp.json` Claude Code reads. (The key is spelled `mcpServers`, not +the `mcp_servers` the Codex docs show — the manifest struct is `camelCase`, +[openai/codex#22105](https://github.com/openai/codex/issues/22105).) Neither server needs a +path expanded, so `${CLAUDE_PLUGIN_ROOT}` — which Codex does not expand, +[openai/codex#22842](https://github.com/openai/codex/issues/22842) — never comes up. ### Hermes @@ -164,35 +165,47 @@ Installing the plugin registers **two MCP servers**, split so nothing is duplica | Server | Tools | Needs | |---|---|---| | **`flyte-docs`** (hosted HTTP) | 3 `search` — Flyte SDK examples, docs examples, `llms.txt` | nothing at all | -| **`flyte-cluster`** (local stdio) | 13 control-plane — run/inspect tasks, manage runs, apps, triggers | `uv`, plus a Flyte login | +| **`flyte-cluster`** (local stdio) | 29 control-plane — tasks, runs, actions, logs, apps, triggers, projects, secrets, conditions, `whoami` | `uv`, plus a Flyte login | `flyte-docs` is a read-only, unauthenticated server **operated by Union**, so search works the moment you install — no setup, no corpus, no `uv`. Your search queries do leave your -machine; set `FLYTE_MCP_LOCAL_SEARCH=1` to serve search from a local corpus instead -(~120 MB cached under `~/.flyte/mcp`). +machine. -`flyte-cluster` is tenant-agnostic: it calls `flyte.init_from_config()`, so it acts on the same -control plane your `flyte` CLI is authenticated against. **A cluster is optional** — it -starts either way and offers nothing until one is reachable, so the plugin still works -while you are deploying your first cluster. The tools appear once you are logged in -(run `/reload-plugins`, or restart Claude Code — the choice is made at startup). +`flyte-cluster` is the SDK's own `flyte-mcp` entry point, run straight from PyPI with +`uvx` — nothing is vendored here: -Test it end-to-end — this spawns the server exactly as Claude Code does, handshakes, and -reports which mode it landed in: +``` +uvx --from "flyte[mcp]>=2.5.18" flyte-mcp --transport stdio \ + --tool-groups task,run,action,logs,app,trigger,project,secret,condition,identity +``` + +`>=2.5.18` is the first release that caps `mcp<2`; below it the server dies at import. The +`search` groups are left out on purpose — `flyte-docs` already serves them hosted, and +enabling them here shallow-clones ~120 MB into `~/.flyte/mcp` on first launch. + +It is tenant-agnostic: config discovery is the SDK's normal one, so it acts on the same +control plane your `flyte` CLI is authenticated against. **A cluster is optional** — the +server starts even with no Flyte config at all, so the plugin still works while you are +deploying your first cluster; the tools are registered either way and simply fail when +called until you are logged in. + +Test it end-to-end — this spawns the server exactly as a client does, handshakes, lists the +tools, and makes one real read-only call: ``` python3 scripts/smoke_test_mcp.py ``` -Override the automatic tool choice with `FLYTE_MCP_TOOL_GROUPS` / `FLYTE_MCP_TOOLS`, and -scope with `FLYTE_MCP_CONFIG`, `FLYTE_MCP_PROJECT`, `FLYTE_MCP_DOMAIN`, -`FLYTE_MCP_{TASK,APP,TRIGGER}_ALLOWLIST`, or `FLYTE_MCP_LOCAL_SEARCH` — see the plugin -[README](plugins/flyte/README.md). +Change what is served by editing `args` in `plugins/flyte/.mcp.json` (`--tool-groups`, +`--tools`, `--read-only`), and scope it with `FLYTE_MCP_PROJECT` / `FLYTE_MCP_DOMAIN` — see +the plugin [README](plugins/flyte/README.md). ### Adding the MCP servers elsewhere -Codex, Hermes, opencode, and pi all support MCP — this plugin just doesn't configure it -for them. Wiring it up yourself is a few lines. +Hermes, opencode, and pi all support MCP — this plugin just doesn't configure it for them. +(Claude Code and Codex get both servers from the plugin; use these snippets only if you +want them configured globally rather than per-plugin.) Wiring it up yourself is a few +lines. **`flyte-docs`** is plain remote HTTP with no auth and no local dependency, so it drops into any harness: @@ -219,16 +232,39 @@ mcp_servers: pi uses the same `mcpServers` shape in `~/.pi/agent/mcp.json`. -**`flyte-cluster`** is a local stdio process, so point your harness at the launcher script -with an absolute path — there is no `${CLAUDE_PLUGIN_ROOT}` outside Claude Code: +**`flyte-cluster`** is a local stdio process, but it is just the SDK's published +`flyte-mcp` entry point run with `uvx` — no checkout, no path, so it is as portable as the +hosted one. It needs [`uv`](https://docs.astral.sh/uv/) on `PATH` and picks up whatever +control plane your `flyte` CLI is logged into: +```toml +# Codex — ~/.codex/config.toml +[mcp_servers.flyte-cluster] +command = "uvx" +args = ["--from", "flyte[mcp]>=2.5.18", "flyte-mcp", "--transport", "stdio", + "--tool-groups", "task,run,action,logs,app,trigger,project,secret,condition,identity"] ``` -uv run --quiet --no-project /abs/path/to/plugins/flyte/scripts/flyte_mcp_stdio.py + +```json +// opencode — opencode.json +{ "mcp": { "flyte-cluster": { "type": "local", "enabled": true, + "command": ["uvx", "--from", "flyte[mcp]>=2.5.18", "flyte-mcp", "--transport", "stdio", + "--tool-groups", + "task,run,action,logs,app,trigger,project,secret,condition,identity"] } } } +``` + +```yaml +# Hermes — ~/.hermes/config.yaml +mcp_servers: + flyte-cluster: + command: "uvx" + args: ["--from", "flyte[mcp]>=2.5.18", "flyte-mcp", "--transport", "stdio", + "--tool-groups", "task,run,action,logs,app,trigger,project,secret,condition,identity"] ``` -Once [flyte-sdk#1319](https://github.com/flyteorg/flyte-sdk/pull/1319) ships, that becomes -`uvx --from "flyte[mcp]" flyte-mcp --transport stdio` — no path, no script, portable -everywhere. +Drop `--tool-groups` to get everything, including the three `search` tools — but then the +server shallow-clones a ~120 MB corpus into `~/.flyte/mcp` on first launch, which is exactly +what `flyte-docs` exists to avoid. ## Layout @@ -238,9 +274,8 @@ All skills live in the single `flyte` plugin: .claude-plugin/marketplace.json # marketplace catalog package.json # pi package manifest (pi.skills) plugins/flyte/.claude-plugin/plugin.json # Claude Code plugin manifest -plugins/flyte/.codex-plugin/plugin.json # Codex plugin manifest -plugins/flyte/.mcp.json # the two bundled MCP servers (Claude Code) -plugins/flyte/scripts/flyte_mcp_stdio.py # stdio adapter that .mcp.json launches +plugins/flyte/.codex-plugin/plugin.json # Codex plugin manifest (points at .mcp.json) +plugins/flyte/.mcp.json # the two bundled MCP servers plugins/flyte/skills//SKILL.md scripts/smoke_test_mcp.py # end-to-end check of the local MCP server ``` @@ -249,8 +284,9 @@ Each harness consumes a different part of this. Claude Code and Codex read the p manifests, so the **plugin name** matters to them. Hermes, opencode, and pi install skills by **directory path**, so `plugins/flyte/skills/…` is their interface. -The `.mcp.json` server is Claude Code-specific; the skills themselves stay portable across -harnesses. +`.mcp.json` is shared by Claude Code (which finds it by convention) and Codex (which is +pointed at it by `.codex-plugin/plugin.json`); the skills themselves stay portable across +every harness. ## Contributing diff --git a/plugins/flyte/.codex-plugin/plugin.json b/plugins/flyte/.codex-plugin/plugin.json index 6f0c4e7..fbead61 100644 --- a/plugins/flyte/.codex-plugin/plugin.json +++ b/plugins/flyte/.codex-plugin/plugin.json @@ -2,5 +2,6 @@ "name": "flyte", "version": "0.3.0", "description": "Skills for working with Flyte 2 — deploy Flyte v2 (flyte-binary) clusters on AWS or kind, and author, run, and operate Flyte workflows, apps, agents, data, and ML workloads with the SDK.", - "skills": "./skills/" + "skills": "./skills/", + "mcpServers": "./.mcp.json" } diff --git a/plugins/flyte/.mcp.json b/plugins/flyte/.mcp.json index 7d26ff9..6e6f8b1 100644 --- a/plugins/flyte/.mcp.json +++ b/plugins/flyte/.mcp.json @@ -5,12 +5,15 @@ "url": "https://flyte-mcp.apps.demo.hosted.unionai.cloud/flyte-mcp/mcp" }, "flyte-cluster": { - "command": "uv", + "command": "uvx", "args": [ - "run", - "--quiet", - "--no-project", - "${CLAUDE_PLUGIN_ROOT}/scripts/flyte_mcp_stdio.py" + "--from", + "flyte[mcp]>=2.5.18", + "flyte-mcp", + "--transport", + "stdio", + "--tool-groups", + "task,run,action,logs,app,trigger,project,secret,condition,identity" ] } } diff --git a/plugins/flyte/README.md b/plugins/flyte/README.md index 850a945..4b4e1cb 100644 --- a/plugins/flyte/README.md +++ b/plugins/flyte/README.md @@ -60,52 +60,66 @@ Convert existing Flyte 1 (`flytekit`) code to Flyte 2, distilled from the offici ## Bundled MCP servers -**Claude Code only.** The servers live in `.mcp.json`, which Claude Code reads by -convention; Codex, Hermes, opencode, and pi install the skills and nothing else. They all -support MCP, so you can add these by hand — see -[Adding the MCP servers elsewhere](../../README.md#adding-the-mcp-servers-elsewhere). +The servers live in `.mcp.json`. Claude Code reads that file by convention; Codex picks it +up through the `mcpServers` entry in `.codex-plugin/plugin.json`. Hermes, opencode, and pi +install the skills and nothing else — they all support MCP, so you can add these by hand, +see [Adding the MCP servers elsewhere](../../README.md#adding-the-mcp-servers-elsewhere). -The plugin's `.mcp.json` declares **two MCP servers**, split so nothing is duplicated: +`.mcp.json` declares **two MCP servers**, split so nothing is duplicated: - **`flyte-docs`** — hosted HTTP, 3 `search` tools over Flyte SDK examples, docs examples, and `llms.txt`. Read-only, unauthenticated, **operated by Union**. Needs nothing at all, so search works the moment you install. Your queries do leave your machine. -- **`flyte-cluster`** — local stdio (`scripts/flyte_mcp_stdio.py`), 13 control-plane tools: - run and inspect tasks, manage runs, apps, and triggers. Needs - [`uv`](https://docs.astral.sh/uv/) and a Flyte config with `project` and `domain`. - -**A cluster is optional.** `flyte-cluster` starts either way and offers nothing until one is -reachable, so the plugin still works while you are deploying your first cluster. It is -tenant-agnostic — `flyte.init_from_config()` targets whatever control plane your `flyte` -CLI is authenticated against. After logging in, run `/reload-plugins` (or restart Claude -Code) so the server respawns and picks the tools up — the choice is made at startup, and -plugin MCP servers can't be restarted from `/mcp`. - -To test it, run `python3 scripts/smoke_test_mcp.py` from the repo root — it reports what it -landed in. (`flyte-docs` is hosted, so check it with +- **`flyte-cluster`** — local stdio, 29 control-plane tools: run and inspect tasks, runs, + actions, and logs; manage apps, triggers, projects, secrets, and conditions; `whoami`. + Needs [`uv`](https://docs.astral.sh/uv/) (for `uvx`) and a Flyte login. + +`flyte-cluster` is the SDK's own published entry point — no wrapper script and no path to +expand, so the same line works in every harness: + +``` +uvx --from "flyte[mcp]>=2.5.18" flyte-mcp --transport stdio \ + --tool-groups task,run,action,logs,app,trigger,project,secret,condition,identity +``` + +Two things about that command are deliberate: + +- **`>=2.5.18`** is the first release that caps `mcp<2`. Below it, `mcp` 2.0.0 resolves, + `mcp.server.fastmcp` is gone, and the server dies at import claiming "mcp is not installed". +- **The `search` groups are left out.** `flyte-docs` already serves those three tools from a + hosted corpus; enabling them here would shallow-clone ~120 MB into `~/.flyte/mcp` on first + launch for no gain. + +**A cluster is optional.** The server starts even with no Flyte config at all, so the plugin +still works while you are deploying your first cluster — the tools are registered either way +and simply fail when called until you are logged in. It is tenant-agnostic: config discovery +is the SDK's normal one, so it targets whatever control plane your `flyte` CLI is +authenticated against, and nothing needs restarting once you log in. + +To test it, run `python3 scripts/smoke_test_mcp.py` from the repo root — it spawns the +server exactly as a client does, lists the tools, and makes one real read-only call. +(`flyte-docs` is hosted, so check it with `curl https://flyte-mcp.apps.demo.hosted.unionai.cloud/health` instead.) ### Configuring `flyte-cluster` -Set these in your shell or in the `env` block of an MCP config. The plugin's own `.mcp.json` -sets none of them, so ambient values apply. +Change what is served by editing `args` in `.mcp.json`: `--tool-groups` (valid groups are +`all`, `core`, `task`, `run`, `action`, `logs`, `app`, `trigger`, `project`, `secret`, +`condition`, `identity`, `search`), `--tools` for an explicit tool list instead, or +`--read-only` to narrow whatever those selected down to the tools annotated +`readOnlyHint=True`. `uvx --from "flyte[mcp]" flyte-mcp --help` lists the rest. + +Two environment variables are read at startup. Set them in your shell or in an `env` block; +the plugin's own `.mcp.json` sets neither, so ambient values apply. | Variable | Effect | |---|---| -| `FLYTE_MCP_LOCAL_SEARCH` | serve search here, from a local corpus (~120 MB under `~/.flyte/mcp`) instead of the hosted server — offline and private | -| `FLYTE_MCP_TOOL_GROUPS` | override the automatic choice; valid groups are `all`, `core`, `task`, `run`, `app`, `trigger`, `search` | -| `FLYTE_MCP_TOOLS` | an explicit tool list (mutually exclusive with groups) | -| `FLYTE_MCP_CONFIG` | a specific Flyte config file, instead of normal discovery | -| `FLYTE_MCP_PROJECT` / `FLYTE_MCP_DOMAIN` | override the project/domain from the config | -| `FLYTE_MCP_TASK_ALLOWLIST` / `_APP_` / `_TRIGGER_` | restrict which resources the tools may target | - -Setting `FLYTE_MCP_TOOL_GROUPS`/`FLYTE_MCP_TOOLS` overrides the automatic choice, including -offering control-plane tools while disconnected — those will fail when called. - -Note there is no per-server toggle for plugin MCP servers: Claude Code manages them through -plugin installation, not `/mcp`. So `FLYTE_MCP_LOCAL_SEARCH` moves search into -`flyte-cluster` but does not un-declare `flyte-docs` — suppressing that needs a -`deniedMcpServers` entry or disabling the plugin. +| `FLYTE_MCP_PROJECT` | override the project from the resolved config | +| `FLYTE_MCP_DOMAIN` | override the domain from the resolved config | + +Note there is no per-server toggle for plugin MCP servers in Claude Code — it manages them +through plugin installation, not `/mcp`. Suppressing one needs a `deniedMcpServers` entry or +disabling the plugin. To build an MCP server of your own — with allowlists, auth, and a shared endpoint for a team — ask the `flyte-docs` search tools for `FlyteMCPAppEnvironment`; they return the diff --git a/plugins/flyte/scripts/flyte_mcp_stdio.py b/plugins/flyte/scripts/flyte_mcp_stdio.py deleted file mode 100644 index d828241..0000000 --- a/plugins/flyte/scripts/flyte_mcp_stdio.py +++ /dev/null @@ -1,244 +0,0 @@ -# /// script -# requires-python = ">=3.12" -# dependencies = ["flyte[mcp]>=2.5.11"] -# /// -"""Serve the Flyte MCP server over stdio, degrading gracefully when there is no cluster. - -The server always starts, and offers only what can actually work: - -* **Connected** -- a Flyte config resolved with a project *and* domain. Exposes the - control-plane tools: run and inspect tasks, manage runs, apps, and triggers. -* **Not connected** -- no usable config. Exposes nothing rather than tools that would fail - on every call. - -That is deliberate. Someone installing this plugin to *deploy their first Flyte cluster* -has no config yet; failing at startup would make the plugin look broken at the exact -moment they are learning it. The control-plane tools appear on their own once they are -logged in -- run `/reload-plugins` in Claude Code, or restart it, so this process -respawns. The choice is made at startup. - -Search is normally served by the sibling ``flyte-docs`` server declared in ``.mcp.json``, -which is hosted and needs no local corpus, so this file does not duplicate it. Set -``FLYTE_MCP_LOCAL_SEARCH`` to serve search here instead, from a local corpus -- useful -offline, or when search queries should not leave the machine. - -Nothing here is tenant-specific: ``flyte.init_from_config()`` performs the SDK's normal -config discovery, so the tools act on the same control plane your ``flyte`` CLI talks to. - -Why this file exists rather than ``.mcp.json`` just invoking the SDK's own ``flyte-mcp`` -entry point -- two reasons, one temporary and one not: - -1. *Temporary.* ``flyte[mcp]`` <= 2.5.11 has no working stdio transport. - ``MCPAppEnvironment`` accepts ``transport="stdio"`` but ``__post_init__`` builds the - Starlette app and points ``_server`` at uvicorn for every value, so "stdio" silently - serves HTTP. flyteorg/flyte-sdk#1319 fixes that; it does not remove the need for this - file. - -2. *Ongoing.* ``flyte-mcp`` cannot express what this server does. It has no - ``--project``/``--domain``, no allowlist flags, and no notion of degrading to the search - tools when no cluster is reachable -- it initializes unconditionally and leaves the - control-plane tools to fail at call time. The degrade-vs-fail default differs on - purpose: this server is started automatically by an MCP client for someone who may not - even have a cluster, whereas someone typing ``flyte-mcp`` almost certainly has one and - is better served by a loud failure. - -Retiring this file therefore needs the CLI to grow ``--project``, ``--domain``, the three -allowlist flags, and an explicit opt-in such as ``--fallback-tool-groups search``. At that -point ``.mcp.json`` can call ``flyte-mcp`` directly, which also drops the -``${CLAUDE_PLUGIN_ROOT}`` path that currently keeps the bundled server Claude Code-only -(Codex does not expand it -- openai/codex#22842). - -Configuration is read from the environment, because this file is plugin-managed and gets -overwritten on update: - -``FLYTE_MCP_TOOL_GROUPS`` comma-separated groups; overrides the automatic choice -``FLYTE_MCP_TOOLS`` comma-separated tool names (mutually exclusive with groups) -``FLYTE_MCP_CONFIG`` path to a Flyte config file (default: normal discovery) -``FLYTE_MCP_PROJECT`` override the project from the config -``FLYTE_MCP_DOMAIN`` override the domain from the config -``FLYTE_MCP_TASK_ALLOWLIST`` comma-separated task allowlist -``FLYTE_MCP_APP_ALLOWLIST`` comma-separated app allowlist -``FLYTE_MCP_TRIGGER_ALLOWLIST`` comma-separated trigger allowlist -``FLYTE_MCP_LOCAL_SEARCH`` set to serve the search tools from a local corpus - instead of the hosted flyte-docs server - -The search corpus is a ~120 MB shallow clone of flyte-sdk and unionai-examples plus -llms.txt, cached under ``~/.flyte/mcp``. First run takes a few seconds; later runs reuse -the cache. -""" - -from __future__ import annotations - -import contextlib -import os -import sys - -SEARCH_TOOLS = ("search_flyte_sdk_examples", "search_flyte_docs_examples", "search_full_docs") - - -def _csv(name: str) -> list[str] | None: - raw = os.environ.get(name) - if raw is None: - return None - items = [x.strip() for x in raw.split(",") if x.strip()] - return items or None - - -def _connect() -> tuple[bool, str]: - """Try to initialize Flyte. Returns ``(connected, human-readable reason)``. - - "Connected" means a config resolved *and* carries a project and domain. Without those - every control-plane tool fails at call time with a validation error about - ``project_id.domain``, which reads as a broken tool rather than missing setup -- so we - treat it as not connected and simply do not offer those tools. - """ - import flyte - - try: - flyte.init_from_config( - os.environ.get("FLYTE_MCP_CONFIG"), - project=os.environ.get("FLYTE_MCP_PROJECT"), - domain=os.environ.get("FLYTE_MCP_DOMAIN"), - ) - except Exception as e: - return False, f"no usable Flyte config ({type(e).__name__}: {e})" - - try: - from flyte._initialize import get_init_config - - cfg = get_init_config() - except ImportError: # private API moved; assume the init above was enough - return True, "Flyte initialized" - - missing = [n for n in ("project", "domain") if getattr(cfg, n, None) is None] - if missing: - found = getattr(cfg, "source_config_path", None) - where = f"config {found}" if found else "no config file found" - return False, f"{' and '.join(missing)} not set ({where})" - - return True, f"project={cfg.project} domain={cfg.domain}" - - -def _search_paths(enabled: set[str]) -> dict[str, str | None]: - """Materialize the local search corpus for whichever search tools are enabled.""" - paths: dict[str, str | None] = { - "sdk_examples_path": None, - "docs_examples_path": None, - "full_docs_path": None, - } - if not os.environ.get("FLYTE_MCP_LOCAL_SEARCH"): - # Off by default: the plugin ships a hosted `flyte-docs` server that already - # provides these tools with no local corpus. Opt in for an offline/private one. - return paths - try: - from flyte._bin.mcp import _MCP_CACHE_DIR, _prepare_search_corpus - except ImportError: - print("Could not import the search corpus helper; search tools may be empty.", file=sys.stderr) - return paths - - try: - sdk, docs, full = _prepare_search_corpus( - _MCP_CACHE_DIR, - fetch_sdk_examples="search_flyte_sdk_examples" in enabled, - fetch_docs_examples="search_flyte_docs_examples" in enabled, - fetch_full_docs="search_full_docs" in enabled, - ) - except Exception as e: - # A failed clone must not take the whole server down -- the control-plane tools - # (if any) are still perfectly usable. - print(f"Search corpus unavailable ({type(e).__name__}: {e}).", file=sys.stderr) - return paths - - paths.update(sdk_examples_path=sdk, docs_examples_path=docs, full_docs_path=full) - return paths - - -def main() -> int: - from flyte.ai.mcp import FlyteMCPAppEnvironment - from flyte.ai.mcp._flyte_mcp_app import _resolve_tools - - tool_groups = _csv("FLYTE_MCP_TOOL_GROUPS") - tools = _csv("FLYTE_MCP_TOOLS") - if tool_groups and tools: - print("Set FLYTE_MCP_TOOL_GROUPS or FLYTE_MCP_TOOLS, not both.", file=sys.stderr) - return 2 - - # stdout is the JSON-RPC channel; anything printed during setup would corrupt it. - with contextlib.redirect_stdout(sys.stderr): - connected, reason = _connect() - - explicit = bool(tool_groups or tools) - - # Resolve the corpus before choosing tools, not after: registering a search tool - # whose corpus is missing produces a tool that exists and always raises - # "sdk_examples_path is not configured", which is worse than not offering it. - wanted = _resolve_tools(tool_groups, tools) if explicit else set(SEARCH_TOOLS) - paths = _search_paths(wanted & set(SEARCH_TOOLS)) - search_ok = any(paths.values()) - - if not explicit: - # Offer only what can actually work: cluster tools when connected, search - # tools when their corpus is present. Either may be absent independently. - groups = ["task", "run", "app", "trigger"] if connected else [] - if search_ok: - groups.append("search") - if not groups: - print( - f"No tools available: not connected ({reason}) and no search corpus. " - "Serving an empty tool set.", - file=sys.stderr, - ) - groups = ["core"] - tool_groups = groups - elif not connected: - print( - f"Flyte is not connected ({reason}), but tools were selected explicitly; " - "any control-plane tool will fail when called.", - file=sys.stderr, - ) - - enabled = _resolve_tools(tool_groups, tools) - - parts = [] - if connected: - parts.append( - f"Tools for the Flyte control plane you are authenticated against ({reason}): " - "run and inspect tasks, manage runs, apps, and triggers." - ) - else: - parts.append( - f"This server is NOT connected to a Flyte cluster ({reason}), so it cannot " - "run tasks or inspect runs. Once a Flyte config with a project and domain is " - "available, this server has to be relaunched to pick it up -- in Claude Code " - "that is /reload-plugins, since the tool set is chosen at startup." - ) - if search_ok: - parts.append( - "The search tools grep a local copy of Flyte SDK examples, docs examples, " - "and llms.txt -- use them to ground Flyte code you write." - ) - instructions = " ".join(parts) - - print( - f"flyte-mcp: {'connected' if connected else 'not connected'} ({reason}); " - f"serving {len(enabled)} tool(s)", - file=sys.stderr, - ) - - env = FlyteMCPAppEnvironment( - name="flyte-mcp", - instructions=instructions, - tool_groups=tool_groups, - tools=tools, - task_allowlist=_csv("FLYTE_MCP_TASK_ALLOWLIST"), - app_allowlist=_csv("FLYTE_MCP_APP_ALLOWLIST"), - trigger_allowlist=_csv("FLYTE_MCP_TRIGGER_ALLOWLIST"), - **paths, - ) - - env.mcp.run(transport="stdio") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/smoke_test_mcp.py b/scripts/smoke_test_mcp.py index 7b9caa5..43c061a 100644 --- a/scripts/smoke_test_mcp.py +++ b/scripts/smoke_test_mcp.py @@ -1,19 +1,22 @@ """Smoke-test the bundled Flyte MCP server the way Claude Code launches it. Reads ``plugins/flyte/.mcp.json``, spawns the exact command the local ``flyte-cluster`` -server declares (expanding ``${CLAUDE_PLUGIN_ROOT}``), completes the MCP handshake, lists -the tools, and makes one real read-only call against whatever tenant you are logged into. +server declares, completes the MCP handshake, lists the tools, and makes one real +read-only call against whatever tenant you are logged into. The sibling ``flyte-docs`` server is hosted HTTP, not spawned, so it is not covered here -- curl its ``/health`` endpoint instead. python3 scripts/smoke_test_mcp.py [plugin_dir] -Works with or without a cluster: given a Flyte config with ``project`` and ``domain`` it -exercises a real read-only control-plane call (``list_runs``); without one it reports that -the control-plane tools are simply not offered. The local server serves the search tools -only when ``FLYTE_MCP_LOCAL_SEARCH`` is set -- otherwise search comes from the hosted -``flyte-docs`` server, which this test does not spawn. +Works with or without a cluster. ``flyte-mcp`` registers its control-plane tools +unconditionally, so the tool list is the same either way; without a usable Flyte config +the read-only call fails at call time, which this script reports as "not connected" +rather than as a broken server. + +The ``.mcp.json`` command deliberately leaves the three ``search`` tools out -- the hosted +``flyte-docs`` server already provides them, and enabling them here would shallow-clone a +~120 MB corpus into ``~/.flyte/mcp`` on first launch. """ import json @@ -28,8 +31,8 @@ MCP_JSON = os.path.join(PLUGIN, ".mcp.json") spec = json.load(open(MCP_JSON))["mcpServers"]["flyte-cluster"] -argv = [spec["command"]] + [a.replace("${CLAUDE_PLUGIN_ROOT}", PLUGIN) for a in spec["args"]] -env = {**os.environ, **{k: v.replace("${CLAUDE_PLUGIN_ROOT}", PLUGIN) for k, v in spec.get("env", {}).items()}} +argv = [spec["command"]] + list(spec["args"]) +env = {**os.environ, **spec.get("env", {})} print(f"$ {' '.join(argv)}\n") proc = subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE, @@ -66,38 +69,32 @@ def rpc(method, params=None, notify=False): print(f"tools/list OK -> {len(tools)} tools: {', '.join(sorted(t['name'] for t in tools))}\n") names = {t["name"] for t in tools} - connected = "list_runs" in names def call(tool, args, describe): + """Run one tool call. Returns True if it succeeded.""" print(f"tools/call {tool}({args}) ...") resp = rpc("tools/call", {"name": tool, "arguments": args}) if "error" in resp: print(" RPC ERROR:", resp["error"].get("message", resp["error"])) - return + return False res = resp.get("result", {}) if res.get("isError"): print(" ERROR from tool:", res["content"][0]["text"][:400]) - return + return False payload = res.get("structuredContent", {}).get("result") if payload is None: payload = res.get("content", [{}])[0].get("text", "") describe(payload) + return True + + if "list_runs" not in names: + raise SystemExit("list_runs is missing -- the declared --tool-groups did not take effect") - # The local server serves search only under FLYTE_MCP_LOCAL_SEARCH; by default search - # comes from the hosted flyte-docs server, which this test does not spawn. - if "search_flyte_sdk_examples" in names: - call("search_flyte_sdk_examples", {"pattern": "TaskEnvironment"}, - lambda p: print(f" OK -> {len(str(p))} chars, " - f"{'found matches' if 'TaskEnvironment' in str(p) else 'NO MATCHES'}")) - else: - print("search not served locally (set FLYTE_MCP_LOCAL_SEARCH=1 to exercise it here).") - - if connected: - call("list_runs", {"limit": 3}, - lambda p: print(f" OK -> {len(p if isinstance(p, list) else json.loads(p or '[]'))} run(s)")) - else: - print("\nnot connected to a cluster -- control-plane tools are not offered.") - print("Set a Flyte config (project + domain) and rerun to exercise list_runs.") + ok = call("list_runs", {"limit": 3}, + lambda p: print(f" OK -> {len(p if isinstance(p, list) else json.loads(p or '[]'))} run(s)")) + if not ok: + print("\nnot connected to a cluster -- the tools are registered but fail when called.") + print("Log in with the `flyte` CLI (project + domain) and rerun to exercise list_runs.") finally: proc.stdin.close() try: