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.
This commit is contained in:
Anthony Stirling
2026-06-10 09:46:25 +00:00
committed by GitHub
parent 84aca12055
commit 3ecd95b779
80 changed files with 7712 additions and 56 deletions
+89
View File
@@ -0,0 +1,89 @@
"""Tests for the engine shared-secret middleware (EngineSharedSecretMiddleware)."""
from __future__ import annotations
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from stirling.api.engine_auth import EngineSharedSecretMiddleware
SECRET = "s3cr3t-between-java-and-engine"
_TRUTHY = {"1", "true", "yes", "on"}
def _client(*, secret: str | None = None, require: str | None = None) -> TestClient:
# Values come from explicit kwargs rather than env so tests don't depend on the lru-cached
# AppSettings; this mirrors how production code wires the middleware via pydantic-settings.
require_flag = require is not None and require.strip().lower() in _TRUTHY
app = FastAPI()
app.add_middleware(
EngineSharedSecretMiddleware,
secret=secret or "",
require=require_flag,
)
@app.get("/health")
def health() -> dict[str, bool]:
return {"ok": True}
@app.post("/v1/agents/invoke")
def invoke() -> dict[str, bool]:
return {"ran": True}
return TestClient(app)
def test_dev_mode_open_when_unset():
c = _client()
assert c.post("/v1/agents/invoke").status_code == 200
def test_health_is_public_even_with_secret():
c = _client(secret=SECRET)
assert c.get("/health").status_code == 200 # no header required
def test_missing_header_rejected():
c = _client(secret=SECRET)
assert c.post("/v1/agents/invoke").status_code == 401
def test_wrong_secret_rejected():
c = _client(secret=SECRET)
r = c.post("/v1/agents/invoke", headers={"X-Engine-Auth": "not-the-secret"})
assert r.status_code == 401
def test_valid_secret_allowed():
c = _client(secret=SECRET)
r = c.post("/v1/agents/invoke", headers={"X-Engine-Auth": SECRET})
assert r.status_code == 200
assert r.json() == {"ran": True}
def test_require_auth_fails_closed_without_secret():
c = _client(require="true")
# Require flag, no secret -> protected routes refused.
assert c.post("/v1/agents/invoke").status_code == 503
# Liveness still works for health checks.
assert c.get("/health").status_code == 200
def test_require_auth_with_secret_enforces_normally():
c = _client(secret=SECRET, require="true")
assert c.post("/v1/agents/invoke").status_code == 401
assert c.post("/v1/agents/invoke", headers={"X-Engine-Auth": SECRET}).status_code == 200
@pytest.mark.parametrize("flag", ["true", "1", "YES", "On"])
def test_require_flag_truthy_variants(flag: str):
c = _client(require=flag)
assert c.post("/v1/agents/invoke").status_code == 503
@pytest.mark.parametrize("flag", ["false", "0", "no", ""])
def test_require_flag_falsey_variants_stay_open(flag: str):
c = _client(require=flag)
assert c.post("/v1/agents/invoke").status_code == 200