Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 66 additions & 20 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,15 @@ jobs:
env:
DEPLOY_HOST: 10.71.20.63
DEPLOY_USER: rico
SERVICE_NAME: mcp2cli
BINARY_PATH: /usr/local/bin/mcp2cli
BINARY_PATH: /home/rico/.local/bin/mcp2cli
DEPLOY_DIR: /home/rico/.local/share/mcp2cli
ENV_FILE: /home/rico/.config/mcp2cli/env
PID_FILE: /home/rico/.local/share/mcp2cli/run/mcp2cli.pid
LOG_FILE: /home/rico/.local/state/mcp2cli/log/mcp2cli.log
STAGED_BINARY: /home/rico/.local/share/mcp2cli/mcp2cli-new
BACKUP_PATH: /home/rico/.local/share/mcp2cli/backups/mcp2cli.bak
RESTART_HELPER: /home/rico/.local/share/mcp2cli/restart-hosted-daemon.sh
VAULTWARDEN_REMOTE_URL: http://127.0.0.1:9500
HEALTH_URL: http://10.71.20.63:9500/health

steps:
Expand All @@ -93,20 +100,40 @@ jobs:
- name: Backup current binary
run: |
ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \
"sudo cp ${{ env.BINARY_PATH }} ${{ env.BINARY_PATH }}.bak 2>/dev/null || true"
"mkdir -p ${{ env.DEPLOY_DIR }}/backups ${{ env.DEPLOY_DIR }}/run /home/rico/.local/bin /home/rico/.local/state/mcp2cli/log && \
if [ -f ${{ env.BINARY_PATH }} ]; then \
cp ${{ env.BINARY_PATH }} ${{ env.BACKUP_PATH }}; \
fi"

- name: Deploy restart helper
run: |
scp scripts/deploy/restart-hosted-daemon.sh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }}:${{ env.RESTART_HELPER }}
ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \
"chmod 755 ${{ env.RESTART_HELPER }} && \
sh -n ${{ env.RESTART_HELPER }} && \
BINARY_PATH=${{ env.BINARY_PATH }} \
ENV_FILE=${{ env.ENV_FILE }} \
PID_FILE=${{ env.PID_FILE }} \
LOG_FILE=${{ env.LOG_FILE }} \
${{ env.RESTART_HELPER }} --check"

- name: Deploy new binary
id: deploy_binary
run: |
scp dist/mcp2cli ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }}:/tmp/mcp2cli-new
scp dist/mcp2cli ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }}:${{ env.STAGED_BINARY }}
ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \
"sudo mv /tmp/mcp2cli-new ${{ env.BINARY_PATH }} && \
sudo chmod +x ${{ env.BINARY_PATH }} && \
sudo chown mcp2cli:mcp2cli ${{ env.BINARY_PATH }}"
"mv ${{ env.STAGED_BINARY }} ${{ env.BINARY_PATH }} && \
chmod 755 ${{ env.BINARY_PATH }}"

- name: Restart service
- name: Restart hosted daemon
run: |
ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \
"sudo systemctl restart ${{ env.SERVICE_NAME }}"
"BINARY_PATH=${{ env.BINARY_PATH }} \
ENV_FILE=${{ env.ENV_FILE }} \
PID_FILE=${{ env.PID_FILE }} \
LOG_FILE=${{ env.LOG_FILE }} \
VAULTWARDEN_REMOTE_URL=${{ env.VAULTWARDEN_REMOTE_URL }} \
${{ env.RESTART_HELPER }}"

- name: Health check (with retry)
run: |
Expand All @@ -115,6 +142,10 @@ jobs:
if ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} "curl -sf --max-time 5 http://localhost:9500/health" > /dev/null 2>&1; then
echo "Health check passed (attempt $i)"
ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} "curl -sf http://localhost:9500/health" | jq '{version, activeConnections, configuredServices}'
ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \
"pid=\$(cat ${{ env.PID_FILE }}); \
test \"\$(readlink /proc/\$pid/exe)\" = '${{ env.BINARY_PATH }}'; \
kill -0 \"\$pid\""
exit 0
fi
echo "Health check attempt $i failed, retrying..."
Expand All @@ -123,38 +154,53 @@ jobs:
exit 1

- name: Rollback on failure
if: failure()
if: failure() && steps.deploy_binary.outcome == 'success'
run: |
echo "Deployment failed -- rolling back to previous binary"
ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \
"if [ -f ${{ env.BINARY_PATH }}.bak ]; then \
sudo mv ${{ env.BINARY_PATH }}.bak ${{ env.BINARY_PATH }} && \
sudo systemctl restart ${{ env.SERVICE_NAME }} && \
echo 'Rollback complete'; \
else \
echo 'No backup found -- manual intervention required'; \
exit 1; \
fi"
'set -eu
if [ -f "${{ env.BACKUP_PATH }}" ]; then
mv "${{ env.BACKUP_PATH }}" "${{ env.BINARY_PATH }}"
chmod 755 "${{ env.BINARY_PATH }}"
BINARY_PATH="${{ env.BINARY_PATH }}" \
ENV_FILE="${{ env.ENV_FILE }}" \
PID_FILE="${{ env.PID_FILE }}" \
LOG_FILE="${{ env.LOG_FILE }}" \
VAULTWARDEN_REMOTE_URL="${{ env.VAULTWARDEN_REMOTE_URL }}" \
"${{ env.RESTART_HELPER }}"
echo "Rollback complete"
else
echo "No backup found -- manual intervention required"
exit 1
fi'

- name: Verify rollback health
if: failure()
run: |
sleep 5
if ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} "curl -sf --max-time 5 http://localhost:9500/health" > /dev/null 2>&1; then
echo "Rollback health check passed -- service restored"
ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \
"pid=\$(cat ${{ env.PID_FILE }}); \
test \"\$(readlink /proc/\$pid/exe)\" = '${{ env.BINARY_PATH }}'; \
kill -0 \"\$pid\""
else
echo "WARNING: Rollback health check failed -- service may be down"
exit 1
fi

- name: Clean up backup
- name: Archive backup
if: success()
run: |
ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \
"sudo rm -f ${{ env.BINARY_PATH }}.bak"
"if [ -f ${{ env.BACKUP_PATH }} ]; then \
mv ${{ env.BACKUP_PATH }} ${{ env.DEPLOY_DIR }}/backups/mcp2cli-${{ github.sha }}.bak; \
fi"

- name: Report deployed version
if: success()
run: |
echo "Deployed version: ${{ needs.version-bump.outputs.new_version }}"
ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} "curl -sf http://localhost:9500/health" | jq '{version, activeConnections, configuredServices}'
ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \
"pid=\$(cat ${{ env.PID_FILE }}); ps -p \"\$pid\" -o pid=,ppid=,user=,command="
86 changes: 86 additions & 0 deletions scripts/deploy/restart-hosted-daemon.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#!/usr/bin/env sh
set -eu

: "${BINARY_PATH:?BINARY_PATH is required}"
: "${ENV_FILE:?ENV_FILE is required}"
: "${PID_FILE:?PID_FILE is required}"
: "${LOG_FILE:?LOG_FILE is required}"

mode="${1:-restart}"

if [ ! -r "$ENV_FILE" ]; then
echo "Env file is missing or unreadable: $ENV_FILE" >&2
exit 1
fi

if [ ! -x "$BINARY_PATH" ]; then
echo "Binary is missing or not executable: $BINARY_PATH" >&2
exit 1
fi

(set -a; . "$ENV_FILE"; : )

if [ "$mode" = "--check" ]; then
exit 0
fi

mkdir -p "$(dirname "$PID_FILE")"
mkdir -p "$(dirname "$LOG_FILE")"

if [ -f "$PID_FILE" ]; then
old_pid="$(cat "$PID_FILE")"
if [ -n "$old_pid" ] && [ "$old_pid" != "0" ] && kill -0 "$old_pid" 2>/dev/null; then
old_exe="$(readlink "/proc/$old_pid/exe" 2>/dev/null || true)"
if [ "$old_exe" = "$BINARY_PATH" ]; then
kill "$old_pid" 2>/dev/null || true
fi
fi
fi

pgrep -u "$(id -un)" -f "^$BINARY_PATH$" | while read -r old_pid; do
kill "$old_pid" 2>/dev/null || true
done

for _ in 1 2 3 4 5; do
if pgrep -u "$(id -un)" -f "^$BINARY_PATH$" >/dev/null 2>&1; then
sleep 1
else
break
fi
done

pgrep -u "$(id -un)" -f "^$BINARY_PATH$" | while read -r old_pid; do
kill -KILL "$old_pid" 2>/dev/null || true
done

set -a
. "$ENV_FILE"
MCP2CLI_DAEMON=1
MCP2CLI_LISTEN_HOST="${MCP2CLI_LISTEN_HOST:-0.0.0.0}"
MCP2CLI_LISTEN_PORT="${MCP2CLI_LISTEN_PORT:-9500}"
MCP2CLI_VAULTWARDEN_REMOTE_URL="${VAULTWARDEN_REMOTE_URL:-${MCP2CLI_VAULTWARDEN_REMOTE_URL:-http://127.0.0.1:9500}}"
set +a

nohup "$BINARY_PATH" < /dev/null > "$LOG_FILE" 2>&1 &
pid="$!"
echo "$pid" > "$PID_FILE"

for _ in 1 2 3 4 5 6 7 8 9 10; do
if kill -0 "$pid" 2>/dev/null && curl -sf --max-time 2 "http://127.0.0.1:${MCP2CLI_LISTEN_PORT}/health" >/dev/null 2>&1; then
break
fi
sleep 1
done

if ! kill -0 "$pid" 2>/dev/null; then
tail -n 80 "$LOG_FILE" || true
exit 1
fi

actual_exe="$(readlink "/proc/$pid/exe")"
if [ "$actual_exe" != "$BINARY_PATH" ]; then
echo "Service started unexpected binary: $actual_exe" >&2
exit 1
fi

curl -sf --max-time 5 "http://127.0.0.1:${MCP2CLI_LISTEN_PORT}/health" >/dev/null
128 changes: 127 additions & 1 deletion src/secrets/refs.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { createLogger } from "../logger/index.ts";
import type { ServiceConfig } from "../config/index.ts";
import { readFile } from "node:fs/promises";
import { join } from "node:path";

const log = createLogger("secret-refs");
const SECRET_REF_PATTERN = /\$\{secret:([^}]+)\}/g;
Expand Down Expand Up @@ -104,9 +106,14 @@ function parseSecretRef(ref: string): { query: string; field?: string } {
}

async function fetchVaultwardenCredential(query: string): Promise<unknown> {
const timeoutMs = resolveTimeoutMs();
const remote = await getVaultwardenRemoteConfig();
if (remote) {
return fetchVaultwardenCredentialViaDaemon(query, remote, timeoutMs);
}

const command = process.env.MCP2CLI_VAULTWARDEN_COMMAND ?? "mcp2cli";
const commandArgs = parseCommandArgs(process.env.MCP2CLI_VAULTWARDEN_COMMAND_ARGS);
const timeoutMs = resolveTimeoutMs();
const proc = Bun.spawn([
command,
...commandArgs,
Expand Down Expand Up @@ -157,6 +164,116 @@ async function fetchVaultwardenCredential(query: string): Promise<unknown> {
}
}

async function getVaultwardenRemoteConfig(): Promise<{ url: string; token?: string } | null> {
const explicitUrl = process.env.MCP2CLI_VAULTWARDEN_REMOTE_URL;
const inheritedUrl =
process.env.MCP2CLI_VAULTWARDEN_USE_DAEMON === "1"
? process.env.MCP2CLI_REMOTE_URL ?? process.env.MCP_HOST
: undefined;
const url = explicitUrl ?? inheritedUrl;
if (!url) return null;
return {
url,
token: await resolveVaultwardenRemoteToken(url),
};
}

async function resolveVaultwardenRemoteToken(url: string): Promise<string | undefined> {
const token =
process.env.MCP2CLI_VAULTWARDEN_AUTH_TOKEN ??
process.env.MCP2CLI_AUTH_TOKEN ??
process.env.MCP_TOKEN ??
await readDaemonTokenFile();

if (!token) return undefined;
if (shouldAttachRemoteAuth(url)) return token;

throw new SecretResolutionError(
`Refusing to forward Vaultwarden daemon auth to non-loopback URL: ${redactUrl(url)}`,
);
}

async function readDaemonTokenFile(): Promise<string | undefined> {
const tokensPath =
process.env.MCP2CLI_TOKENS_FILE ??
join(process.env.HOME ?? "", ".config", "mcp2cli", "tokens.json");
if (!tokensPath) return undefined;

try {
const parsed = JSON.parse(await readFile(tokensPath, "utf8")) as {
tokens?: Array<{ token?: unknown; role?: unknown; expiresAt?: unknown }>;
};
const tokenEntry = parsed.tokens?.find((entry) =>
entry.role === "admin" &&
typeof entry.token === "string" &&
!isExpiredToken(typeof entry.expiresAt === "string" ? entry.expiresAt : undefined)
);
return typeof tokenEntry?.token === "string" ? tokenEntry.token : undefined;
} catch {
return undefined;
}
}

function isExpiredToken(expiresAt: string | undefined): boolean {
if (!expiresAt) return false;
const expiresAtMs = Date.parse(expiresAt);
return Number.isNaN(expiresAtMs) || expiresAtMs <= Date.now();
}

function shouldAttachRemoteAuth(url: string): boolean {
if (process.env.MCP2CLI_VAULTWARDEN_ALLOW_REMOTE_AUTH === "1") return true;
try {
const parsed = new URL(url);
const hostname = parsed.hostname.toLowerCase();
return hostname === "localhost" || hostname === "::1" || hostname.startsWith("127.");
} catch {
return false;
}
}

async function fetchVaultwardenCredentialViaDaemon(
query: string,
remote: { url: string; token?: string },
timeoutMs: number,
): Promise<unknown> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (remote.token) {
headers.Authorization = `Bearer ${remote.token}`;
}

let response: Response;
try {
response = await fetch(`${remote.url.replace(/\/$/, "")}/call`, {
method: "POST",
headers,
body: JSON.stringify({
service: "vaultwarden-secrets",
tool: "get_credential",
params: { query },
}),
signal: AbortSignal.timeout(timeoutMs),
});
} catch (err) {
const timedOut = err instanceof Error && err.name === "TimeoutError";
throw new SecretResolutionError(
`Vaultwarden lookup failed for ${redactRef(query)}${timedOut ? " (timeout)" : ""}`,
);
}

if (!response.ok) {
throw new SecretResolutionError(`Vaultwarden lookup failed for ${redactRef(query)}`);
}

try {
const parsed = await response.json();
return unwrapMcpResult(parsed);
} catch {
throw new SecretResolutionError(`Vaultwarden lookup returned non-JSON output for ${redactRef(query)}`);
}
}

function parseCommandArgs(raw: string | undefined): string[] {
if (!raw?.trim()) return [];
try {
Expand Down Expand Up @@ -213,3 +330,12 @@ function getPath(value: unknown, path: string): unknown {
function redactRef(ref: string): string {
return ref.length <= 4 ? "***" : `${ref.slice(0, 4)}***`;
}

function redactUrl(url: string): string {
try {
const parsed = new URL(url);
return `${parsed.protocol}//${parsed.host}`;
} catch {
return "***";
}
}
Loading