Auto-sync: 2026-03-01 17:00:01

This commit is contained in:
Windmill Bot
2026-03-01 17:00:01 +00:00
parent 148d2cb025
commit 9e75903b39
5 changed files with 566 additions and 175 deletions

1
.env
View File

@@ -16,3 +16,4 @@ GOOGLE_OAUTH_CLIENT_SECRET=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
# To rotate logs, set the following variables:
#LOG_MAX_SIZE=10m
#LOG_MAX_FILE=3
WINDMILL_TOKEN=qLJ3VPZ61kTDiIwaUPUu1dXszGrsN1Dh

View File

@@ -149,7 +149,6 @@ services:
- lsp_cache:/pyls/.cache
networks:
- windmill-internal
- traefik-net
logging: *default-logging
labels:
# LSPなどのWebSocket用設定Caddyfileの代替
@@ -158,15 +157,44 @@ services:
- "traefik.http.routers.windmill-lsp.rule=Host(`windmill.keinafarm.net`) && PathPrefix(`/ws/`)"
- "traefik.http.routers.windmill-lsp.entrypoints=websecure"
- "traefik.http.routers.windmill-lsp.tls=true"
- "traefik.http.routers.windmill-lsp.service=windmill-lsp"
- "traefik.http.services.windmill-lsp.loadbalancer.server.port=3001"
# Debuggerへのルーティング (/ws_debug/* -> 3003)
- "traefik.http.routers.windmill-debug.rule=Host(`windmill.keinafarm.net`) && PathPrefix(`/ws_debug/`)"
- "traefik.http.routers.windmill-debug.entrypoints=websecure"
- "traefik.http.routers.windmill-debug.tls=true"
- "traefik.http.routers.windmill-debug.service=windmill-debug"
- "traefik.http.services.windmill-debug.loadbalancer.server.port=3003"
windmill_mcp:
build:
context: ./mcp
dockerfile: Dockerfile
container_name: windmill_mcp
restart: unless-stopped
expose:
- 8001
environment:
- WINDMILL_TOKEN=${WINDMILL_TOKEN}
- WINDMILL_URL=https://windmill.keinafarm.net
- WINDMILL_WORKSPACE=admins
- MCP_TRANSPORT=sse
- MCP_HOST=0.0.0.0
- MCP_PORT=8001
labels:
- "traefik.enable=true"
# HTTPS ルーター
- "traefik.http.routers.windmill-mcp.rule=Host(`windmill_mcp.keinafarm.net`)"
- "traefik.http.routers.windmill-mcp.entrypoints=websecure"
- "traefik.http.routers.windmill-mcp.tls=true"
- "traefik.http.routers.windmill-mcp.tls.certresolver=letsencrypt"
- "traefik.http.services.windmill-mcp.loadbalancer.server.port=8001"
# HTTP → HTTPS リダイレクト
- "traefik.http.routers.windmill-mcp-http.rule=Host(`windmill_mcp.keinafarm.net`)"
- "traefik.http.routers.windmill-mcp-http.entrypoints=web"
- "traefik.http.routers.windmill-mcp-http.middlewares=windmill-https-redirect"
networks:
- traefik-net
logging: *default-logging
volumes:
db_data: null
worker_dependency_cache: null

14
mcp/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY windmill_mcp.py .
ENV MCP_TRANSPORT=sse
ENV MCP_HOST=0.0.0.0
ENV MCP_PORT=8001
CMD ["python", "windmill_mcp.py"]

2
mcp/requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
mcp>=1.0.0
httpx>=0.27.0

346
mcp/windmill_mcp.py Normal file
View File

@@ -0,0 +1,346 @@
#!/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: ジョブのIDwindmill_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_create_flow(path: str, summary: str, flow_definition: str, description: str = "") -> str:
"""新しいフローを作成する
Args:
path: フローのパス (例: u/admin/my_flow)
summary: フローの概要
flow_definition: フローの定義 (JSON形式の文字列)
description: フローの詳細説明 (省略可)
"""
try:
flow_value = json.loads(flow_definition)
except json.JSONDecodeError as e:
return f"Error: flow_definitionのJSON形式が不正です: {e}"
payload = {
"path": path,
"summary": summary,
"description": description,
"value": flow_value,
}
resp = httpx.post(
_api("flows/create"),
headers=_headers(),
json=payload,
timeout=30,
)
resp.raise_for_status()
return (
f"フローを作成しました。\n"
f"パス: {path}\n"
f"URL: {WINDMILL_URL}/flows/edit/{path}?workspace={WINDMILL_WORKSPACE}"
)
@mcp.tool()
def windmill_update_flow(path: str, summary: str, flow_definition: str, description: str = "") -> str:
"""既存のフローを更新する
Args:
path: フローのパス (例: u/admin/my_flow)
summary: フローの概要
flow_definition: フローの定義 (JSON形式の文字列)
description: フローの詳細説明 (省略可)
"""
try:
flow_value = json.loads(flow_definition)
except json.JSONDecodeError as e:
return f"Error: flow_definitionのJSON形式が不正です: {e}"
payload = {
"path": path,
"summary": summary,
"description": description,
"value": flow_value,
}
resp = httpx.post(
_api(f"flows/edit/{path}"),
headers=_headers(),
json=payload,
timeout=30,
)
resp.raise_for_status()
return (
f"フローを更新しました。\n"
f"パス: {path}\n"
f"URL: {WINDMILL_URL}/flows/edit/{path}?workspace={WINDMILL_WORKSPACE}"
)
@mcp.tool()
def windmill_create_script(
path: str, language: str, content: str, summary: str = "", description: str = ""
) -> str:
"""新しいスクリプトを作成する(既存パスの場合は新バージョンを登録する)
Args:
path: スクリプトのパス (例: u/admin/my_script)
language: 言語 (python3, deno, bun, bash など)
content: スクリプトのソースコード
summary: スクリプトの概要 (省略可)
description: スクリプトの詳細説明 (省略可)
"""
payload = {
"path": path,
"language": language,
"content": content,
"summary": summary,
"description": description,
}
resp = httpx.post(
_api("scripts/create"),
headers=_headers(),
json=payload,
timeout=30,
)
resp.raise_for_status()
hash_val = resp.text.strip().strip('"')
return (
f"スクリプトを作成しました。\n"
f"パス: {path}\n"
f"ハッシュ: {hash_val}\n"
f"URL: {WINDMILL_URL}/scripts/edit/{path}?workspace={WINDMILL_WORKSPACE}"
)
@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__":
transport = os.environ.get("MCP_TRANSPORT", "stdio")
if transport == "sse":
host = os.environ.get("MCP_HOST", "0.0.0.0")
port = int(os.environ.get("MCP_PORT", "8001"))
mcp.run(transport="sse", host=host, port=port)
else:
mcp.run(transport="stdio")