Compare commits
3
Commits
cf87fdba1a
...
fe60a0365d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe60a0365d
|
||
|
|
edf6756d2d
|
||
|
|
80a8ea097a
|
No files matched your search
@@ -0,0 +1,10 @@
|
|||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
deploy
|
||||||
|
*.md
|
||||||
|
*.service
|
||||||
|
example.env
|
||||||
|
.env
|
||||||
|
test_live.py
|
||||||
|
test_smoke.py
|
||||||
|
install.sh
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
name: Build and publish Docker image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ['v*']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Resolve registry and image metadata
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
host="${GITEA_SERVER_URL#*://}"
|
||||||
|
image="$host/${GITEA_REPOSITORY,,}"
|
||||||
|
echo "host=$host" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "image=$image" >> "$GITHUB_OUTPUT"
|
||||||
|
if [ "$GITEA_REF_TYPE" = "tag" ]; then
|
||||||
|
echo "tag=$GITEA_REF_NAME" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "tag=latest" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
env:
|
||||||
|
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
GITEA_REF_TYPE: ${{ gitea.ref_type }}
|
||||||
|
GITEA_REF_NAME: ${{ gitea.ref_name }}
|
||||||
|
|
||||||
|
- name: Log in to the Gitea container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ steps.meta.outputs.host }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.tag }}
|
||||||
|
${{ steps.meta.outputs.image }}:${{ gitea.sha }}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY pyproject.toml ./
|
||||||
|
COPY bookstack_mcp ./bookstack_mcp
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir . \
|
||||||
|
&& useradd --system --create-home --home-dir /home/bookstack-mcp --shell /usr/sbin/nologin bookstack-mcp
|
||||||
|
|
||||||
|
# fastmcp writes OAuth client registrations/caches under $HOME; point it at the
|
||||||
|
# service user's own home instead of the default root-owned locations.
|
||||||
|
ENV HOME=/home/bookstack-mcp \
|
||||||
|
XDG_DATA_HOME=/home/bookstack-mcp/.local \
|
||||||
|
XDG_CONFIG_HOME=/home/bookstack-mcp/.config \
|
||||||
|
XDG_CACHE_HOME=/home/bookstack-mcp/.cache \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
USER bookstack-mcp
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["python", "-m", "bookstack_mcp"]
|
||||||
+3
-3
@@ -16,7 +16,7 @@ cd bookstack-mcp
|
|||||||
You should now be able to run this with no errors — all three paths must exist:
|
You should now be able to run this with no errors — all three paths must exist:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ls pyproject.toml bookstack_mcp/server.py deploy/lxc/install.sh
|
ls pyproject.toml bookstack_mcp/server.py scripts/install-lxc.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## 1. Install
|
## 1. Install
|
||||||
@@ -24,7 +24,7 @@ ls pyproject.toml bookstack_mcp/server.py deploy/lxc/install.sh
|
|||||||
Requires Debian 12+ or Ubuntu 24.04+ (Python 3.11 or newer).
|
Requires Debian 12+ or Ubuntu 24.04+ (Python 3.11 or newer).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash deploy/lxc/install.sh
|
bash scripts/install-lxc.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This creates the `bookstack-mcp` service user, a venv at
|
This creates the `bookstack-mcp` service user, a venv at
|
||||||
@@ -61,7 +61,7 @@ MCP_STATIC_TOKENS=<openssl rand -hex 32>
|
|||||||
```
|
```
|
||||||
|
|
||||||
Add claude.ai later by switching to `MCP_AUTH_MODE=oidc+token` and filling in
|
Add claude.ai later by switching to `MCP_AUTH_MODE=oidc+token` and filling in
|
||||||
the OIDC block — see `deploy/authelia/configuration.snippet.yml`.
|
the OIDC block — see `examples/authelia/configuration.snippet.yml`.
|
||||||
|
|
||||||
## 4. Start
|
## 4. Start
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ Discovery URLs by provider:
|
|||||||
| Zitadel | `https://auth.example.com/.well-known/openid-configuration` |
|
| Zitadel | `https://auth.example.com/.well-known/openid-configuration` |
|
||||||
|
|
||||||
**Authelia users:** a ready-made config snippet is in
|
**Authelia users:** a ready-made config snippet is in
|
||||||
[`deploy/authelia/configuration.snippet.yml`](deploy/authelia/configuration.snippet.yml).
|
[`examples/authelia/configuration.snippet.yml`](examples/authelia/configuration.snippet.yml).
|
||||||
Three things are easy to miss and each one produces a confusing failure:
|
Three things are easy to miss and each one produces a confusing failure:
|
||||||
|
|
||||||
1. **Bypass forward-auth for `mcp.example.com`.** If the host sits behind
|
1. **Bypass forward-auth for `mcp.example.com`.** If the host sits behind
|
||||||
@@ -211,7 +211,7 @@ Then inside the container:
|
|||||||
```bash
|
```bash
|
||||||
apt update && apt install -y git
|
apt update && apt install -y git
|
||||||
git clone <your-repo> /tmp/bookstack-mcp && cd /tmp/bookstack-mcp
|
git clone <your-repo> /tmp/bookstack-mcp && cd /tmp/bookstack-mcp
|
||||||
bash deploy/lxc/install.sh
|
bash scripts/install-lxc.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer creates a `bookstack-mcp` system user, builds a venv at
|
The installer creates a `bookstack-mcp` system user, builds a venv at
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=BookStack MCP Server
|
|
||||||
Documentation=https://github.com/your/bookstack-mcp
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=exec
|
|
||||||
User=bookstack-mcp
|
|
||||||
Group=bookstack-mcp
|
|
||||||
|
|
||||||
EnvironmentFile=/etc/bookstack-mcp/env
|
|
||||||
WorkingDirectory=/opt/bookstack-mcp
|
|
||||||
ExecStart=/opt/bookstack-mcp/venv/bin/python -m bookstack_mcp
|
|
||||||
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=5s
|
|
||||||
TimeoutStopSec=20s
|
|
||||||
|
|
||||||
# --- Hardening ---
|
|
||||||
# These all work in an unprivileged Proxmox/LXD container. If the service fails
|
|
||||||
# with a 226/NAMESPACE or 227/SECCOMP status on an older kernel, comment out the
|
|
||||||
# block below and it will start fine -- it only reduces defence in depth.
|
|
||||||
NoNewPrivileges=yes
|
|
||||||
PrivateTmp=yes
|
|
||||||
ProtectSystem=strict
|
|
||||||
ProtectHome=yes
|
|
||||||
ProtectControlGroups=yes
|
|
||||||
RestrictSUIDSGID=yes
|
|
||||||
RemoveIPC=yes
|
|
||||||
LockPersonality=yes
|
|
||||||
MemoryDenyWriteExecute=yes
|
|
||||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
|
||||||
CapabilityBoundingSet=
|
|
||||||
AmbientCapabilities=
|
|
||||||
|
|
||||||
# Journald captures stdout/stderr: journalctl -u bookstack-mcp -f
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
SyslogIdentifier=bookstack-mcp
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
"""Configuration, loaded from environment variables."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
|
|
||||||
def _bool(name: str, default: bool = False) -> bool:
|
|
||||||
raw = os.environ.get(name)
|
|
||||||
if raw is None:
|
|
||||||
return default
|
|
||||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
||||||
|
|
||||||
|
|
||||||
def _list(name: str) -> list[str]:
|
|
||||||
raw = os.environ.get(name, "")
|
|
||||||
return [item.strip() for item in raw.split(",") if item.strip()]
|
|
||||||
|
|
||||||
|
|
||||||
def _require(name: str) -> str:
|
|
||||||
value = os.environ.get(name, "").strip()
|
|
||||||
if not value:
|
|
||||||
raise RuntimeError(f"Missing required environment variable: {name}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Settings:
|
|
||||||
# --- BookStack ---
|
|
||||||
bookstack_url: str
|
|
||||||
bookstack_token_id: str
|
|
||||||
bookstack_token_secret: str
|
|
||||||
bookstack_verify_tls: bool = True
|
|
||||||
request_timeout: float = 30.0
|
|
||||||
|
|
||||||
# --- Behaviour ---
|
|
||||||
read_only: bool = True
|
|
||||||
allow_delete: bool = False
|
|
||||||
max_content_chars: int = 40_000
|
|
||||||
|
|
||||||
# --- Transport ---
|
|
||||||
host: str = "0.0.0.0"
|
|
||||||
port: int = 8080
|
|
||||||
mcp_path: str = "/mcp"
|
|
||||||
|
|
||||||
# --- Auth (client -> this MCP server) ---
|
|
||||||
auth_mode: str = "none" # none | token | github | github+token
|
|
||||||
static_tokens: list[str] = field(default_factory=list)
|
|
||||||
public_url: str = ""
|
|
||||||
github_client_id: str = ""
|
|
||||||
github_client_secret: str = ""
|
|
||||||
allowed_hosts: list[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
# --- OIDC (your own IdP: Authentik, Keycloak, Authelia, Zitadel, ...) ---
|
|
||||||
oidc_config_url: str = ""
|
|
||||||
oidc_client_id: str = ""
|
|
||||||
oidc_client_secret: str = ""
|
|
||||||
oidc_verify_id_token: bool = False
|
|
||||||
oidc_groups_claim: str = "groups"
|
|
||||||
oidc_scopes: list[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
# --- Who may use the server (applies to both GitHub and OIDC) ---
|
|
||||||
allowed_users: list[str] = field(default_factory=list)
|
|
||||||
required_groups: list[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_env(cls) -> "Settings":
|
|
||||||
auth_mode = os.environ.get("MCP_AUTH_MODE", "none").strip().lower()
|
|
||||||
valid_modes = {
|
|
||||||
"none", "token",
|
|
||||||
"github", "github+token",
|
|
||||||
"oidc", "oidc+token",
|
|
||||||
}
|
|
||||||
if auth_mode not in valid_modes:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"MCP_AUTH_MODE must be one of {'|'.join(sorted(valid_modes))}, got {auth_mode!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
settings = cls(
|
|
||||||
bookstack_url=_require("BOOKSTACK_URL").rstrip("/"),
|
|
||||||
bookstack_token_id=_require("BOOKSTACK_TOKEN_ID"),
|
|
||||||
bookstack_token_secret=_require("BOOKSTACK_TOKEN_SECRET"),
|
|
||||||
bookstack_verify_tls=_bool("BOOKSTACK_VERIFY_TLS", True),
|
|
||||||
request_timeout=float(os.environ.get("BOOKSTACK_TIMEOUT", "30")),
|
|
||||||
read_only=_bool("BOOKSTACK_READ_ONLY", True),
|
|
||||||
allow_delete=_bool("BOOKSTACK_ALLOW_DELETE", False),
|
|
||||||
max_content_chars=int(os.environ.get("MCP_MAX_CONTENT_CHARS", "40000")),
|
|
||||||
host=os.environ.get("MCP_HOST", "0.0.0.0"),
|
|
||||||
port=int(os.environ.get("MCP_PORT", "8080")),
|
|
||||||
mcp_path=os.environ.get("MCP_PATH", "/mcp"),
|
|
||||||
auth_mode=auth_mode,
|
|
||||||
static_tokens=_list("MCP_STATIC_TOKENS"),
|
|
||||||
public_url=os.environ.get("MCP_PUBLIC_URL", "").rstrip("/"),
|
|
||||||
github_client_id=os.environ.get("GITHUB_CLIENT_ID", ""),
|
|
||||||
github_client_secret=os.environ.get("GITHUB_CLIENT_SECRET", ""),
|
|
||||||
allowed_hosts=_list("MCP_ALLOWED_HOSTS"),
|
|
||||||
oidc_config_url=os.environ.get("OIDC_CONFIG_URL", "").strip(),
|
|
||||||
oidc_client_id=os.environ.get("OIDC_CLIENT_ID", ""),
|
|
||||||
oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET", ""),
|
|
||||||
oidc_verify_id_token=_bool("OIDC_VERIFY_ID_TOKEN", False),
|
|
||||||
oidc_groups_claim=os.environ.get("OIDC_GROUPS_CLAIM", "groups"),
|
|
||||||
oidc_scopes=_list("OIDC_SCOPES"),
|
|
||||||
# GITHUB_ALLOWED_USERS is the old name, still honoured.
|
|
||||||
allowed_users=[
|
|
||||||
u.lower() for u in (_list("MCP_ALLOWED_USERS") or _list("GITHUB_ALLOWED_USERS"))
|
|
||||||
],
|
|
||||||
required_groups=[g.lower() for g in _list("MCP_REQUIRED_GROUPS")],
|
|
||||||
)
|
|
||||||
settings.validate()
|
|
||||||
return settings
|
|
||||||
|
|
||||||
def validate(self) -> None:
|
|
||||||
if "token" in self.auth_mode and not self.static_tokens:
|
|
||||||
raise RuntimeError("MCP_AUTH_MODE includes 'token' but MCP_STATIC_TOKENS is empty")
|
|
||||||
if "github" in self.auth_mode:
|
|
||||||
if not (self.github_client_id and self.github_client_secret):
|
|
||||||
raise RuntimeError(
|
|
||||||
"MCP_AUTH_MODE includes 'github' but GITHUB_CLIENT_ID / "
|
|
||||||
"GITHUB_CLIENT_SECRET are not set"
|
|
||||||
)
|
|
||||||
if "oidc" in self.auth_mode:
|
|
||||||
if not self.oidc_config_url:
|
|
||||||
raise RuntimeError(
|
|
||||||
"MCP_AUTH_MODE includes 'oidc' but OIDC_CONFIG_URL is not set. "
|
|
||||||
"This is your provider's discovery document, e.g. "
|
|
||||||
"https://auth.example.com/application/o/bookstack-mcp/.well-known/openid-configuration"
|
|
||||||
)
|
|
||||||
if not (self.oidc_client_id and self.oidc_client_secret):
|
|
||||||
raise RuntimeError(
|
|
||||||
"MCP_AUTH_MODE includes 'oidc' but OIDC_CLIENT_ID / "
|
|
||||||
"OIDC_CLIENT_SECRET are not set"
|
|
||||||
)
|
|
||||||
if ("github" in self.auth_mode or "oidc" in self.auth_mode) and not self.public_url:
|
|
||||||
raise RuntimeError(
|
|
||||||
"MCP_PUBLIC_URL is required for OAuth (e.g. https://mcp.example.com)"
|
|
||||||
)
|
|
||||||
if not self.mcp_path.startswith("/"):
|
|
||||||
raise RuntimeError("MCP_PATH must start with '/'")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def api_base(self) -> str:
|
|
||||||
return f"{self.bookstack_url}/api"
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
# Authelia configuration for the BookStack MCP server.
|
|
||||||
#
|
|
||||||
# Merge these blocks into your existing configuration.yml. Tested against
|
|
||||||
# Authelia 4.39+, where ID Token claims changed (see the claims_policies note).
|
|
||||||
#
|
|
||||||
# Generate the client id and secret first:
|
|
||||||
#
|
|
||||||
# docker run --rm authelia/authelia:latest \
|
|
||||||
# authelia crypto rand --length 72 --charset rfc3986
|
|
||||||
#
|
|
||||||
# docker run --rm authelia/authelia:latest \
|
|
||||||
# authelia crypto hash generate pbkdf2 --variant sha512 \
|
|
||||||
# --random --random.length 72 --random.charset rfc3986
|
|
||||||
#
|
|
||||||
# The second command prints BOTH a "Random Password" and a "Digest".
|
|
||||||
# Digest -> client_secret below
|
|
||||||
# Random Password -> OIDC_CLIENT_SECRET in /etc/bookstack-mcp/env
|
|
||||||
# Do not put the digest in the MCP server's env file.
|
|
||||||
|
|
||||||
identity_providers:
|
|
||||||
oidc:
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# 1. Claims policy
|
|
||||||
#
|
|
||||||
# Authelia 4.39 stopped putting non-standard claims in the ID Token by
|
|
||||||
# default. The MCP server reads identity from the ID Token (because
|
|
||||||
# Authelia issues opaque access tokens), so groups and preferred_username
|
|
||||||
# have to be put back explicitly. Without this, MCP_REQUIRED_GROUPS will
|
|
||||||
# never match and every tool call gets denied.
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
claims_policies:
|
|
||||||
mcp_claims:
|
|
||||||
id_token:
|
|
||||||
- 'groups'
|
|
||||||
- 'preferred_username'
|
|
||||||
- 'email'
|
|
||||||
- 'email_verified'
|
|
||||||
- 'name'
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# 2. Lifespan
|
|
||||||
#
|
|
||||||
# Authelia's default refresh_token lifespan is 90m. A claude.ai connector
|
|
||||||
# that sits idle longer than that has to be re-authorised by hand, which
|
|
||||||
# gets old fast. A long refresh token with a short access token is the
|
|
||||||
# right shape for a background integration.
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
lifespans:
|
|
||||||
custom:
|
|
||||||
mcp:
|
|
||||||
access_token: '1h'
|
|
||||||
id_token: '1h'
|
|
||||||
refresh_token: '30d'
|
|
||||||
|
|
||||||
clients:
|
|
||||||
- client_id: 'REPLACE_WITH_GENERATED_ID'
|
|
||||||
client_name: 'BookStack MCP'
|
|
||||||
client_secret: 'REPLACE_WITH_PBKDF2_DIGEST'
|
|
||||||
public: false
|
|
||||||
|
|
||||||
# one_factor if you don't want to be prompted for 2FA on reconnect.
|
|
||||||
authorization_policy: 'two_factor'
|
|
||||||
|
|
||||||
claims_policy: 'mcp_claims'
|
|
||||||
lifespan: 'mcp'
|
|
||||||
|
|
||||||
# Claude re-authorises on token refresh. Explicit consent every time is
|
|
||||||
# painful; pre-configured remembers the grant for the given duration.
|
|
||||||
consent_mode: 'pre-configured'
|
|
||||||
pre_configured_consent_duration: '1 month'
|
|
||||||
|
|
||||||
require_pkce: true
|
|
||||||
pkce_challenge_method: 'S256'
|
|
||||||
|
|
||||||
redirect_uris:
|
|
||||||
- 'https://mcp.example.com/auth/callback'
|
|
||||||
|
|
||||||
# offline_access is what gets you a refresh token. Without it the
|
|
||||||
# connector dies when the access token expires.
|
|
||||||
scopes:
|
|
||||||
- 'openid'
|
|
||||||
- 'profile'
|
|
||||||
- 'email'
|
|
||||||
- 'groups'
|
|
||||||
- 'offline_access'
|
|
||||||
|
|
||||||
grant_types:
|
|
||||||
- 'authorization_code'
|
|
||||||
- 'refresh_token'
|
|
||||||
|
|
||||||
response_types:
|
|
||||||
- 'code'
|
|
||||||
|
|
||||||
token_endpoint_auth_method: 'client_secret_post'
|
|
||||||
|
|
||||||
# Leaving these as 'none' keeps access tokens opaque, which is why the
|
|
||||||
# MCP server needs OIDC_VERIFY_ID_TOKEN=true.
|
|
||||||
access_token_signed_response_alg: 'none'
|
|
||||||
userinfo_signed_response_alg: 'none'
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 3. Access control bypass <-- the one everybody misses
|
|
||||||
#
|
|
||||||
# If mcp.example.com is behind Authelia's forward-auth on your reverse proxy,
|
|
||||||
# Claude will be served an HTML login page instead of the MCP endpoint and the
|
|
||||||
# connector will fail with a parse error or a redirect loop. Claude cannot do
|
|
||||||
# cookie-based forward auth; the MCP server runs its own OAuth flow against
|
|
||||||
# Authelia instead. So the host must bypass forward-auth entirely.
|
|
||||||
#
|
|
||||||
# This is not a security hole: the MCP server refuses every unauthenticated
|
|
||||||
# request itself, and Authelia is still the thing deciding who gets a token.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
access_control:
|
|
||||||
rules:
|
|
||||||
- domain: 'mcp.example.com'
|
|
||||||
policy: 'bypass'
|
|
||||||
File renamed without changes.
@@ -0,0 +1,53 @@
|
|||||||
|
# Butane config for running bookstack-mcp as a container on Flatcar Container
|
||||||
|
# Linux. Convert to Ignition and hand it to the machine on first boot:
|
||||||
|
#
|
||||||
|
# butane --pretty --strict bookstack-mcp.bu > bookstack-mcp.ign
|
||||||
|
#
|
||||||
|
# Fill in the image reference below (your Gitea registry) and the values in
|
||||||
|
# the env file. systemd's EnvironmentFile= only sets the *unit's* environment
|
||||||
|
# (i.e. what the docker CLI sees), not the container's -- that's why the unit
|
||||||
|
# passes --env-file explicitly to `docker run`.
|
||||||
|
variant: flatcar
|
||||||
|
version: 1.0.0
|
||||||
|
storage:
|
||||||
|
files:
|
||||||
|
- path: /etc/bookstack-mcp.env
|
||||||
|
mode: 0600
|
||||||
|
contents:
|
||||||
|
inline: |
|
||||||
|
BOOKSTACK_URL=https://wiki.example.com
|
||||||
|
BOOKSTACK_TOKEN_ID=
|
||||||
|
BOOKSTACK_TOKEN_SECRET=
|
||||||
|
BOOKSTACK_READ_ONLY=true
|
||||||
|
MCP_HOST=0.0.0.0
|
||||||
|
MCP_PORT=8080
|
||||||
|
MCP_PATH=/mcp
|
||||||
|
MCP_AUTH_MODE=oidc+token
|
||||||
|
MCP_PUBLIC_URL=https://mcp.example.com
|
||||||
|
MCP_STATIC_TOKENS=
|
||||||
|
# See example.env at the project root for the full list of options
|
||||||
|
# (OIDC, GitHub OAuth, allowed users/groups, etc).
|
||||||
|
systemd:
|
||||||
|
units:
|
||||||
|
- name: bookstack-mcp.service
|
||||||
|
enabled: true
|
||||||
|
contents: |
|
||||||
|
[Unit]
|
||||||
|
Description=BookStack MCP Server
|
||||||
|
After=docker.service network-online.target
|
||||||
|
Requires=docker.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStartPre=-/usr/bin/docker rm -f bookstack-mcp
|
||||||
|
ExecStartPre=/usr/bin/docker pull git.example.com/owner/bookstack-mcp:latest
|
||||||
|
ExecStart=/usr/bin/docker run --rm --name bookstack-mcp \
|
||||||
|
--env-file /etc/bookstack-mcp.env \
|
||||||
|
-p 8080:8080 \
|
||||||
|
git.example.com/owner/bookstack-mcp:latest
|
||||||
|
ExecStop=/usr/bin/docker stop bookstack-mcp
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
File renamed without changes.
-113
@@ -1,113 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# Install the BookStack MCP server into a Debian/Ubuntu LXC container.
|
|
||||||
# Run as root, from inside the container, in the directory containing pyproject.toml:
|
|
||||||
#
|
|
||||||
# bash deploy/lxc/install.sh
|
|
||||||
#
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
APP_DIR=/opt/bookstack-mcp
|
|
||||||
CONF_DIR=/etc/bookstack-mcp
|
|
||||||
SVC_USER=bookstack-mcp
|
|
||||||
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
||||||
|
|
||||||
log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
|
|
||||||
die() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; }
|
|
||||||
|
|
||||||
[[ $EUID -eq 0 ]] || die "Run this as root (or with sudo)."
|
|
||||||
[[ -f "$SRC_DIR/pyproject.toml" ]] || die "Can't find pyproject.toml next to this script."
|
|
||||||
|
|
||||||
# --- Python version check -------------------------------------------------
|
|
||||||
# Needs 3.11+. Debian 12/13 and Ubuntu 24.04 are fine; Debian 11 and
|
|
||||||
# Ubuntu 22.04 ship something older and need a newer python installed first.
|
|
||||||
log "Checking Python version"
|
|
||||||
if ! command -v python3 >/dev/null; then
|
|
||||||
die "python3 not installed. Try: apt install -y python3 python3-venv"
|
|
||||||
fi
|
|
||||||
PY_OK=$(python3 -c 'import sys; print(1 if sys.version_info >= (3,11) else 0)')
|
|
||||||
if [[ "$PY_OK" != "1" ]]; then
|
|
||||||
die "Python $(python3 -V | cut -d' ' -f2) found, but 3.11+ is required.
|
|
||||||
Debian 12+/Ubuntu 24.04+ work out of the box. On older releases install a
|
|
||||||
newer python3 first (e.g. via deadsnakes) and re-run."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Dependencies ---------------------------------------------------------
|
|
||||||
log "Installing system packages"
|
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
|
||||||
# A single broken third-party repo shouldn't abort the whole install, so the
|
|
||||||
# update is advisory; only the install itself is allowed to fail the script.
|
|
||||||
apt-get update -qq || log "apt update reported errors, continuing"
|
|
||||||
apt-get install -y -qq python3-venv python3-pip ca-certificates >/dev/null
|
|
||||||
|
|
||||||
# --- Service user ---------------------------------------------------------
|
|
||||||
if ! id -u "$SVC_USER" >/dev/null 2>&1; then
|
|
||||||
log "Creating service user $SVC_USER"
|
|
||||||
useradd --system --home-dir "$APP_DIR" --shell /usr/sbin/nologin "$SVC_USER"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Application ----------------------------------------------------------
|
|
||||||
log "Installing application to $APP_DIR"
|
|
||||||
mkdir -p "$APP_DIR"
|
|
||||||
rm -rf "$APP_DIR/bookstack_mcp"
|
|
||||||
cp -r "$SRC_DIR/bookstack_mcp" "$APP_DIR/"
|
|
||||||
cp "$SRC_DIR/pyproject.toml" "$APP_DIR/"
|
|
||||||
|
|
||||||
if [[ ! -d "$APP_DIR/venv" ]]; then
|
|
||||||
log "Creating virtualenv"
|
|
||||||
python3 -m venv "$APP_DIR/venv"
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "Installing Python dependencies (this takes a minute)"
|
|
||||||
"$APP_DIR/venv/bin/pip" install --quiet --upgrade pip
|
|
||||||
"$APP_DIR/venv/bin/pip" install --quiet "$APP_DIR"
|
|
||||||
|
|
||||||
chown -R "$SVC_USER:$SVC_USER" "$APP_DIR"
|
|
||||||
|
|
||||||
# --- Configuration --------------------------------------------------------
|
|
||||||
mkdir -p "$CONF_DIR"
|
|
||||||
if [[ ! -f "$CONF_DIR/env" ]]; then
|
|
||||||
log "Creating $CONF_DIR/env from the template"
|
|
||||||
# systemd EnvironmentFile does not understand quotes or inline comments the
|
|
||||||
# way a shell does, so strip comments and blank lines out of the example.
|
|
||||||
grep -vE '^\s*(#|$)' "$SRC_DIR/.env.example" > "$CONF_DIR/env"
|
|
||||||
NEW_CONFIG=1
|
|
||||||
else
|
|
||||||
log "Keeping existing $CONF_DIR/env"
|
|
||||||
NEW_CONFIG=0
|
|
||||||
fi
|
|
||||||
chown root:"$SVC_USER" "$CONF_DIR/env"
|
|
||||||
chmod 640 "$CONF_DIR/env"
|
|
||||||
|
|
||||||
# --- systemd --------------------------------------------------------------
|
|
||||||
log "Installing systemd unit"
|
|
||||||
install -m 644 "$SRC_DIR/deploy/lxc/bookstack-mcp.service" \
|
|
||||||
/etc/systemd/system/bookstack-mcp.service
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl enable bookstack-mcp >/dev/null
|
|
||||||
|
|
||||||
echo
|
|
||||||
log "Install complete."
|
|
||||||
echo
|
|
||||||
if [[ "$NEW_CONFIG" == "1" ]]; then
|
|
||||||
cat <<EOF
|
|
||||||
Next steps:
|
|
||||||
|
|
||||||
1. Edit the config and fill in your BookStack details:
|
|
||||||
nano $CONF_DIR/env
|
|
||||||
|
|
||||||
At minimum: BOOKSTACK_URL, BOOKSTACK_TOKEN_ID, BOOKSTACK_TOKEN_SECRET.
|
|
||||||
Generate a Claude Code token with: openssl rand -hex 32
|
|
||||||
|
|
||||||
2. Start it:
|
|
||||||
systemctl start bookstack-mcp
|
|
||||||
systemctl status bookstack-mcp
|
|
||||||
journalctl -u bookstack-mcp -f
|
|
||||||
|
|
||||||
3. Verify locally (401 with a WWW-Authenticate header is the correct answer
|
|
||||||
when auth is enabled):
|
|
||||||
curl -i http://127.0.0.1:8080/mcp
|
|
||||||
EOF
|
|
||||||
else
|
|
||||||
echo " systemctl restart bookstack-mcp"
|
|
||||||
fi
|
|
||||||
Executable → Regular
+8
-9
@@ -3,7 +3,7 @@
|
|||||||
# Install the BookStack MCP server into a Debian/Ubuntu LXC container.
|
# Install the BookStack MCP server into a Debian/Ubuntu LXC container.
|
||||||
# Run as root, from inside the container, in the directory containing pyproject.toml:
|
# Run as root, from inside the container, in the directory containing pyproject.toml:
|
||||||
#
|
#
|
||||||
# bash deploy/lxc/install.sh
|
# bash scripts/install-lxc.sh
|
||||||
#
|
#
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -15,12 +15,12 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
|
log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
|
||||||
die() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; }
|
die() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
# Find the project root. Normally this script lives at deploy/lxc/install.sh so
|
# Find the project root. Normally this script lives at scripts/install-lxc.sh
|
||||||
# the root is two levels up, but people often copy the tree around flat, so
|
# so the root is one level up, but people often copy the tree around flat, so
|
||||||
# check the obvious candidates rather than assuming.
|
# check the obvious candidates rather than assuming.
|
||||||
find_src() {
|
find_src() {
|
||||||
local candidate
|
local candidate
|
||||||
for candidate in "$SCRIPT_DIR/../.." "$SCRIPT_DIR/.." "$SCRIPT_DIR" "$PWD"; do
|
for candidate in "$SCRIPT_DIR/.." "$SCRIPT_DIR" "$PWD"; do
|
||||||
if [[ -f "$candidate/pyproject.toml" && -d "$candidate/bookstack_mcp" ]]; then
|
if [[ -f "$candidate/pyproject.toml" && -d "$candidate/bookstack_mcp" ]]; then
|
||||||
(cd "$candidate" && pwd)
|
(cd "$candidate" && pwd)
|
||||||
return 0
|
return 0
|
||||||
@@ -31,12 +31,12 @@ find_src() {
|
|||||||
|
|
||||||
if ! SRC_DIR="$(find_src)"; then
|
if ! SRC_DIR="$(find_src)"; then
|
||||||
die "Can't find the project source (needs pyproject.toml and a bookstack_mcp/ directory).
|
die "Can't find the project source (needs pyproject.toml and a bookstack_mcp/ directory).
|
||||||
Looked in: $SCRIPT_DIR/../.. , $SCRIPT_DIR/.. , $SCRIPT_DIR , $PWD
|
Looked in: $SCRIPT_DIR/.. , $SCRIPT_DIR , $PWD
|
||||||
|
|
||||||
Get the whole project into the container, then run the script from its root:
|
Get the whole project into the container, then run the script from its root:
|
||||||
git clone <your-repo> /opt/src/bookstack-mcp
|
git clone <your-repo> /opt/src/bookstack-mcp
|
||||||
cd /opt/src/bookstack-mcp
|
cd /opt/src/bookstack-mcp
|
||||||
bash deploy/lxc/install.sh"
|
bash scripts/install-lxc.sh"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
[[ $EUID -eq 0 ]] || die "Run this as root (or with sudo)."
|
[[ $EUID -eq 0 ]] || die "Run this as root (or with sudo)."
|
||||||
@@ -46,7 +46,7 @@ if [[ "$SRC_DIR" == "$APP_DIR" ]]; then
|
|||||||
die "The source is $APP_DIR, which is also the install target.
|
die "The source is $APP_DIR, which is also the install target.
|
||||||
Keep the source somewhere else and let the installer copy it in:
|
Keep the source somewhere else and let the installer copy it in:
|
||||||
mkdir -p /opt/src && mv $APP_DIR /opt/src/bookstack-mcp
|
mkdir -p /opt/src && mv $APP_DIR /opt/src/bookstack-mcp
|
||||||
cd /opt/src/bookstack-mcp && bash deploy/lxc/install.sh"
|
cd /opt/src/bookstack-mcp && bash scripts/install-lxc.sh"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "Installing from $SRC_DIR"
|
log "Installing from $SRC_DIR"
|
||||||
@@ -97,7 +97,6 @@ log "Installing Python dependencies (this takes a minute)"
|
|||||||
|
|
||||||
chown -R "$SVC_USER:$SVC_USER" "$APP_DIR"
|
chown -R "$SVC_USER:$SVC_USER" "$APP_DIR"
|
||||||
|
|
||||||
# --- Configuration --------------------------------------------------------
|
|
||||||
# --- Configuration --------------------------------------------------------
|
# --- Configuration --------------------------------------------------------
|
||||||
# Prefer a filled-in .env if one exists, otherwise fall back to the template.
|
# Prefer a filled-in .env if one exists, otherwise fall back to the template.
|
||||||
ENV_SRC=""
|
ENV_SRC=""
|
||||||
@@ -127,7 +126,7 @@ chmod 640 "$CONF_DIR/env"
|
|||||||
# --- systemd --------------------------------------------------------------
|
# --- systemd --------------------------------------------------------------
|
||||||
log "Installing systemd unit"
|
log "Installing systemd unit"
|
||||||
UNIT_SRC=""
|
UNIT_SRC=""
|
||||||
for candidate in "$SRC_DIR/deploy/lxc/bookstack-mcp.service" "$SCRIPT_DIR/bookstack-mcp.service"; do
|
for candidate in "$SRC_DIR/examples/lxc/bookstack-mcp.service" "$SCRIPT_DIR/bookstack-mcp.service"; do
|
||||||
[[ -f "$candidate" ]] && { UNIT_SRC="$candidate"; break; }
|
[[ -f "$candidate" ]] && { UNIT_SRC="$candidate"; break; }
|
||||||
done
|
done
|
||||||
[[ -n "$UNIT_SRC" ]] || die "Can't find bookstack-mcp.service"
|
[[ -n "$UNIT_SRC" ]] || die "Can't find bookstack-mcp.service"
|
||||||
Reference in new issue
Block a user