From 6de184968d0a731c1daa4d90685da053a6b8e796 Mon Sep 17 00:00:00 2001 From: Akira Date: Wed, 25 Feb 2026 21:42:41 +0900 Subject: [PATCH] =?UTF-8?q?Windmill=20MCP=20=E3=82=B5=E3=83=BC=E3=83=90?= =?UTF-8?q?=E3=83=BC=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mcp/windmill_mcp.py: FastMCP ベースの Python MCP サーバー - windmill_list_flows: フロー一覧 - windmill_get_flow: フロー定義取得 - windmill_run_flow: フローのトリガー実行 - windmill_list_recent_jobs: ジョブ一覧(成功/失敗/実行中フィルタ対応) - windmill_get_job_logs: ジョブログ・実行結果取得 - windmill_list_scripts: スクリプト一覧 - windmill_get_script: スクリプトソースコード取得 .gitignore: .mcp.json, __pycache__/ を除外 Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 4 + mcp/.env.example | 12 ++ mcp/claude_mcp_config.json | 14 +++ mcp/requirements.txt | 2 + mcp/windmill_mcp.py | 231 +++++++++++++++++++++++++++++++++++++ 5 files changed, 263 insertions(+) create mode 100644 mcp/.env.example create mode 100644 mcp/claude_mcp_config.json create mode 100644 mcp/requirements.txt create mode 100644 mcp/windmill_mcp.py diff --git a/.gitignore b/.gitignore index 3443c3d..2cae475 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,9 @@ Thumbs.db *.bak *~ +# MCP server config (contains API tokens) +.mcp.json + # Resolved markdown files (generated by editor) *.resolved *.resolved.* @@ -46,3 +49,4 @@ workflows/.wmill/tmp/ !workflows/g/ !workflows/wmill.yaml !workflows/wmill-lock.yaml +__pycache__/ diff --git a/mcp/.env.example b/mcp/.env.example new file mode 100644 index 0000000..788fdf6 --- /dev/null +++ b/mcp/.env.example @@ -0,0 +1,12 @@ +# Windmill MCP Server の設定 +# このファイルを .env にコピーして値を設定してください + +# Windmill のベース URL(デフォルト: https://windmill.keinafarm.net) +WINDMILL_URL=https://windmill.keinafarm.net + +# Windmill API トークン(必須) +# Windmill の「設定 > トークン」から作成してください +WINDMILL_TOKEN=your_token_here + +# 対象ワークスペース(デフォルト: admins) +WINDMILL_WORKSPACE=admins diff --git a/mcp/claude_mcp_config.json b/mcp/claude_mcp_config.json new file mode 100644 index 0000000..0e895bf --- /dev/null +++ b/mcp/claude_mcp_config.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "windmill": { + "command": "python", + "args": ["windmill_mcp.py"], + "cwd": "/path/to/mcp", + "env": { + "WINDMILL_TOKEN": "your_api_token_here", + "WINDMILL_URL": "https://windmill.keinafarm.net", + "WINDMILL_WORKSPACE": "admins" + } + } + } +} diff --git a/mcp/requirements.txt b/mcp/requirements.txt new file mode 100644 index 0000000..9714bd9 --- /dev/null +++ b/mcp/requirements.txt @@ -0,0 +1,2 @@ +mcp>=1.0.0 +httpx>=0.27.0 diff --git a/mcp/windmill_mcp.py b/mcp/windmill_mcp.py new file mode 100644 index 0000000..177c1ec --- /dev/null +++ b/mcp/windmill_mcp.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +"""Windmill MCP Server - Claude が Windmill を直接操作できるようにする""" + +import os +import json +import sys +import httpx +from mcp.server.fastmcp import FastMCP + +WINDMILL_URL = os.environ.get("WINDMILL_URL", "https://windmill.keinafarm.net") +WINDMILL_TOKEN = os.environ.get("WINDMILL_TOKEN", "") +WINDMILL_WORKSPACE = os.environ.get("WINDMILL_WORKSPACE", "admins") + +if not WINDMILL_TOKEN: + print("Error: WINDMILL_TOKEN 環境変数が設定されていません", file=sys.stderr) + sys.exit(1) + +mcp = FastMCP("windmill") + + +def _headers() -> dict: + return {"Authorization": f"Bearer {WINDMILL_TOKEN}"} + + +def _api(path: str) -> str: + return f"{WINDMILL_URL}/api/w/{WINDMILL_WORKSPACE}/{path}" + + +@mcp.tool() +def windmill_list_flows(per_page: int = 20) -> str: + """Windmill のフロー一覧を取得する + + Args: + per_page: 取得件数(最大100) + """ + resp = httpx.get( + _api("flows/list"), + headers=_headers(), + params={"per_page": min(per_page, 100)}, + timeout=30, + ) + resp.raise_for_status() + flows = resp.json() + if not flows: + return "フローが見つかりませんでした" + lines = [ + f"- {f['path']}: {f.get('summary', '(概要なし)')}" for f in flows + ] + return "\n".join(lines) + + +@mcp.tool() +def windmill_get_flow(path: str) -> str: + """指定したパスのフロー定義(スクリプト含む)を取得する + + Args: + path: フローのパス (例: u/antigravity/git_sync) + """ + resp = httpx.get(_api(f"flows/get/{path}"), headers=_headers(), timeout=30) + resp.raise_for_status() + return json.dumps(resp.json(), indent=2, ensure_ascii=False) + + +@mcp.tool() +def windmill_run_flow(path: str, args: str = "{}") -> str: + """フローをトリガーして実行する + + Args: + path: フローのパス (例: u/antigravity/git_sync) + args: JSON形式の入力引数 (例: {"key": "value"}) + """ + try: + args_dict = json.loads(args) + except json.JSONDecodeError as e: + return f"Error: argsのJSON形式が不正です: {e}" + + resp = httpx.post( + _api(f"jobs/run/f/{path}"), + headers=_headers(), + json=args_dict, + timeout=30, + ) + resp.raise_for_status() + job_id = resp.text.strip().strip('"') + return ( + f"フローを開始しました。\n" + f"ジョブID: {job_id}\n" + f"詳細URL: {WINDMILL_URL}/run/{job_id}?workspace={WINDMILL_WORKSPACE}" + ) + + +@mcp.tool() +def windmill_list_recent_jobs( + limit: int = 20, + success_only: bool = False, + failure_only: bool = False, + script_path_filter: str = "", +) -> str: + """最近のジョブ一覧を取得する + + Args: + limit: 取得件数(最大100) + success_only: Trueにすると成功ジョブのみ表示 + failure_only: Trueにすると失敗ジョブのみ表示 + script_path_filter: パスで絞り込む (例: u/antigravity/git_sync) + """ + params: dict = {"per_page": min(limit, 100)} + if success_only: + params["success"] = "true" + if failure_only: + params["success"] = "false" + if script_path_filter: + params["script_path_filter"] = script_path_filter + + resp = httpx.get(_api("jobs/list"), headers=_headers(), params=params, timeout=30) + resp.raise_for_status() + jobs = resp.json() + if not jobs: + return "ジョブが見つかりませんでした" + + lines = [] + for j in jobs: + success = j.get("success") + if success is True: + status = "[OK]" + elif success is False: + status = "[FAIL]" + else: + status = "[RUNNING]" + path = j.get("script_path", "unknown") + started = (j.get("started_at") or "")[:19] or "pending" + job_id = j.get("id", "") + lines.append(f"{status} [{started}] {path} (ID: {job_id})") + + return "\n".join(lines) + + +@mcp.tool() +def windmill_get_job_logs(job_id: str) -> str: + """ジョブの詳細情報とログを取得する + + Args: + job_id: ジョブのID(windmill_list_recent_jobs で確認できる) + """ + resp = httpx.get(_api(f"jobs_u/get/{job_id}"), headers=_headers(), timeout=30) + resp.raise_for_status() + job = resp.json() + + success = job.get("success") + if success is True: + state = "成功 [OK]" + elif success is False: + state = "失敗 [FAIL]" + else: + state = "実行中 [RUNNING]" + + result_parts = [ + f"ジョブID: {job_id}", + f"パス: {job.get('script_path', 'N/A')}", + f"状態: {state}", + f"開始: {job.get('started_at', 'N/A')}", + f"終了: {job.get('created_at', 'N/A')}", + ] + + log_resp = httpx.get( + _api(f"jobs_u/getlogs/{job_id}"), headers=_headers(), timeout=30 + ) + if log_resp.status_code == 200: + result_parts.append("\n--- ログ ---") + result_parts.append(log_resp.text) + + result_val = job.get("result") + if result_val is not None: + result_parts.append("\n--- 実行結果 ---") + result_parts.append( + json.dumps(result_val, indent=2, ensure_ascii=False) + if isinstance(result_val, (dict, list)) + else str(result_val) + ) + + return "\n".join(result_parts) + + +@mcp.tool() +def windmill_list_scripts(per_page: int = 20) -> str: + """Windmill のスクリプト一覧を取得する + + Args: + per_page: 取得件数(最大100) + """ + resp = httpx.get( + _api("scripts/list"), + headers=_headers(), + params={"per_page": min(per_page, 100)}, + timeout=30, + ) + resp.raise_for_status() + scripts = resp.json() + if not scripts: + return "スクリプトが見つかりませんでした" + lines = [ + f"- {s['path']} [{s.get('language', '?')}]: {s.get('summary', '(概要なし)')}" + for s in scripts + ] + return "\n".join(lines) + + +@mcp.tool() +def windmill_get_script(path: str) -> str: + """指定したパスのスクリプトのソースコードを取得する + + Args: + path: スクリプトのパス (例: u/antigravity/test_git_sync) + """ + resp = httpx.get(_api(f"scripts/get/{path}"), headers=_headers(), timeout=30) + resp.raise_for_status() + script = resp.json() + + result_parts = [ + f"パス: {script.get('path', 'N/A')}", + f"言語: {script.get('language', 'N/A')}", + f"概要: {script.get('summary', 'N/A')}", + "", + "--- コード ---", + script.get("content", "(コードなし)"), + ] + return "\n".join(result_parts) + + +if __name__ == "__main__": + mcp.run(transport="stdio")