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.
135 lines
6.4 KiB
Bash
135 lines
6.4 KiB
Bash
#!/bin/bash
|
|
# Validate the MCP server in apikey auth mode (curl + real MCP SDK client), then restore oauth.
|
|
# Runs every check and exits non-zero on any failure.
|
|
|
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
COMPOSE="$SCRIPT_DIR/docker-compose-keycloak-mcp.yml"
|
|
CHECK_DIR="$SCRIPT_DIR/mcp-client-check"
|
|
MCP_URL="http://localhost:8080/mcp"
|
|
RPC_LIST='{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
|
|
|
|
PASS=0; FAIL=0
|
|
pass() { echo -e "${GREEN}✓${NC} $1"; PASS=$((PASS + 1)); }
|
|
fail() { echo -e "${RED}✗ $1${NC}"; FAIL=$((FAIL + 1)); }
|
|
|
|
# Wait on the container healthcheck. Returns 0 once healthy, 1 after the timeout.
|
|
wait_up() {
|
|
local w=0
|
|
while [ $w -lt 360 ]; do
|
|
if [ "$(docker inspect -f '{{.State.Health.Status}}' stirling-pdf-mcp-test 2>/dev/null)" = "healthy" ]; then
|
|
echo ""
|
|
return 0
|
|
fi
|
|
sleep 5; w=$((w + 5)); echo -n "."
|
|
done
|
|
echo ""
|
|
return 1
|
|
}
|
|
|
|
echo -e "${BLUE}╔════════════════════════════════════════════════════╗${NC}"
|
|
echo -e "${BLUE}║ Validating MCP - API-KEY mode (+ real client) ║${NC}"
|
|
echo -e "${BLUE}╚════════════════════════════════════════════════════╝${NC}"
|
|
echo ""
|
|
|
|
echo -e "${YELLOW}▶ Recreating Stirling in apikey mode...${NC}"
|
|
MCP_AUTH_MODE=apikey PREMIUM_KEY="${PREMIUM_KEY:-}" \
|
|
docker compose -f "$COMPOSE" up -d --no-build --force-recreate stirling-pdf-mcp >/dev/null 2>&1
|
|
if ! wait_up; then
|
|
fail "Stirling did not become healthy in apikey mode"
|
|
echo -e "${RED}Aborting.${NC}"; exit 1
|
|
fi
|
|
# In apikey mode the OAuth metadata is not served (404, or 401 from the app chain).
|
|
META_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/.well-known/oauth-protected-resource)
|
|
if [ "$META_CODE" = "404" ] || [ "$META_CODE" = "401" ]; then
|
|
pass "apikey mode active (OAuth metadata not served -> $META_CODE)"
|
|
else
|
|
fail "expected OAuth metadata absent in apikey mode, got $META_CODE"
|
|
fi
|
|
|
|
echo -e "${YELLOW}▶ Minting a Stirling API key for mcpuser...${NC}"
|
|
JWT=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"username":"[email protected]","password":"mcppassword"}' \
|
|
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
|
|
if [ -n "$JWT" ]; then
|
|
pass "logged in as mcpuser (got a session token)"
|
|
else
|
|
fail "could not log in as mcpuser - cannot mint an API key"
|
|
fi
|
|
APIKEY=$(curl -s -X POST http://localhost:8080/api/v1/user/get-api-key \
|
|
-H "Authorization: Bearer $JWT" | sed -n 's/.*"apiKey":"\([^"]*\)".*/\1/p')
|
|
if [ -z "$APIKEY" ]; then
|
|
APIKEY=$(curl -s -X POST http://localhost:8080/api/v1/user/update-api-key \
|
|
-H "Authorization: Bearer $JWT" | sed -n 's/.*"apiKey":"\([^"]*\)".*/\1/p')
|
|
fi
|
|
if [ -n "$APIKEY" ]; then
|
|
pass "minted an API key for mcpuser"
|
|
else
|
|
fail "could not mint an API key"
|
|
fi
|
|
echo ""
|
|
|
|
echo -e "${YELLOW}[1] curl checks (apikey)${NC}"
|
|
NO_KEY=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" -H "Content-Type: application/json" -d "$RPC_LIST")
|
|
[ "$NO_KEY" = "401" ] && pass "no key -> 401" || fail "no key -> $NO_KEY (expected 401)"
|
|
BAD_KEY=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" -H "Content-Type: application/json" -H "X-API-KEY: not-a-real-key" -d "$RPC_LIST")
|
|
[ "$BAD_KEY" = "401" ] && pass "bad key -> 401" || fail "bad key -> $BAD_KEY (expected 401)"
|
|
if [ -n "$APIKEY" ]; then
|
|
OK_CODE=$(curl -s -o /tmp/mcp_apikey_list.json -w "%{http_code}" -X POST "$MCP_URL" -H "Content-Type: application/json" -H "X-API-KEY: $APIKEY" -d "$RPC_LIST")
|
|
if [ "$OK_CODE" = "200" ] && grep -q "stirling_describe_operation" /tmp/mcp_apikey_list.json; then
|
|
pass "valid X-API-KEY -> 200 + tools listed"
|
|
else
|
|
fail "valid X-API-KEY -> $OK_CODE (tools listed? check body)"
|
|
fi
|
|
rm -f /tmp/mcp_apikey_list.json
|
|
fi
|
|
echo ""
|
|
|
|
echo -e "${YELLOW}[2] Real MCP SDK client (apikey / X-API-KEY)${NC}"
|
|
if ! command -v node >/dev/null 2>&1; then
|
|
echo -e " ${YELLOW}(node not installed - skipping real-client check)${NC}"
|
|
elif [ -z "$APIKEY" ]; then
|
|
fail "skipping real-client check - no API key"
|
|
else
|
|
[ -d "$CHECK_DIR/node_modules" ] || (cd "$CHECK_DIR" && npm install --silent --no-fund --no-audit >/dev/null 2>&1)
|
|
if MODE=apikey MCP_URL="$MCP_URL" MCP_APIKEY="$APIKEY" node "$CHECK_DIR/check.mjs"; then
|
|
pass "official MCP SDK client connected + validated (apikey)"
|
|
else
|
|
fail "official MCP SDK client could not validate the server (apikey)"
|
|
fi
|
|
fi
|
|
echo ""
|
|
|
|
echo -e "${YELLOW}[3] Real operation execution (rotate-pdf, inline file)${NC}"
|
|
SAMPLE_PDF="$SCRIPT_DIR/../../app/common/src/test/resources/example.pdf"
|
|
if [ -z "$APIKEY" ]; then
|
|
fail "skipping execution check - no API key"
|
|
elif [ ! -f "$SAMPLE_PDF" ]; then
|
|
fail "sample PDF not found at $SAMPLE_PDF"
|
|
else
|
|
B64=$(base64 -w0 "$SAMPLE_PDF" 2>/dev/null || base64 "$SAMPLE_PDF" | tr -d '\n')
|
|
EXEC_RPC=$(printf '{"jsonrpc":"2.0","id":91,"method":"tools/call","params":{"name":"stirling_pages","arguments":{"operation":"rotate-pdf","fileName":"example.pdf","parameters":{"angle":90},"file":"%s"}}}' "$B64")
|
|
EXEC=$(curl -s -X POST "$MCP_URL" -H "Content-Type: application/json" -H "X-API-KEY: $APIKEY" -d "$EXEC_RPC")
|
|
if echo "$EXEC" | grep -q '"isError":true'; then
|
|
fail "rotate-pdf returned isError: $(printf '%s' "$EXEC" | head -c 300)"
|
|
elif echo "$EXEC" | grep -q 'fileId='; then
|
|
pass "rotate-pdf processed a real PDF over MCP and returned a result fileId"
|
|
else
|
|
fail "rotate-pdf returned no result fileId: $(printf '%s' "$EXEC" | head -c 300)"
|
|
fi
|
|
fi
|
|
echo ""
|
|
|
|
echo -e "${YELLOW}▶ Restoring oauth mode...${NC}"
|
|
PREMIUM_KEY="${PREMIUM_KEY:-}" docker compose -f "$COMPOSE" up -d --no-build --force-recreate stirling-pdf-mcp >/dev/null 2>&1
|
|
wait_up && pass "restored oauth mode" || fail "Stirling not healthy after restoring oauth"
|
|
echo ""
|
|
|
|
echo -e "${BLUE}────────────────────────────────────────────────────${NC}"
|
|
if [ "$FAIL" -eq 0 ]; then
|
|
echo -e "${GREEN}API-key mode checks passed! ($PASS passed)${NC}"; exit 0
|
|
else
|
|
echo -e "${RED}API-key mode validation failed: $FAIL failed, $PASS passed.${NC}"; exit 1
|
|
fi
|