mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
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:
@@ -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
|
||||
Reference in New Issue
Block a user