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

3
.env
View File

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

View File

@@ -1,174 +1,202 @@
x-logging: &default-logging x-logging: &default-logging
driver: "json-file" driver: "json-file"
options: options:
max-size: "${LOG_MAX_SIZE:-20m}" max-size: "${LOG_MAX_SIZE:-20m}"
max-file: "${LOG_MAX_FILE:-10}" max-file: "${LOG_MAX_FILE:-10}"
compress: "true" compress: "true"
networks: networks:
traefik-net: traefik-net:
external: true # サーバー上の既存Traefikネットワーク external: true # サーバー上の既存Traefikネットワーク
windmill-internal: windmill-internal:
driver: bridge driver: bridge
services: services:
db: db:
deploy: deploy:
replicas: 1 replicas: 1
image: postgres:16 image: postgres:16
shm_size: 1g shm_size: 1g
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- db_data:/var/lib/postgresql/data - db_data:/var/lib/postgresql/data
expose: expose:
- 5432 - 5432
environment: environment:
POSTGRES_PASSWORD: ${DATABASE_PASSWORD} POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
POSTGRES_DB: windmill POSTGRES_DB: windmill
healthcheck: healthcheck:
test: [ "CMD-SHELL", "pg_isready -U postgres" ] test: [ "CMD-SHELL", "pg_isready -U postgres" ]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
logging: *default-logging logging: *default-logging
networks: networks:
- windmill-internal - windmill-internal
windmill_server: windmill_server:
image: ${WM_IMAGE} image: ${WM_IMAGE}
container_name: windmill_server container_name: windmill_server
pull_policy: if_not_present pull_policy: if_not_present
deploy: deploy:
replicas: 1 replicas: 1
restart: unless-stopped restart: unless-stopped
expose: expose:
- 8000 - 8000
environment: environment:
- DATABASE_URL=${DATABASE_URL} - DATABASE_URL=${DATABASE_URL}
- MODE=server - MODE=server
- BASE_URL=https://windmill.keinafarm.net - BASE_URL=https://windmill.keinafarm.net
- OAUTH_REDIRECT_BASE_URL=https://windmill.keinafarm.net - OAUTH_REDIRECT_BASE_URL=https://windmill.keinafarm.net
- GOOGLE_OAUTH_ENABLED=true - GOOGLE_OAUTH_ENABLED=true
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID} - GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID}
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET} - GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
volumes: volumes:
- worker_logs:/tmp/windmill/logs - worker_logs:/tmp/windmill/logs
# Git同期のために、カレントディレクトリリポジトリルートを/workspaceにマウント # Git同期のために、カレントディレクトリリポジトリルートを/workspaceにマウント
# これにより、コンテナ内から .git ディレクトリにアクセス可能となり、git pushが可能になる # これにより、コンテナ内から .git ディレクトリにアクセス可能となり、git pushが可能になる
- .:/workspace - .:/workspace
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
# HTTPSルーター # HTTPSルーター
- "traefik.http.routers.windmill.rule=Host(`windmill.keinafarm.net`)" - "traefik.http.routers.windmill.rule=Host(`windmill.keinafarm.net`)"
- "traefik.http.routers.windmill.entrypoints=websecure" - "traefik.http.routers.windmill.entrypoints=websecure"
- "traefik.http.routers.windmill.tls=true" - "traefik.http.routers.windmill.tls=true"
- "traefik.http.routers.windmill.tls.certresolver=letsencrypt" - "traefik.http.routers.windmill.tls.certresolver=letsencrypt"
- "traefik.http.services.windmill.loadbalancer.server.port=8000" - "traefik.http.services.windmill.loadbalancer.server.port=8000"
# HTTPからHTTPSへのリダイレクト # HTTPからHTTPSへのリダイレクト
- "traefik.http.routers.windmill-http.rule=Host(`windmill.keinafarm.net`)" - "traefik.http.routers.windmill-http.rule=Host(`windmill.keinafarm.net`)"
- "traefik.http.routers.windmill-http.entrypoints=web" - "traefik.http.routers.windmill-http.entrypoints=web"
- "traefik.http.routers.windmill-http.middlewares=windmill-https-redirect" - "traefik.http.routers.windmill-http.middlewares=windmill-https-redirect"
- "traefik.http.middlewares.windmill-https-redirect.redirectscheme.scheme=https" - "traefik.http.middlewares.windmill-https-redirect.redirectscheme.scheme=https"
networks: networks:
- traefik-net - traefik-net
- windmill-internal - windmill-internal
logging: *default-logging logging: *default-logging
windmill_worker: windmill_worker:
image: ${WM_IMAGE} image: ${WM_IMAGE}
pull_policy: if_not_present pull_policy: if_not_present
deploy: deploy:
replicas: 3 replicas: 3
resources: resources:
limits: limits:
cpus: "1" cpus: "1"
memory: 2048M memory: 2048M
restart: unless-stopped restart: unless-stopped
environment: environment:
- DATABASE_URL=${DATABASE_URL} - DATABASE_URL=${DATABASE_URL}
- MODE=worker - MODE=worker
- WORKER_GROUP=default - WORKER_GROUP=default
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- worker_dependency_cache:/tmp/windmill/cache - worker_dependency_cache:/tmp/windmill/cache
- worker_logs:/tmp/windmill/logs - worker_logs:/tmp/windmill/logs
# WorkerからもGit同期が必要な場合に備えてマウント # WorkerからもGit同期が必要な場合に備えてマウント
- .:/workspace - .:/workspace
networks: networks:
- windmill-internal - windmill-internal
logging: *default-logging logging: *default-logging
windmill_worker_native: windmill_worker_native:
image: ${WM_IMAGE} image: ${WM_IMAGE}
pull_policy: if_not_present pull_policy: if_not_present
deploy: deploy:
replicas: 1 replicas: 1
resources: resources:
limits: limits:
cpus: "1" cpus: "1"
memory: 2048M memory: 2048M
restart: unless-stopped restart: unless-stopped
environment: environment:
- DATABASE_URL=${DATABASE_URL} - DATABASE_URL=${DATABASE_URL}
- MODE=worker - MODE=worker
- WORKER_GROUP=native - WORKER_GROUP=native
- NUM_WORKERS=8 - NUM_WORKERS=8
- SLEEP_QUEUE=200 - SLEEP_QUEUE=200
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
volumes: volumes:
- worker_logs:/tmp/windmill/logs - worker_logs:/tmp/windmill/logs
networks: networks:
- windmill-internal - windmill-internal
logging: *default-logging logging: *default-logging
windmill_extra: windmill_extra:
image: ghcr.io/windmill-labs/windmill-extra:${WM_VERSION} image: ghcr.io/windmill-labs/windmill-extra:${WM_VERSION}
pull_policy: if_not_present pull_policy: if_not_present
restart: unless-stopped restart: unless-stopped
expose: expose:
- 3001 - 3001
- 3002 - 3002
- 3003 - 3003
environment: environment:
- ENABLE_LSP=true - ENABLE_LSP=true
- ENABLE_MULTIPLAYER=false - ENABLE_MULTIPLAYER=false
- ENABLE_DEBUGGER=true - ENABLE_DEBUGGER=true
- DEBUGGER_PORT=3003 - DEBUGGER_PORT=3003
- ENABLE_NSJAIL=false - ENABLE_NSJAIL=false
- REQUIRE_SIGNED_DEBUG_REQUESTS=false - REQUIRE_SIGNED_DEBUG_REQUESTS=false
- WINDMILL_BASE_URL=http://windmill_server:8000 - WINDMILL_BASE_URL=http://windmill_server:8000
volumes: volumes:
- lsp_cache:/pyls/.cache - lsp_cache:/pyls/.cache
networks: networks:
- windmill-internal - windmill-internal
- traefik-net logging: *default-logging
logging: *default-logging labels:
labels: # LSPなどのWebSocket用設定Caddyfileの代替
# LSPなどのWebSocket用設定Caddyfileの代替 - "traefik.enable=true"
- "traefik.enable=true" # LSPへのルーティング (/ws/* -> 3001)
# LSPへのルーティング (/ws/* -> 3001) - "traefik.http.routers.windmill-lsp.rule=Host(`windmill.keinafarm.net`) && PathPrefix(`/ws/`)"
- "traefik.http.routers.windmill-lsp.rule=Host(`windmill.keinafarm.net`) && PathPrefix(`/ws/`)" - "traefik.http.routers.windmill-lsp.entrypoints=websecure"
- "traefik.http.routers.windmill-lsp.entrypoints=websecure" - "traefik.http.routers.windmill-lsp.tls=true"
- "traefik.http.routers.windmill-lsp.tls=true" - "traefik.http.services.windmill-lsp.loadbalancer.server.port=3001"
- "traefik.http.routers.windmill-lsp.service=windmill-lsp" # Debuggerへのルーティング (/ws_debug/* -> 3003)
- "traefik.http.services.windmill-lsp.loadbalancer.server.port=3001" - "traefik.http.routers.windmill-debug.rule=Host(`windmill.keinafarm.net`) && PathPrefix(`/ws_debug/`)"
# Debuggerへのルーティング (/ws_debug/* -> 3003) - "traefik.http.routers.windmill-debug.entrypoints=websecure"
- "traefik.http.routers.windmill-debug.rule=Host(`windmill.keinafarm.net`) && PathPrefix(`/ws_debug/`)" - "traefik.http.routers.windmill-debug.tls=true"
- "traefik.http.routers.windmill-debug.entrypoints=websecure" - "traefik.http.services.windmill-debug.loadbalancer.server.port=3003"
- "traefik.http.routers.windmill-debug.tls=true"
- "traefik.http.routers.windmill-debug.service=windmill-debug" windmill_mcp:
- "traefik.http.services.windmill-debug.loadbalancer.server.port=3003" build:
context: ./mcp
volumes: dockerfile: Dockerfile
db_data: null container_name: windmill_mcp
worker_dependency_cache: null restart: unless-stopped
worker_logs: null expose:
lsp_cache: null - 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
worker_logs: null
lsp_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")