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
@@ -0,0 +1,160 @@
"""
Curated registry of agent capabilities the MCP server (Java side) is allowed to publish.
Internal sub-agents (currently only ``ExecutionPlanningAgent`` - it lives behind the orchestrator
and has no end-user-facing API surface) are intentionally absent. The handoff spec calls for
"user-facing" capabilities only; revisit this list when adding a new agent and ask whether MCP
clients should be able to invoke it directly.
The Java side pulls ``/api/v1/agents/capabilities`` once at boot and again every few minutes; the
manifest is the authoritative source for the ``stirling_ai`` MCP tool's operation enum.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from pydantic import BaseModel
from stirling.contracts import (
AgentDraftRequest,
AgentExecutionRequest,
AgentRevisionRequest,
Evidence,
FolioManifest,
PdfCommentRequest,
PdfEditRequest,
PdfQuestionRequest,
)
@dataclass(frozen=True)
class AgentCapability:
"""One row in the curated manifest.
Attributes:
id: stable capability identifier (used as the operation enum value in
``stirling_ai``). Avoid renaming - clients persist these.
description: one-line human-friendly summary shown inside MCP tool descriptions.
input_model: Pydantic class whose JSON Schema becomes the capability's
``input_schema``. Auto-derived; do not hand-write schemas.
mode: ``"sync"`` if the capability returns content inline, ``"async"`` if it returns a
plan that Java executes via the job pipeline.
required_scope: coarse OAuth scope. ``mcp.tools.read`` for pure-read capabilities
(Q&A, audits) and ``mcp.tools.write`` for anything that yields a plan / file.
route: HTTP path Java POSTs to when invoking this capability. When a capability does
not have a stable per-agent route yet, use the generic invoke fallback at
``/api/v1/agents/invoke/{id}``.
"""
id: str
description: str
input_model: type[BaseModel]
mode: str
required_scope: str
route: str
EXPOSED_CAPABILITIES: list[AgentCapability] = [
AgentCapability(
id="pdf-question-answer",
description="Answer a natural-language question about a PDF document.",
input_model=PdfQuestionRequest,
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/pdf-question",
),
AgentCapability(
id="pdf-edit-plan",
description=(
"Produce an edit plan (a structured sequence of PDF operations) from a"
" natural-language edit request. The plan is executed by Java through the job"
" pipeline; this capability does not modify files itself."
),
input_model=PdfEditRequest,
mode="async",
required_scope="mcp.tools.write",
route="/api/v1/pdf-edit",
),
AgentCapability(
id="agent-draft",
description=(
"Draft a structured agent specification from a free-text description of the task the user wants automated."
),
input_model=AgentDraftRequest,
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/ai/agents/draft",
),
AgentCapability(
id="agent-revise",
description=("Revise an existing draft agent specification based on user feedback or constraint changes."),
input_model=AgentRevisionRequest,
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/ai/agents/revise",
),
AgentCapability(
id="math-audit-examine",
description=(
"Examine a folio manifest of financial / numeric documents and surface the"
" evidence that needs to be checked for arithmetic consistency."
),
input_model=FolioManifest,
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/ai/math-auditor-agent/examine",
),
AgentCapability(
id="math-audit-deliberate",
description=(
"Render a deliberated verdict on a single piece of evidence the examine step"
" surfaced (does the arithmetic check out, with what caveats)."
),
input_model=Evidence,
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/ai/math-auditor-agent/deliberate",
),
AgentCapability(
id="pdf-comment-generate",
description="Generate inline review comments for a PDF document.",
input_model=PdfCommentRequest,
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/pdf-comment/generate",
),
AgentCapability(
id="agent-next-action",
description=(
"Decide the next execution step for an in-progress agent workflow. Returns a"
" ToolCall, Completed, or CannotContinue action."
),
input_model=AgentExecutionRequest,
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/agents/next-action",
),
]
def manifest_payload() -> dict[str, Any]:
"""Serialize the curated registry to the wire shape consumed by Java.
Schema is derived from ``input_model.model_json_schema()`` so we never hand-write JSON
Schema - the Pydantic model is the single source of truth.
"""
items: list[dict[str, Any]] = []
for cap in EXPOSED_CAPABILITIES:
items.append(
{
"id": cap.id,
"description": cap.description,
"input_schema": cap.input_model.model_json_schema(),
"mode": cap.mode,
"required_scope": cap.required_scope,
"route": cap.route,
}
)
return {"version": 1, "capabilities": items}
+4
View File
@@ -18,8 +18,10 @@ from stirling.agents import (
)
from stirling.agents.ledger import MathAuditorAgent
from stirling.agents.pdf_comment import PdfCommentAgent
from stirling.api.engine_auth import EngineSharedSecretMiddleware
from stirling.api.middleware import UserIdMiddleware
from stirling.api.routes import (
agent_capabilities_router,
agent_draft_router,
document_router,
execution_router,
@@ -115,6 +117,7 @@ async def lifespan(fast_api: FastAPI):
app = FastAPI(title="Stirling AI Engine", lifespan=lifespan, version="0.1.0")
app.add_middleware(UserIdMiddleware)
app.add_middleware(EngineSharedSecretMiddleware)
app.include_router(orchestrator_router)
app.include_router(pdf_edit_router)
app.include_router(pdf_question_router)
@@ -123,6 +126,7 @@ app.include_router(execution_router)
app.include_router(document_router)
app.include_router(ledger_router)
app.include_router(pdf_comments_router)
app.include_router(agent_capabilities_router)
@app.get("/health", response_model=HealthResponse)
+99
View File
@@ -0,0 +1,99 @@
"""
Shared-secret middleware that locks the engine to the trusted Java backend.
Config (resolved via :class:`stirling.config.AppSettings`/pydantic-settings):
``STIRLING_ENGINE_SHARED_SECRET`` - non-public routes need ``X-Engine-Auth`` or 401.
``STIRLING_ENGINE_REQUIRE_AUTH`` - fail closed with 503 when truthy and no secret is set.
"""
from __future__ import annotations
import hmac
import logging
from collections.abc import Iterable
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from starlette.types import ASGIApp
from stirling.config import load_settings
logger = logging.getLogger(__name__)
_HEADER = "X-Engine-Auth"
# Public paths (liveness + docs); everything else needs the secret when configured.
_PUBLIC_PREFIXES: tuple[str, ...] = (
"/health",
"/docs",
"/redoc",
"/openapi.json",
)
class EngineSharedSecretMiddleware(BaseHTTPMiddleware):
"""Reject non-public requests lacking the shared secret.
Non-public path: secret set -> require matching X-Engine-Auth (else 401); else require flag
truthy -> 503 (fail-closed); else allow through.
Secret/require values come from :class:`stirling.config.AppSettings` by default; tests can
pass them explicitly to avoid touching the lru-cached settings.
"""
def __init__(
self,
app: ASGIApp,
public_prefixes: Iterable[str] = _PUBLIC_PREFIXES,
*,
secret: str | None = None,
require: bool | None = None,
) -> None:
super().__init__(app)
self._public_prefixes = tuple(public_prefixes)
if secret is None or require is None:
settings = load_settings()
if secret is None:
secret = settings.engine_shared_secret
if require is None:
require = settings.engine_require_auth
self._secret = secret or ""
self._require = bool(require)
if self._secret:
logger.info(
"Engine shared-secret enforcement ENABLED: non-public routes require a valid %s"
" header (constant-time compared).",
_HEADER,
)
elif self._require:
logger.error(
"STIRLING_ENGINE_REQUIRE_AUTH is enabled but STIRLING_ENGINE_SHARED_SECRET is not"
" set - the engine will REFUSE every non-public request (HTTP 503, fail-closed)"
" until a shared secret is configured.",
)
else:
logger.warning(
"STIRLING_ENGINE_SHARED_SECRET not set - engine shared-secret enforcement is"
" DISABLED. The AI and document routes then trust the caller-supplied X-User-Id"
" header alone. Set this secret (and STIRLING_ENGINE_REQUIRE_AUTH=true) in any"
" deployment that exposes the engine beyond localhost.",
)
def _is_public(self, path: str) -> bool:
return any(path == p or path.startswith(p + "/") for p in self._public_prefixes)
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
if not self._is_public(request.url.path):
if self._secret:
offered = request.headers.get(_HEADER) or ""
# Constant-time compare to avoid timing leaks.
if not hmac.compare_digest(offered, self._secret):
return JSONResponse({"detail": "Missing or invalid X-Engine-Auth header."}, status_code=401)
elif self._require:
# Fail closed: require flag set but no secret configured.
return JSONResponse(
{"detail": ("Engine authentication is required but no shared secret is configured.")},
status_code=503,
)
return await call_next(request)
@@ -1,3 +1,4 @@
from .agent_capabilities import router as agent_capabilities_router
from .agent_drafts import router as agent_draft_router
from .documents import router as document_router
from .execution import router as execution_router
@@ -8,6 +9,7 @@ from .pdf_edit import router as pdf_edit_router
from .pdf_questions import router as pdf_question_router
__all__ = [
"agent_capabilities_router",
"agent_draft_router",
"document_router",
"execution_router",
@@ -0,0 +1,22 @@
"""GET ``/api/v1/agents/capabilities`` - the manifest the Java MCP server pulls at boot."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter
from stirling.api.agent_capabilities import manifest_payload
router = APIRouter(prefix="/api/v1/agents", tags=["agents"])
@router.get("/capabilities")
def get_capabilities() -> dict[str, Any]:
"""Return the curated agent capabilities manifest.
Gated by ``EngineSharedSecretMiddleware`` when the ``STIRLING_ENGINE_SHARED_SECRET`` env var
is configured. In dev/local mode (no secret set), the endpoint is open - the engine binds to
localhost only by default, so this is acceptable while iterating.
"""
return manifest_payload()
+5
View File
@@ -102,6 +102,11 @@ class AppSettings(BaseSettings):
posthog_api_key: str = Field(validation_alias="STIRLING_POSTHOG_API_KEY")
posthog_host: str = Field(validation_alias="STIRLING_POSTHOG_HOST")
# Shared secret enforced by EngineSharedSecretMiddleware. Empty disables enforcement
# unless engine_require_auth is set, in which case the engine fails closed (503).
engine_shared_secret: str = Field(default="", validation_alias="STIRLING_ENGINE_SHARED_SECRET")
engine_require_auth: bool = Field(default=False, validation_alias="STIRLING_ENGINE_REQUIRE_AUTH")
def _configure_logging(level_name: str, log_file: str, http_debug: bool) -> None:
"""Configure the ``stirling`` logger hierarchy."""
+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