mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 18:44:05 +02:00
Adds an optional MCP server (proprietary module) that exposes Stirling's PDF operations and AI capabilities to MCP clients. Off by default, zero footprint when disabled. ### What - New `/mcp` endpoint: streamable-HTTP + JSON-RPC 2.0; 8 tools (describe_operation, pages/convert/misc/security category tools, AI, upload, download). - Runs real operations over an internal loopback; results returned inline as base64 (small) or by fileId (large). ### Auth (two modes) - OAuth2 resource server: RFC 9728 protected-resource metadata, RFC 8707 audience binding, JWKS, `mcp.tools.read/write` scopes; binds each token to a provisioned Stirling account. - API-key mode: reuses Stirling per-user `X-API-KEY` (no IdP needed). ### Security - Per-user file ownership in FileStorage: async/queued writes scoped to the submitting user; legacy/owner-less files stay readable. - Admin allow/block list controls which operations are exposed. - Python engine gated behind a shared secret (`X-Engine-Auth`). - MCP filter chain is isolated and cannot weaken the main app's security. - Hardened: no upstream error-body leakage, log injection sanitized, fileId path/sidecar enumeration blocked. ### Config / footprint - Disabled by default (`mcp.enabled=false`); all beans `@ConditionalOnProperty`. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details.
267 lines
10 KiB
YAML
267 lines
10 KiB
YAML
version: '3'
|
|
|
|
tasks:
|
|
install:
|
|
desc: "Install Playwright browsers"
|
|
dir: frontend/editor
|
|
deps: [ ':frontend:install' ]
|
|
cmds:
|
|
- npx playwright install {{.CLI_ARGS}} --with-deps
|
|
|
|
stubbed:
|
|
desc: "Run stubbed E2E tests"
|
|
dir: frontend/editor
|
|
deps: [ ':frontend:prepare' ]
|
|
cmds:
|
|
- npx playwright test --project=stubbed {{.CLI_ARGS}}
|
|
|
|
live:
|
|
desc: "Run live E2E tests"
|
|
summary: |
|
|
Auto-spawns a Spring Boot backend with isolated state under
|
|
.test-state/playwright/ (purged on every run) so the suite doesn't
|
|
touch your local DB, settings.yml, backups or customFiles. The
|
|
backend is stopped automatically when the runner exits.
|
|
|
|
Pass extra Playwright flags via -- :
|
|
task e2e:live -- --headed --grep merge
|
|
deps:
|
|
- live:backend
|
|
- live:runner
|
|
|
|
live:backend:
|
|
internal: true
|
|
ignore_error: true
|
|
vars:
|
|
BASE_DIR: '{{.ROOT_DIR}}/.test-state/playwright'
|
|
# COVERAGE=1 in the calling environment attaches the JaCoCo agent to
|
|
# the bootRun JVM and writes to BASE_DIR/jacoco.exec on shutdown.
|
|
# Off by default to keep local dev runs uninstrumented; CI flips it.
|
|
env:
|
|
STIRLING_BASE_PATH: '{{.BASE_DIR}}'
|
|
# Suppress the analytics opt-in modal that fires on first admin login.
|
|
# It renders a Mantine overlay that intercepts pointer events and blocks
|
|
# FirstLoginSlide from rendering, so the bootstrap spec times out waiting
|
|
# for the password-change prompt.
|
|
SYSTEM_ENABLEANALYTICS: "false"
|
|
# NOTE: SECURITY_INITIALLOGIN_USERNAME/PASSWORD are intentionally NOT set.
|
|
# The live-setup project's bootstrap spec performs the real first-login
|
|
# flow against the backend's default admin/stirling user, exercising the
|
|
# forced-password-change UI and leaving the DB at admin/adminadmin for
|
|
# the rest of the live suite. This is both real coverage of the first-
|
|
# login flow and a stronger seed than env-var-driven user creation.
|
|
cmds:
|
|
# Wrapped in `bash -c` because Task's embedded shell (mvdan/sh) doesn't
|
|
# support `&` + `$!` + `wait` reliably (gradle was never actually
|
|
# backgrounded, so `$!` recorded a stale PID and the runner saw an
|
|
# immediate "backend died"). Real bash backgrounds gradle properly and
|
|
# gives us a stable PID to clean up.
|
|
- |
|
|
bash -c '
|
|
set -e
|
|
rm -rf "{{.BASE_DIR}}"
|
|
mkdir -p "{{.BASE_DIR}}"
|
|
GRADLE_ARGS=":stirling-pdf:bootRun"
|
|
if [ -n "${COVERAGE:-}" ]; then
|
|
# copyJacocoAgent is wired as a dependency of bootRun when
|
|
# -PjacocoAgent=true, so we do not need to invoke it separately.
|
|
GRADLE_ARGS="$GRADLE_ARGS -PjacocoAgent=true -PjacocoExec={{.BASE_DIR}}/jacoco.exec"
|
|
echo "JaCoCo coverage enabled, writing to {{.BASE_DIR}}/jacoco.exec"
|
|
fi
|
|
# Background gradle and record its PID so the runner can clean up
|
|
# the exact process tree (wrapper + forked Spring Boot JVM) without
|
|
# resorting to fuzzy `pkill -f` patterns. `wait` keeps this script
|
|
# alive for the lifetime of gradle so Task'"'"'s parallel deps stay
|
|
# synchronised.
|
|
bash gradlew $GRADLE_ARGS > "{{.BASE_DIR}}/backend.log" 2>&1 &
|
|
GRADLE_PID=$!
|
|
echo $GRADLE_PID > "{{.BASE_DIR}}/backend.pid"
|
|
wait $GRADLE_PID
|
|
'
|
|
|
|
live:runner:
|
|
internal: true
|
|
deps: [ ':frontend:prepare' ]
|
|
dir: frontend/editor
|
|
vars:
|
|
BASE_DIR: '{{.ROOT_DIR}}/.test-state/playwright'
|
|
cmds:
|
|
# Wrapped in `bash -c` because Task's embedded shell (mvdan/sh) only
|
|
# supports `EXIT`/`ERR` in `trap`, not `INT`/`TERM`. CLI_ARGS are
|
|
# passed positionally so quoted args like --grep "foo bar" survive.
|
|
- |
|
|
bash -c '
|
|
set +e
|
|
# bootRun forks a separate Spring Boot JVM as a child of the gradle
|
|
# wrapper; killing only the wrapper leaves that JVM orphaned holding
|
|
# :8080. Walk the recorded PID'"'"'s process tree via pgrep so we kill
|
|
# every descendant, then lsof as a last-resort backstop.
|
|
kill_tree() {
|
|
local pid=$1
|
|
for child in $(pgrep -P "$pid" 2>/dev/null); do
|
|
kill_tree "$child"
|
|
done
|
|
kill "$pid" 2>/dev/null || true
|
|
}
|
|
cleanup() {
|
|
if [ -f "{{.BASE_DIR}}/backend.pid" ]; then
|
|
echo "Stopping backend..."
|
|
kill_tree "$(cat "{{.BASE_DIR}}/backend.pid")"
|
|
sleep 1
|
|
lsof -ti:8080 2>/dev/null | xargs kill 2>/dev/null || true
|
|
echo "Backend stopped"
|
|
fi
|
|
}
|
|
# Trap so cleanup runs on every exit path: tests pass, tests fail,
|
|
# backend never starts, user Ctrl-Cs. Without this the gradle JVM
|
|
# is left orphaned holding :8080 whenever the wait loop times out.
|
|
trap cleanup EXIT INT TERM
|
|
|
|
echo "Waiting for backend on :8080 (up to 10min; first compile can be slow)..."
|
|
WAITED=0
|
|
while [ $WAITED -lt 600 ]; do
|
|
if curl -sf http://localhost:8080/api/v1/info/status 2>/dev/null | grep -q UP; then
|
|
echo "Backend ready, starting Playwright"
|
|
break
|
|
fi
|
|
# Bail early if the backend already died: no point waiting 10min.
|
|
if [ -f "{{.BASE_DIR}}/backend.pid" ]; then
|
|
BACKEND_PID=$(cat "{{.BASE_DIR}}/backend.pid")
|
|
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
|
|
echo "Backend process exited before becoming ready; last 100 log lines:"
|
|
tail -100 "{{.BASE_DIR}}/backend.log" 2>/dev/null || true
|
|
exit 1
|
|
fi
|
|
fi
|
|
sleep 2
|
|
WAITED=$((WAITED + 2))
|
|
done
|
|
if [ $WAITED -ge 600 ]; then
|
|
echo "Backend did not become ready within 10min, aborting"
|
|
exit 1
|
|
fi
|
|
|
|
npx playwright test --project=live "$@"
|
|
' bash {{.CLI_ARGS}}
|
|
|
|
enterprise:
|
|
desc: "Run enterprise E2E tests"
|
|
summary: |
|
|
Requires an already-running keycloak compose stack on :8080. Bring one
|
|
up first with:
|
|
task e2e:oauth:up
|
|
task e2e:saml:up
|
|
dir: frontend/editor
|
|
deps: [ ':frontend:prepare' ]
|
|
cmds:
|
|
- npx playwright test --project=enterprise {{.CLI_ARGS}}
|
|
|
|
cross-browser:
|
|
desc: "Run stubbed E2E tests on Chromium, Firefox, and WebKit"
|
|
dir: frontend/editor
|
|
deps: [ ':frontend:prepare' ]
|
|
cmds:
|
|
- npx playwright test --project=stubbed --project=stubbed-firefox --project=stubbed-webkit {{.CLI_ARGS}}
|
|
|
|
check:
|
|
desc: "Run all E2E tests not requiring Docker"
|
|
cmds:
|
|
- task: stubbed
|
|
- task: live
|
|
|
|
check:all:
|
|
desc: "Run all E2E tests"
|
|
summary: |
|
|
Includes the enterprise project, which requires a keycloak compose
|
|
stack on :8080. Bring one up first with:
|
|
task e2e:oauth:up
|
|
task e2e:saml:up
|
|
cmds:
|
|
- task: stubbed
|
|
- task: live
|
|
- task: enterprise
|
|
|
|
oauth:up:
|
|
desc: "Start the OAuth keycloak test environment"
|
|
summary: |
|
|
Set LICENSE_KEY=<KEY> to skip the interactive license prompt:
|
|
task e2e:oauth:up LICENSE_KEY=abc123
|
|
Pass extra flags via -- :
|
|
task e2e:oauth:up -- --auto --nobuild
|
|
ignore_error: true
|
|
cmds:
|
|
- bash testing/compose/start-oauth-test.sh {{if .LICENSE_KEY}}--license-key "{{.LICENSE_KEY}}"{{end}} {{.CLI_ARGS}}
|
|
|
|
oauth:down:
|
|
desc: "Stop the OAuth keycloak test environment"
|
|
cmds:
|
|
- docker compose -f testing/compose/docker-compose-keycloak-oauth.yml down -v
|
|
|
|
saml:up:
|
|
desc: "Start the SAML keycloak test environment"
|
|
summary: |
|
|
Set LICENSE_KEY=<KEY> to skip the interactive license prompt:
|
|
task e2e:saml:up LICENSE_KEY=abc123
|
|
Pass extra flags via -- :
|
|
task e2e:saml:up -- --auto --with-storage --nobuild --language sv-SE
|
|
ignore_error: true
|
|
cmds:
|
|
- bash testing/compose/start-saml-test.sh {{if .LICENSE_KEY}}--license-key "{{.LICENSE_KEY}}"{{end}} {{.CLI_ARGS}}
|
|
|
|
saml:down:
|
|
desc: "Stop the SAML keycloak test environment"
|
|
cmds:
|
|
- docker compose -f testing/compose/docker-compose-keycloak-saml.yml down -v
|
|
|
|
mcp:up:
|
|
desc: "Start the MCP keycloak test environment (Stirling as OAuth resource server)"
|
|
summary: |
|
|
Brings up Keycloak (OAuth authorization server) + Stirling configured as an
|
|
MCP resource server, then you can exercise /mcp with real Keycloak tokens.
|
|
Set LICENSE_KEY=<KEY> to skip the interactive license prompt:
|
|
task e2e:mcp:up LICENSE_KEY=abc123
|
|
Pass extra flags via -- :
|
|
task e2e:mcp:up -- --validate --nobuild
|
|
ignore_error: true
|
|
cmds:
|
|
- bash testing/compose/start-mcp-test.sh {{if .LICENSE_KEY}}--license-key "{{.LICENSE_KEY}}"{{end}} {{.CLI_ARGS}}
|
|
|
|
mcp:manual:
|
|
desc: "Start the MCP keycloak test env in manual mode (prints URLs + a live token for your client)"
|
|
summary: |
|
|
Brings the stack up and prints copy-paste URLs/commands plus a freshly minted
|
|
access token so you can drive your own MCP client (Inspector, curl, ...).
|
|
task e2e:mcp:manual LICENSE_KEY=<your-license-key>
|
|
Add --nobuild if the images are already built:
|
|
task e2e:mcp:manual LICENSE_KEY=<your-license-key> -- --nobuild
|
|
ignore_error: true
|
|
cmds:
|
|
- bash testing/compose/start-mcp-test.sh --manual {{if .LICENSE_KEY}}--license-key "{{.LICENSE_KEY}}"{{end}} {{.CLI_ARGS}}
|
|
|
|
mcp:apikey:
|
|
desc: "Start the MCP test env in API-KEY manual mode (no OAuth/IdP): mints a key + prints client settings"
|
|
summary: |
|
|
Brings Stirling up in apikey auth mode and prints copy-paste client settings with a freshly
|
|
minted X-API-KEY - ideal for clients whose OAuth layer can't reach localhost.
|
|
task e2e:mcp:apikey LICENSE_KEY=<your-license-key>
|
|
Add --nobuild if images are already built:
|
|
task e2e:mcp:apikey LICENSE_KEY=<your-license-key> -- --nobuild
|
|
ignore_error: true
|
|
cmds:
|
|
- bash testing/compose/start-mcp-test.sh --apikey {{if .LICENSE_KEY}}--license-key "{{.LICENSE_KEY}}"{{end}} {{.CLI_ARGS}}
|
|
|
|
mcp:validate:
|
|
desc: "Validate the running MCP keycloak test environment end-to-end (oauth mode + real MCP SDK client)"
|
|
cmds:
|
|
- bash testing/compose/validate-mcp-test.sh
|
|
|
|
mcp:validate-apikey:
|
|
desc: "Validate the MCP server in API-KEY auth mode (mints a key + real MCP SDK client), then restore oauth"
|
|
cmds:
|
|
- bash testing/compose/validate-mcp-apikey.sh
|
|
|
|
mcp:down:
|
|
desc: "Stop the MCP keycloak test environment"
|
|
cmds:
|
|
- docker compose -f testing/compose/docker-compose-keycloak-mcp.yml down -v
|