Files
Anthony StirlingandGitHub 3ecd95b779 Add MCP server with OAuth/API-key auth (#6570)
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.
2026-06-10 09:46:25 +00:00

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