Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a15e0100df | ||
|
|
bcfb24b16d | ||
|
|
42d31e38e4 | ||
|
|
16a01a9ea5 | ||
|
|
b6b23a7970 | ||
|
|
150b262db3 |
+49
-6
@@ -179,6 +179,12 @@ steps:
|
|||||||
from_secret: ai_open_webui_database_url
|
from_secret: ai_open_webui_database_url
|
||||||
AI_OAUTH_CLIENT_SECRET:
|
AI_OAUTH_CLIENT_SECRET:
|
||||||
from_secret: ai_oauth_client_secret
|
from_secret: ai_oauth_client_secret
|
||||||
|
FLOWAGENT_AZURE_CLIENT_ID:
|
||||||
|
from_secret: flowagent_azure_client_id
|
||||||
|
FLOWAGENT_AZURE_TENANT_ID:
|
||||||
|
from_secret: flowagent_azure_tenant_id
|
||||||
|
FLOWAGENT_AZURE_CLIENT_SECRET:
|
||||||
|
from_secret: flowagent_azure_client_secret
|
||||||
commands:
|
commands:
|
||||||
- apk add --no-cache openssh-client
|
- apk add --no-cache openssh-client
|
||||||
- mkdir -p ~/.ssh
|
- mkdir -p ~/.ssh
|
||||||
@@ -221,10 +227,36 @@ steps:
|
|||||||
ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} "source /tmp/cs.sh
|
ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} "source /tmp/cs.sh
|
||||||
create_or_update_secret 'gamma_auth_token' '$${GAMMA_AUTH_TOKEN}'";;
|
create_or_update_secret 'gamma_auth_token' '$${GAMMA_AUTH_TOKEN}'";;
|
||||||
git)
|
git)
|
||||||
ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} "source /tmp/cs.sh
|
# PATTERN B, DELIBERATE (see decision notes) — git hosts the source
|
||||||
create_or_update_secret 'git_db_password' '$${GIT_DB_PASSWORD}'
|
# of truth for every other stack's compose files, so it must be
|
||||||
create_or_update_secret 'git_runner_token' '$${GIT_RUNNER_TOKEN}'
|
# restorable from a flat git.yaml + git.env backup alone, with zero
|
||||||
create_or_update_secret 'git_mcp_access_token' '$${GIT_MCP_ACCESS_TOKEN}'";;
|
# dependency on a running Swarm's Docker secret store. Native Docker
|
||||||
|
# secrets (Pattern C) can't satisfy that: they only exist inside an
|
||||||
|
# already-running Swarm, which is exactly the circular dependency
|
||||||
|
# this stack can't have. Mirrors the ai) case's grep -v + printf
|
||||||
|
# rewrite-in-place approach, never sed (values may contain slash,
|
||||||
|
# dollar sign, ampersand).
|
||||||
|
#
|
||||||
|
# TEST PHASE: target is git.env.pipelinetest, NOT the real git.env.
|
||||||
|
# The real file is never opened for writing by this step. First run
|
||||||
|
# seeds the test file from the real git.env (carries over all
|
||||||
|
# non-secret lines untouched); every push after that only refreshes
|
||||||
|
# the 3 secret lines below. Also strips the legacy GITEA_ACCESS_TOKEN
|
||||||
|
# key name so the test file converges on the git.env.example-
|
||||||
|
# documented GITEA_MCP_ACCESS_TOKEN key. Cutover to the real file —
|
||||||
|
# and pointing git.yaml/stack-deploy at it — is a deliberate,
|
||||||
|
# separate follow-up after manually diffing this render.
|
||||||
|
ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} "FILE=/volume1/docker/compose-files/git.env.pipelinetest
|
||||||
|
TMP=\$FILE.tmp.\$\$
|
||||||
|
[ -f \$FILE ] || cp /volume1/docker/compose-files/git.env \$FILE
|
||||||
|
grep -vE '^(GITEA__database__PASSWD|GITEA_RUNNER_REGISTRATION_TOKEN|GITEA_MCP_ACCESS_TOKEN|GITEA_ACCESS_TOKEN)=' \$FILE > \$TMP 2>/dev/null || touch \$TMP
|
||||||
|
{ cat \$TMP
|
||||||
|
printf 'GITEA__database__PASSWD=%s\n' '$${GIT_DB_PASSWORD}'
|
||||||
|
printf 'GITEA_RUNNER_REGISTRATION_TOKEN=%s\n' '$${GIT_RUNNER_TOKEN}'
|
||||||
|
printf 'GITEA_MCP_ACCESS_TOKEN=%s\n' '$${GIT_MCP_ACCESS_TOKEN}'
|
||||||
|
} > \$FILE
|
||||||
|
rm -f \$TMP
|
||||||
|
echo ' [OK] git.env.pipelinetest updated - real git.env untouched'";;
|
||||||
homeassistant)
|
homeassistant)
|
||||||
ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} "source /tmp/cs.sh
|
ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} "source /tmp/cs.sh
|
||||||
create_or_update_secret 'homeassistant_frigate_rtsp_password' '$${HOMEASSISTANT_FRIGATE_RTSP_PASSWORD}'
|
create_or_update_secret 'homeassistant_frigate_rtsp_password' '$${HOMEASSISTANT_FRIGATE_RTSP_PASSWORD}'
|
||||||
@@ -279,6 +311,13 @@ steps:
|
|||||||
# unresolved at compose-render time (empty), silently breaking Bedrock auth.
|
# unresolved at compose-render time (empty), silently breaking Bedrock auth.
|
||||||
# All other migrated vars in ai.yaml use plain (unprefixed) names, so only
|
# All other migrated vars in ai.yaml use plain (unprefixed) names, so only
|
||||||
# these two lines need the AI_ prefix.
|
# these two lines need the AI_ prefix.
|
||||||
|
#
|
||||||
|
# FLOWAGENT NOTE (added alongside the ai.yaml mcpo image/secrets cutover):
|
||||||
|
# the 3 flowagent_azure_* values are provisioned as native Docker secrets
|
||||||
|
# below (Pattern C, matches every other _FILE-convention stack), NOT written
|
||||||
|
# into ai/ai.env — mcpo's flowagent entry reads them via
|
||||||
|
# /run/secrets/flowagent_azure_* (see mcp-config/flowagent/entrypoint.sh),
|
||||||
|
# not via env var, so they don't belong in this stack's Pattern B .env block.
|
||||||
ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} "FILE=/volume1/docker/compose-files/ai/ai.env
|
ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} "FILE=/volume1/docker/compose-files/ai/ai.env
|
||||||
TMP=\$FILE.tmp.\$\$
|
TMP=\$FILE.tmp.\$\$
|
||||||
grep -vE '^(AI_AWS_ACCESS_KEY_ID|AI_AWS_SECRET_ACCESS_KEY|LITELLM_MASTER_KEY|LITELLM_SALT_KEY|POSTGRES_PASSWORD|DATABASE_URL|WEBUI_SECRET_KEY|OPEN_WEBUI_DATABASE_URL|OAUTH_CLIENT_SECRET)=' \$FILE > \$TMP 2>/dev/null || touch \$TMP
|
grep -vE '^(AI_AWS_ACCESS_KEY_ID|AI_AWS_SECRET_ACCESS_KEY|LITELLM_MASTER_KEY|LITELLM_SALT_KEY|POSTGRES_PASSWORD|DATABASE_URL|WEBUI_SECRET_KEY|OPEN_WEBUI_DATABASE_URL|OAUTH_CLIENT_SECRET)=' \$FILE > \$TMP 2>/dev/null || touch \$TMP
|
||||||
@@ -294,7 +333,11 @@ steps:
|
|||||||
printf 'OAUTH_CLIENT_SECRET=%s\n' '$${AI_OAUTH_CLIENT_SECRET}'
|
printf 'OAUTH_CLIENT_SECRET=%s\n' '$${AI_OAUTH_CLIENT_SECRET}'
|
||||||
} > \$FILE
|
} > \$FILE
|
||||||
rm -f \$TMP
|
rm -f \$TMP
|
||||||
echo ' [OK] ai/ai.env secrets updated'";;
|
echo ' [OK] ai/ai.env secrets updated'
|
||||||
|
source /tmp/cs.sh
|
||||||
|
create_or_update_secret 'flowagent_azure_client_id' '$${FLOWAGENT_AZURE_CLIENT_ID}'
|
||||||
|
create_or_update_secret 'flowagent_azure_tenant_id' '$${FLOWAGENT_AZURE_TENANT_ID}'
|
||||||
|
create_or_update_secret 'flowagent_azure_client_secret' '$${FLOWAGENT_AZURE_CLIENT_SECRET}'";;
|
||||||
entertainment)
|
entertainment)
|
||||||
ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} "source /tmp/cs.sh
|
ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} "source /tmp/cs.sh
|
||||||
create_or_update_secret 'entertainment_discord_token' '$${ENTERTAINMENT_DISCORD_TOKEN}'
|
create_or_update_secret 'entertainment_discord_token' '$${ENTERTAINMENT_DISCORD_TOKEN}'
|
||||||
@@ -352,7 +395,7 @@ steps:
|
|||||||
|
|
||||||
# Bootstrap-tier guard: file synced to host, deploy is MANUAL
|
# Bootstrap-tier guard: file synced to host, deploy is MANUAL
|
||||||
case "$STACK" in
|
case "$STACK" in
|
||||||
traefik|woodpecker|postgresql|secrets)
|
traefik|woodpecker|postgresql|secrets|git)
|
||||||
echo " [BOOTSTRAP] $STACK: file synced. Deploy is MANUAL."
|
echo " [BOOTSTRAP] $STACK: file synced. Deploy is MANUAL."
|
||||||
echo " Run: bash /volume1/docker/compose-files/deploy/stack-deploy.sh $STACK"
|
echo " Run: bash /volume1/docker/compose-files/deploy/stack-deploy.sh $STACK"
|
||||||
continue ;;
|
continue ;;
|
||||||
|
|||||||
+15
-1
@@ -127,7 +127,7 @@ services:
|
|||||||
labels:
|
labels:
|
||||||
- traefik.enable=false
|
- traefik.enable=false
|
||||||
mcpo:
|
mcpo:
|
||||||
image: ghcr.io/open-webui/mcpo:main
|
image: git.bryanmail.net/homelab/flowagent-mcpo:916164714429
|
||||||
command:
|
command:
|
||||||
- --config
|
- --config
|
||||||
- /app/config/config.json
|
- /app/config/config.json
|
||||||
@@ -141,6 +141,13 @@ services:
|
|||||||
- /volume1/docker/mcpo/data:/mcpo_data
|
- /volume1/docker/mcpo/data:/mcpo_data
|
||||||
- /volume1/docker/cronicle/ssh_keys:/app/ssh_keys:ro
|
- /volume1/docker/cronicle/ssh_keys:/app/ssh_keys:ro
|
||||||
- /volume1/docker/mcpo/uv-cache:/app/uv-cache
|
- /volume1/docker/mcpo/uv-cache:/app/uv-cache
|
||||||
|
secrets:
|
||||||
|
- source: flowagent_azure_client_id
|
||||||
|
target: flowagent_azure_client_id
|
||||||
|
- source: flowagent_azure_tenant_id
|
||||||
|
target: flowagent_azure_tenant_id
|
||||||
|
- source: flowagent_azure_client_secret
|
||||||
|
target: flowagent_azure_client_secret
|
||||||
networks:
|
networks:
|
||||||
- traefik_backend
|
- traefik_backend
|
||||||
deploy:
|
deploy:
|
||||||
@@ -173,6 +180,13 @@ services:
|
|||||||
- traefik.http.middlewares.n8n.headers.STSPreload=true
|
- traefik.http.middlewares.n8n.headers.STSPreload=true
|
||||||
- traefik.http.routers.n8n.middlewares=forwardAuth-authentik@file, crowdsec@file
|
- traefik.http.routers.n8n.middlewares=forwardAuth-authentik@file, crowdsec@file
|
||||||
- traefik.swarm.network=traefik_backend
|
- traefik.swarm.network=traefik_backend
|
||||||
|
secrets:
|
||||||
|
flowagent_azure_client_id:
|
||||||
|
external: true
|
||||||
|
flowagent_azure_tenant_id:
|
||||||
|
external: true
|
||||||
|
flowagent_azure_client_secret:
|
||||||
|
external: true
|
||||||
networks:
|
networks:
|
||||||
traefik_backend:
|
traefik_backend:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
@@ -1,186 +0,0 @@
|
|||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"filesystem": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"-y",
|
|
||||||
"@modelcontextprotocol/server-filesystem",
|
|
||||||
"/mcpo_data/filesystem"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"memory": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"-y",
|
|
||||||
"@modelcontextprotocol/server-memory"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"proxmox-nuck7-1": {
|
|
||||||
"command": "sh",
|
|
||||||
"args": [
|
|
||||||
"-c",
|
|
||||||
"LOG_LEVEL=silent npx -y --package @bldg-7/proxmox-mcp@1.2.1 --package pino-pretty proxmox-mcp 2>/dev/null"
|
|
||||||
],
|
|
||||||
"env": {
|
|
||||||
"PROXMOX_HOST": "192.168.4.11",
|
|
||||||
"PROXMOX_PORT": "8006",
|
|
||||||
"PROXMOX_USER": "MCP@pve",
|
|
||||||
"PROXMOX_TOKEN_NAME": "MCP",
|
|
||||||
"PROXMOX_TOKEN_VALUE": "2052990e-749f-43f6-be7f-c9ad206281cc",
|
|
||||||
"PROXMOX_SSL_MODE": "insecure",
|
|
||||||
"PROXMOX_ALLOW_ELEVATED": "true",
|
|
||||||
"PROXMOX_SSH_ENABLED": "true",
|
|
||||||
"PROXMOX_SSH_HOST": "192.168.4.11",
|
|
||||||
"PROXMOX_SSH_PORT": "22",
|
|
||||||
"PROXMOX_SSH_USER": "root",
|
|
||||||
"PROXMOX_SSH_KEY_PATH": "/app/ssh_keys/nuc-cluster",
|
|
||||||
"PROXMOX_SSH_NODE": "nuck7-1",
|
|
||||||
"PROXMOX_ALLOW_UNSAFE_COMMANDS": "true"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"proxmox-nuck7-2": {
|
|
||||||
"command": "sh",
|
|
||||||
"args": [
|
|
||||||
"-c",
|
|
||||||
"LOG_LEVEL=silent npx -y --package @bldg-7/proxmox-mcp@1.2.1 --package pino-pretty proxmox-mcp 2>/dev/null"
|
|
||||||
],
|
|
||||||
"env": {
|
|
||||||
"PROXMOX_HOST": "192.168.4.12",
|
|
||||||
"PROXMOX_PORT": "8006",
|
|
||||||
"PROXMOX_USER": "MCP@pve",
|
|
||||||
"PROXMOX_TOKEN_NAME": "MCP",
|
|
||||||
"PROXMOX_TOKEN_VALUE": "2052990e-749f-43f6-be7f-c9ad206281cc",
|
|
||||||
"PROXMOX_SSL_MODE": "insecure",
|
|
||||||
"PROXMOX_ALLOW_ELEVATED": "true",
|
|
||||||
"PROXMOX_SSH_ENABLED": "true",
|
|
||||||
"PROXMOX_SSH_HOST": "192.168.4.12",
|
|
||||||
"PROXMOX_SSH_PORT": "22",
|
|
||||||
"PROXMOX_SSH_USER": "root",
|
|
||||||
"PROXMOX_SSH_KEY_PATH": "/app/ssh_keys/nuc-cluster",
|
|
||||||
"PROXMOX_SSH_NODE": "nuck7-2",
|
|
||||||
"PROXMOX_ALLOW_UNSAFE_COMMANDS": "true"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"proxmox-nuck7-3": {
|
|
||||||
"command": "sh",
|
|
||||||
"args": [
|
|
||||||
"-c",
|
|
||||||
"LOG_LEVEL=silent npx -y --package @bldg-7/proxmox-mcp@1.2.1 --package pino-pretty proxmox-mcp 2>/dev/null"
|
|
||||||
],
|
|
||||||
"env": {
|
|
||||||
"PROXMOX_HOST": "192.168.4.13",
|
|
||||||
"PROXMOX_PORT": "8006",
|
|
||||||
"PROXMOX_USER": "MCP@pve",
|
|
||||||
"PROXMOX_TOKEN_NAME": "MCP",
|
|
||||||
"PROXMOX_TOKEN_VALUE": "2052990e-749f-43f6-be7f-c9ad206281cc",
|
|
||||||
"PROXMOX_SSL_MODE": "insecure",
|
|
||||||
"PROXMOX_ALLOW_ELEVATED": "true",
|
|
||||||
"PROXMOX_SSH_ENABLED": "true",
|
|
||||||
"PROXMOX_SSH_HOST": "192.168.4.13",
|
|
||||||
"PROXMOX_SSH_PORT": "22",
|
|
||||||
"PROXMOX_SSH_USER": "root",
|
|
||||||
"PROXMOX_SSH_KEY_PATH": "/app/ssh_keys/nuc-cluster",
|
|
||||||
"PROXMOX_SSH_NODE": "nuck7-3",
|
|
||||||
"PROXMOX_ALLOW_UNSAFE_COMMANDS": "true"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"homeassistant": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"-y",
|
|
||||||
"mcp-remote",
|
|
||||||
"https://home.bryanmail.net/mcp_server/sse",
|
|
||||||
"--header",
|
|
||||||
"Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIxNzhmYzI0NjA2Y2I0ZTg4ODI1N2VmMzdiZTNhY2E5YSIsImlhdCI6MTc3NDY4MzAxNiwiZXhwIjoyMDkwMDQzMDE2fQ.32kY2LVHzKZHWLc96T6z2P-8beNTnp2DHRUf2UEie2w"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"teams": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"-y",
|
|
||||||
"@floriscornel/teams-mcp@latest"
|
|
||||||
],
|
|
||||||
"env": {
|
|
||||||
"TEAMS_MCP_READ_ONLY": "true",
|
|
||||||
"HOME": "/app/teams-mcp-auth"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ms365": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"-y",
|
|
||||||
"@softeria/ms-365-mcp-server@0.129.0",
|
|
||||||
"--preset",
|
|
||||||
"personal",
|
|
||||||
"--read-only",
|
|
||||||
"--discovery"
|
|
||||||
],
|
|
||||||
"env": {
|
|
||||||
"HOME": "/app/teams-mcp-auth",
|
|
||||||
"MS365_MCP_CLIENT_ID": "14d82eec-204b-4c2f-b7e8-296a70dab67e",
|
|
||||||
"MS365_MCP_TOKEN_CACHE_PATH": "/app/teams-mcp-auth/.teams-mcp-token-cache.json",
|
|
||||||
"SILENT": "true"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"unifi-network": {
|
|
||||||
"command": "uvx",
|
|
||||||
"args": [
|
|
||||||
"unifi-network-mcp@latest"
|
|
||||||
],
|
|
||||||
"env": {
|
|
||||||
"UNIFI_HOST": "192.168.4.1",
|
|
||||||
"UNIFI_USERNAME": "unifi-mcp",
|
|
||||||
"UNIFI_PASSWORD": "3dHOEOMygTYeX3",
|
|
||||||
"UNIFI_PORT": "443",
|
|
||||||
"UNIFI_VERIFY_SSL": "false",
|
|
||||||
"UV_CACHE_DIR": "/app/uv-cache"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"authentik": {
|
|
||||||
"command": "uvx",
|
|
||||||
"args": [
|
|
||||||
"authentik-diag-mcp",
|
|
||||||
"--base-url",
|
|
||||||
"https://auth.bryanmail.net",
|
|
||||||
"--token",
|
|
||||||
"LAm3lBTumOmsU8AiFQM2FmCZyoj8bTSR0FQnAcBy1QnTMEyU4oWozwdxTUap"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"gitea": {
|
|
||||||
"command": "/mcpo_data/gitea-mcp",
|
|
||||||
"args": [
|
|
||||||
"-t",
|
|
||||||
"stdio"
|
|
||||||
],
|
|
||||||
"env": {
|
|
||||||
"GITEA_HOST": "https://git.bryanmail.net",
|
|
||||||
"GITEA_ACCESS_TOKEN": "5aa3a554c001b1dbe5215cb8cb388112801930e9"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"gitea-admin": {
|
|
||||||
"command": "/mcpo_data/gitea-mcp",
|
|
||||||
"args": [
|
|
||||||
"-t",
|
|
||||||
"stdio"
|
|
||||||
],
|
|
||||||
"env": {
|
|
||||||
"GITEA_HOST": "https://git.bryanmail.net",
|
|
||||||
"GITEA_ACCESS_TOKEN": "289225b0b8f1827242191874b2408db76af06321"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"powerautomate": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"-y",
|
|
||||||
"powerautomate-mcp@latest",
|
|
||||||
"--stdio"
|
|
||||||
],
|
|
||||||
"env": {
|
|
||||||
"HOME": "/app/powerautomate-auth",
|
|
||||||
"PA_MCP_CLIENT_ID": "84b431ed-ef5d-48a0-b0a1-878cfdb71453",
|
|
||||||
"PA_MCP_TENANT_ID": "0f6cf991-c449-480a-a71b-83003ce6edc1",
|
|
||||||
"PA_CONFIG_PATH": "/app/powerautomate-auth/config.json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// Minimal MCP server (stdio, JSON-RPC 2.0) for Woodpecker CI.
|
|
||||||
// Zero dependencies — plain Node 18+ (built-in fetch). Config via env vars.
|
|
||||||
//
|
|
||||||
// Configuration (env):
|
|
||||||
// WOODPECKER_URL e.g. https://your-woodpecker.example.com
|
|
||||||
// WOODPECKER_TOKEN Woodpecker Personal Access Token (JWT)
|
|
||||||
//
|
|
||||||
// Tools:
|
|
||||||
// woodpecker_list_repos
|
|
||||||
// woodpecker_list_pipelines { repo_id, limit? }
|
|
||||||
// woodpecker_get_pipeline { repo_id, number } -> status + workflow/step tree
|
|
||||||
// woodpecker_pipeline_logs { repo_id, number, step_id, tail? } -> decoded step logs
|
|
||||||
|
|
||||||
import { Buffer } from "node:buffer";
|
|
||||||
|
|
||||||
const BASE = (process.env.WOODPECKER_URL || "").replace(/\/+$/, "");
|
|
||||||
const TOKEN = process.env.WOODPECKER_TOKEN || "";
|
|
||||||
|
|
||||||
function log(...a) {
|
|
||||||
// Diagnostics go to stderr so they never corrupt the stdout JSON-RPC stream.
|
|
||||||
process.stderr.write("[woodpecker-mcp] " + a.join(" ") + "\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function api(path) {
|
|
||||||
if (!BASE || !TOKEN) throw new Error("Missing WOODPECKER_URL or WOODPECKER_TOKEN in environment");
|
|
||||||
const res = await fetch(`${BASE}/api${path}`, {
|
|
||||||
headers: { Authorization: `Bearer ${TOKEN}`, Accept: "application/json" },
|
|
||||||
});
|
|
||||||
const body = await res.text();
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status} ${path}: ${body.slice(0, 300)}`);
|
|
||||||
return body ? JSON.parse(body) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- tool response formatting ---
|
|
||||||
|
|
||||||
function summarizePipeline(p) {
|
|
||||||
const lines = [];
|
|
||||||
lines.push(`pipeline #${p.number} (id=${p.id}) status=${p.status} event=${p.event} branch=${p.branch}`);
|
|
||||||
lines.push(`commit=${(p.commit || "").slice(0, 12)} author=${p.author}`);
|
|
||||||
if (p.message) lines.push(`message: ${p.message.split("\n")[0]}`);
|
|
||||||
for (const wf of p.workflows || []) {
|
|
||||||
lines.push(` WORKFLOW "${wf.name}" (pid=${wf.pid}) state=${wf.state}${wf.error ? ` error=${wf.error}` : ""}`);
|
|
||||||
for (const c of wf.children || []) {
|
|
||||||
lines.push(
|
|
||||||
` step "${c.name}" (id=${c.id}, pid=${c.pid}) ${c.state} exit=${c.exit_code ?? "-"} type=${c.type}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeLogs(entries) {
|
|
||||||
if (!Array.isArray(entries)) return String(entries);
|
|
||||||
return entries
|
|
||||||
.map((e) => {
|
|
||||||
const d = e?.data;
|
|
||||||
if (d == null) return "";
|
|
||||||
try {
|
|
||||||
return Buffer.from(d, "base64").toString("utf-8");
|
|
||||||
} catch {
|
|
||||||
return String(d);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
const TOOLS = [
|
|
||||||
{
|
|
||||||
name: "woodpecker_list_repos",
|
|
||||||
description: "List repositories the token can access (id, full name, default branch).",
|
|
||||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
||||||
handler: async () => {
|
|
||||||
const repos = await api(`/user/repos`);
|
|
||||||
return (repos || [])
|
|
||||||
.map((r) => `id=${r.id} ${r.full_name} default_branch=${r.default_branch}`)
|
|
||||||
.join("\n") || "(no repositories)";
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "woodpecker_list_pipelines",
|
|
||||||
description: "Recent pipelines for a repository. Params: repo_id (number), limit (number, default 20).",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
repo_id: { type: "number", description: "Woodpecker repository ID" },
|
|
||||||
limit: { type: "number", description: "How many pipelines to return (default 20)" },
|
|
||||||
},
|
|
||||||
required: ["repo_id"],
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
handler: async ({ repo_id, limit }) => {
|
|
||||||
const list = await api(`/repos/${repo_id}/pipelines?perPage=${limit || 20}`);
|
|
||||||
return (list || [])
|
|
||||||
.map(
|
|
||||||
(p) =>
|
|
||||||
`#${p.number} ${p.status.padEnd(8)} ${p.event.padEnd(12)} ${p.branch} ${(p.commit || "").slice(0, 8)} ${(p.message || "").split("\n")[0]}`
|
|
||||||
)
|
|
||||||
.join("\n") || "(no pipelines)";
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "woodpecker_get_pipeline",
|
|
||||||
description: "Pipeline details: status plus the workflow/step tree (with step ids for fetching logs). Params: repo_id, number.",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
repo_id: { type: "number" },
|
|
||||||
number: { type: "number", description: "Pipeline number (as shown in the UI)" },
|
|
||||||
},
|
|
||||||
required: ["repo_id", "number"],
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
handler: async ({ repo_id, number }) => {
|
|
||||||
const p = await api(`/repos/${repo_id}/pipelines/${number}`);
|
|
||||||
return summarizePipeline(p);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "woodpecker_pipeline_logs",
|
|
||||||
description: "Decoded logs for a single step. Params: repo_id, number (pipeline), step_id (from woodpecker_get_pipeline). Optional tail (last N lines).",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
repo_id: { type: "number" },
|
|
||||||
number: { type: "number" },
|
|
||||||
step_id: { type: "number", description: "Step id from woodpecker_get_pipeline" },
|
|
||||||
tail: { type: "number", description: "Return only the last N lines (optional)" },
|
|
||||||
},
|
|
||||||
required: ["repo_id", "number", "step_id"],
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
handler: async ({ repo_id, number, step_id, tail }) => {
|
|
||||||
const entries = await api(`/repos/${repo_id}/logs/${number}/${step_id}`);
|
|
||||||
let txt = decodeLogs(entries);
|
|
||||||
if (tail && tail > 0) {
|
|
||||||
txt = txt.split("\n").slice(-tail).join("\n");
|
|
||||||
}
|
|
||||||
return txt || "(no logs)";
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// --- JSON-RPC over stdio loop ---
|
|
||||||
|
|
||||||
function send(msg) {
|
|
||||||
process.stdout.write(JSON.stringify(msg) + "\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function reply(id, result) {
|
|
||||||
send({ jsonrpc: "2.0", id, result });
|
|
||||||
}
|
|
||||||
|
|
||||||
function replyError(id, code, message) {
|
|
||||||
send({ jsonrpc: "2.0", id, error: { code, message } });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handle(req) {
|
|
||||||
const { id, method, params } = req;
|
|
||||||
if (method === "initialize") {
|
|
||||||
reply(id, {
|
|
||||||
protocolVersion: params?.protocolVersion || "2024-11-05",
|
|
||||||
capabilities: { tools: {} },
|
|
||||||
serverInfo: { name: "woodpecker-mcp", version: "1.0.0" },
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (method === "notifications/initialized" || method === "notifications/cancelled") {
|
|
||||||
return; // notifications carry no response
|
|
||||||
}
|
|
||||||
if (method === "ping") {
|
|
||||||
reply(id, {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (method === "tools/list") {
|
|
||||||
reply(id, {
|
|
||||||
tools: TOOLS.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (method === "tools/call") {
|
|
||||||
const tool = TOOLS.find((t) => t.name === params?.name);
|
|
||||||
if (!tool) {
|
|
||||||
replyError(id, -32602, `Unknown tool: ${params?.name}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const text = await tool.handler(params.arguments || {});
|
|
||||||
reply(id, { content: [{ type: "text", text }] });
|
|
||||||
} catch (e) {
|
|
||||||
reply(id, { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (id !== undefined) replyError(id, -32601, `Unsupported method: ${method}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let buf = "";
|
|
||||||
process.stdin.setEncoding("utf-8");
|
|
||||||
process.stdin.on("data", (chunk) => {
|
|
||||||
buf += chunk;
|
|
||||||
let nl;
|
|
||||||
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
||||||
const line = buf.slice(0, nl).trim();
|
|
||||||
buf = buf.slice(nl + 1);
|
|
||||||
if (!line) continue;
|
|
||||||
let req;
|
|
||||||
try {
|
|
||||||
req = JSON.parse(line);
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
handle(req).catch((e) => log("handler error:", e.message));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
process.stdin.on("end", () => process.exit(0));
|
|
||||||
log("ready", BASE ? `(${BASE})` : "(WOODPECKER_URL is not set!)");
|
|
||||||
Reference in New Issue
Block a user