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
+9
View File
@@ -3,3 +3,12 @@
# intentionally in #6150 so engine/.env has a working default, with real
# credentials overridden via engine/.env.local.
engine/.env:generic-api-key:41
# MCP test fixtures / harness - no real secrets:
# - test-only API key constant in an integration test
# - JDBC URL + throwaway Keycloak creds in the local test compose
# - placeholder / shell-variable Bearer headers in curl-based validation scripts
app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpApiKeyIntegrationTest.java:generic-api-key:40
testing/compose/docker-compose-keycloak-mcp.yml:generic-api-key:25
testing/compose/validate-mcp-apikey.sh:curl-auth-header:73
testing/compose/validate-mcp-test.sh:curl-auth-header:92
+52
View File
@@ -212,3 +212,55 @@ tasks:
desc: "Stop the SAML keycloak test environment"
cmds:
- docker compose -f testing/compose/docker-compose-keycloak-saml.yml down -v
mcp:up:
desc: "Start the MCP keycloak test environment (Stirling as OAuth resource server)"
summary: |
Brings up Keycloak (OAuth authorization server) + Stirling configured as an
MCP resource server, then you can exercise /mcp with real Keycloak tokens.
Set LICENSE_KEY=<KEY> to skip the interactive license prompt:
task e2e:mcp:up LICENSE_KEY=abc123
Pass extra flags via -- :
task e2e:mcp:up -- --validate --nobuild
ignore_error: true
cmds:
- bash testing/compose/start-mcp-test.sh {{if .LICENSE_KEY}}--license-key "{{.LICENSE_KEY}}"{{end}} {{.CLI_ARGS}}
mcp:manual:
desc: "Start the MCP keycloak test env in manual mode (prints URLs + a live token for your client)"
summary: |
Brings the stack up and prints copy-paste URLs/commands plus a freshly minted
access token so you can drive your own MCP client (Inspector, curl, ...).
task e2e:mcp:manual LICENSE_KEY=<your-license-key>
Add --nobuild if the images are already built:
task e2e:mcp:manual LICENSE_KEY=<your-license-key> -- --nobuild
ignore_error: true
cmds:
- bash testing/compose/start-mcp-test.sh --manual {{if .LICENSE_KEY}}--license-key "{{.LICENSE_KEY}}"{{end}} {{.CLI_ARGS}}
mcp:apikey:
desc: "Start the MCP test env in API-KEY manual mode (no OAuth/IdP): mints a key + prints client settings"
summary: |
Brings Stirling up in apikey auth mode and prints copy-paste client settings with a freshly
minted X-API-KEY - ideal for clients whose OAuth layer can't reach localhost.
task e2e:mcp:apikey LICENSE_KEY=<your-license-key>
Add --nobuild if images are already built:
task e2e:mcp:apikey LICENSE_KEY=<your-license-key> -- --nobuild
ignore_error: true
cmds:
- bash testing/compose/start-mcp-test.sh --apikey {{if .LICENSE_KEY}}--license-key "{{.LICENSE_KEY}}"{{end}} {{.CLI_ARGS}}
mcp:validate:
desc: "Validate the running MCP keycloak test environment end-to-end (oauth mode + real MCP SDK client)"
cmds:
- bash testing/compose/validate-mcp-test.sh
mcp:validate-apikey:
desc: "Validate the MCP server in API-KEY auth mode (mints a key + real MCP SDK client), then restore oauth"
cmds:
- bash testing/compose/validate-mcp-apikey.sh
mcp:down:
desc: "Stop the MCP keycloak test environment"
cmds:
- docker compose -f testing/compose/docker-compose-keycloak-mcp.yml down -v
@@ -11,23 +11,39 @@ public interface FileStore {
/** Stored file record. */
record Stored(String fileId, long size) {}
/** Store the given stream and return a generated file id and total bytes written. */
Stored store(InputStream in, String originalName) throws IOException;
/**
* Store the given stream and return a generated file id and total bytes written. {@code owner}
* may be null to indicate the file has no associated user (anonymous / desktop / async job with
* no propagated security context); a non-null value is persisted alongside the data so {@link
* #getOwner(String)} can return it later for authorization checks.
*/
Stored store(InputStream in, String originalName, String owner) throws IOException;
/** Store with no owner. Equivalent to {@link #store(InputStream, String, String)} with null. */
default Stored store(InputStream in, String originalName) throws IOException {
return store(in, originalName, null);
}
/**
* Store the file at {@code source} and return a generated file id and total bytes written.
*
* <p>Default implementation opens {@code source} as a stream and delegates to {@link
* #store(InputStream, String)}. Local-disk implementations should override to use a direct
* file-to-file copy ({@code Files.copy(source, dest)} can use {@code sendfile(2)} on Linux),
* which avoids the two-memory-copy hit of streaming a disk-backed upload through the JVM heap.
* #store(InputStream, String, String)}. Local-disk implementations should override to use a
* direct file-to-file copy ({@code Files.copy(source, dest)} can use {@code sendfile(2)} on
* Linux), which avoids the two-memory-copy hit of streaming a disk-backed upload through the
* JVM heap.
*/
default Stored store(Path source, String originalName) throws IOException {
default Stored store(Path source, String originalName, String owner) throws IOException {
try (InputStream in = Files.newInputStream(source)) {
return store(in, originalName);
return store(in, originalName, owner);
}
}
/** Store with no owner. Equivalent to {@link #store(Path, String, String)} with null. */
default Stored store(Path source, String originalName) throws IOException {
return store(source, originalName, null);
}
/** Open the stored file for streaming reads. Caller closes. */
InputStream retrieve(String fileId) throws IOException;
@@ -42,4 +58,12 @@ public interface FileStore {
/** Whether the file id exists in the store. */
boolean exists(String fileId);
/**
* Returns the owner identifier recorded at store time, or {@code null} if the file does not
* exist or was stored without an owner. Implementations must not throw when the file is missing
* or when the owner record is absent; they should return null so callers can treat "no owner"
* as a non-authoritative case.
*/
String getOwner(String fileId) throws IOException;
}
@@ -3,9 +3,12 @@ package stirling.software.common.cluster.inprocess;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.locks.ReentrantLock;
import java.util.regex.Pattern;
import lombok.extern.slf4j.Slf4j;
@@ -15,33 +18,47 @@ import stirling.software.common.cluster.FileStore;
@Slf4j
public class LocalDiskFileStore implements FileStore {
private static final String OWNER_SUFFIX = ".owner";
// File ids are generated as random UUIDs; reject anything else so a tainted id can never reach
// Files.* APIs (defence in depth on top of the resolve() prefix check, and silences CodeQL's
// path-injection finding on the resolveOwner sidecar lookup).
private static final Pattern UUID_PATTERN =
Pattern.compile(
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$");
private final String baseDirPath;
// Fixed-size lock stripes so concurrent store/delete on the same (or colliding) fileId
// serialise the data-file + owner-sidecar pair as one critical section. Striped (not
// per-id) so the map never has to be cleaned up; collisions across unrelated ids are
// harmless contention.
private static final int LOCK_STRIPES = 64;
private final ReentrantLock[] stripes = new ReentrantLock[LOCK_STRIPES];
public LocalDiskFileStore(String baseDirPath) {
this.baseDirPath = baseDirPath;
for (int i = 0; i < LOCK_STRIPES; i++) {
stripes[i] = new ReentrantLock();
}
}
@Override
public Stored store(InputStream in, String originalName) throws IOException {
public Stored store(InputStream in, String originalName, String owner) throws IOException {
String fileId = UUID.randomUUID().toString();
Path filePath = resolve(fileId);
Files.createDirectories(filePath.getParent());
ReentrantLock lock = acquire(fileId);
boolean success = false;
try {
long size = Files.copy(in, filePath);
writeOwner(fileId, owner);
success = true;
return new Stored(fileId, size);
} finally {
if (!success) {
try {
Files.deleteIfExists(filePath);
} catch (IOException cleanupEx) {
log.warn(
"Failed to clean up partial file {} after store failure",
filePath,
cleanupEx);
}
cleanupAfterFailedStore(fileId, filePath);
}
release(fileId, lock);
}
}
@@ -52,27 +69,44 @@ public class LocalDiskFileStore implements FileStore {
* the source size before copying so the post-copy stat is unnecessary.
*/
@Override
public Stored store(Path source, String originalName) throws IOException {
public Stored store(Path source, String originalName, String owner) throws IOException {
String fileId = UUID.randomUUID().toString();
Path filePath = resolve(fileId);
Files.createDirectories(filePath.getParent());
long size = Files.size(source);
ReentrantLock lock = acquire(fileId);
boolean success = false;
try {
Files.copy(source, filePath);
writeOwner(fileId, owner);
success = true;
return new Stored(fileId, size);
} finally {
if (!success) {
try {
Files.deleteIfExists(filePath);
} catch (IOException cleanupEx) {
log.warn(
"Failed to clean up partial file {} after store failure",
filePath,
cleanupEx);
}
cleanupAfterFailedStore(fileId, filePath);
}
release(fileId, lock);
}
}
private void writeOwner(String fileId, String owner) throws IOException {
if (owner == null || owner.isBlank()) {
return;
}
Path ownerPath = resolveOwner(fileId);
Files.write(ownerPath, owner.getBytes(StandardCharsets.UTF_8));
}
private void cleanupAfterFailedStore(String fileId, Path filePath) {
try {
Files.deleteIfExists(filePath);
} catch (IOException cleanupEx) {
log.warn("Failed to clean up partial file {} after store failure", filePath, cleanupEx);
}
try {
Files.deleteIfExists(resolveOwner(fileId));
} catch (IOException cleanupEx) {
log.warn("Failed to clean up owner sidecar for {} after store failure", fileId);
}
}
@@ -101,11 +135,26 @@ public class LocalDiskFileStore implements FileStore {
@Override
public boolean delete(String fileId) {
ReentrantLock lock = acquire(fileId);
try {
return Files.deleteIfExists(resolve(fileId));
} catch (IOException e) {
log.error("Error deleting file with ID: {}", fileId, e);
return false;
// Data first, owner second: a concurrent retrieve that observes the transient
// (data-gone, owner-still-present) window simply fails with IOException; the inverse
// order would briefly look like an unowned file and could grant cross-user access.
boolean removed;
try {
removed = Files.deleteIfExists(resolve(fileId));
} catch (IOException e) {
log.error("Error deleting file with ID: {}", fileId, e);
return false;
}
try {
Files.deleteIfExists(resolveOwner(fileId));
} catch (IOException e) {
log.warn("Error deleting owner sidecar for file ID: {}", fileId, e);
}
return removed;
} finally {
release(fileId, lock);
}
}
@@ -114,8 +163,21 @@ public class LocalDiskFileStore implements FileStore {
return Files.exists(resolve(fileId));
}
@Override
public String getOwner(String fileId) throws IOException {
Path ownerPath = resolveOwner(fileId);
if (!Files.exists(ownerPath)) {
return null;
}
byte[] bytes = Files.readAllBytes(ownerPath);
if (bytes.length == 0) {
return null;
}
return new String(bytes, StandardCharsets.UTF_8);
}
public Path resolve(String fileId) {
if (fileId.contains("..") || fileId.contains("/") || fileId.contains("\\")) {
if (fileId == null || !UUID_PATTERN.matcher(fileId).matches()) {
throw new IllegalArgumentException("Invalid file ID");
}
Path basePath = Path.of(baseDirPath).normalize().toAbsolutePath();
@@ -125,4 +187,19 @@ public class LocalDiskFileStore implements FileStore {
}
return resolvedPath;
}
private Path resolveOwner(String fileId) {
Path data = resolve(fileId);
return data.resolveSibling(data.getFileName().toString() + OWNER_SUFFIX);
}
private ReentrantLock acquire(String fileId) {
ReentrantLock lock = stripes[(fileId.hashCode() & Integer.MAX_VALUE) % LOCK_STRIPES];
lock.lock();
return lock;
}
private void release(String fileId, ReentrantLock lock) {
lock.unlock();
}
}
@@ -77,6 +77,7 @@ public class ApplicationProperties {
private ProcessExecutor processExecutor = new ProcessExecutor();
private PdfEditor pdfEditor = new PdfEditor();
private AiEngine aiEngine = new AiEngine();
private Mcp mcp = new Mcp();
private InternalApi internalApi = new InternalApi();
private Cluster cluster = new Cluster();
@@ -256,6 +257,94 @@ public class ApplicationProperties {
private int longRunningTimeoutSeconds = 600;
}
/**
* Model Context Protocol (MCP) server configuration. All keys live under the top-level {@code
* mcp.*} prefix. {@link #enabled} defaults to {@code false}: when off, no MCP beans are wired,
* no /mcp endpoint exists, and no protected-resource metadata is published.
*/
@Data
public static class Mcp {
/** Master switch. When {@code false} (default), no MCP beans are wired. */
private boolean enabled = false;
/**
* When {@code true} (default), invocations require an OAuth scope: {@code mcp.tools.read}
* for read-style operations and {@code mcp.tools.write} for write/destructive ones. When
* {@code false}, scope checks are skipped (use only if your IdP issues a single coarse
* scope).
*/
private boolean scopesEnabled = true;
/** How often to refresh the AI capabilities manifest from the engine. */
private int engineCapabilityRefreshMinutes = 5;
/**
* Tool allow-list (operation ids, e.g. {@code compress-pdf}). When non-empty, ONLY these
* operations are exposed over MCP; everything else is hidden, undescribable, and
* uninvocable - on top of the global endpoint enable/disable config. Empty = allow all.
*/
private List<String> allowedOperations = new ArrayList<>();
/**
* Tool deny-list (operation ids). Any operation listed here is removed from MCP even if it
* would otherwise be allowed. Applied after {@link #allowedOperations}.
*/
private List<String> blockedOperations = new ArrayList<>();
/** Max MCP request body size in bytes; inline file uploads ride in the JSON-RPC body. */
private long maxRequestBytes = 10L * 1024 * 1024;
/** Results up to this size return inline as base64; larger ones return a fileId only. */
private long maxInlineResponseBytes = 10L * 1024 * 1024;
private Auth auth = new Auth();
@Data
public static class Auth {
/**
* Authentication mode for the MCP endpoint. {@code oauth} (default) runs a full OAuth2
* resource server (JWT, RFC 8707 audience, RFC 9728 metadata). {@code apikey} accepts a
* Stirling per-user API key via the {@code X-API-KEY} header (or {@code Authorization:
* Bearer <key>}) and binds the request to that user - the low-friction self-host path,
* no external IdP required.
*/
private String mode = "oauth";
/** OAuth2 issuer URI, e.g. {@code http://localhost:9000}. Required when MCP is on. */
private String issuerUri = "";
/**
* JWKS URI. When blank, derived from the issuer's {@code
* /.well-known/openid-configuration} document.
*/
private String jwksUri = "";
/**
* RFC 8707 resource identifier of THIS MCP server, e.g. {@code
* http://localhost:8080/mcp}. Tokens that do not list this id in their {@code aud}
* claim are rejected with HTTP 401.
*/
private String resourceId = "";
/**
* JWT claim whose value is matched against a provisioned Stirling username. Defaults to
* {@code sub}; set to {@code email} or {@code preferred_username} to match how your IdP
* maps users to Stirling accounts.
*/
private String usernameClaim = "sub";
/**
* When {@code true} (default), a validated token is accepted only if its {@link
* #usernameClaim} value resolves to an existing, enabled Stirling user account. Tokens
* whose subject has no Stirling account (or a disabled one) are rejected with HTTP 403.
* Set to {@code false} only if you intentionally want any IdP-valid token to use MCP
* without a local account.
*/
private boolean requireExistingAccount = true;
}
}
/**
* Cluster backplane configuration. All keys live under the top-level {@code cluster.*} prefix
* (e.g. env var {@code CLUSTER_ENABLED}). The master switch is {@link #enabled} and defaults to
@@ -5,6 +5,7 @@ import java.io.IOException;
import java.io.InputStream;
import java.io.PipedInputStream;
import java.io.PipedOutputStream;
import java.util.Optional;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicReference;
@@ -17,6 +18,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.FileStore;
import stirling.software.common.util.JobContext;
/**
* Service for storing and retrieving files with unique file IDs. Used by the AutoJobPostMapping
@@ -32,8 +34,10 @@ public class FileStorage {
private final FileOrUploadService fileOrUploadService;
private final FileStore fileStore;
private final Optional<JobOwnershipService> jobOwnershipService;
public String storeFile(MultipartFile file) throws IOException {
String owner = resolveOwner();
// Fast path: when Spring buffered the multipart to disk (typical for large uploads), the
// backing Resource exposes a real File. Hand the Path to the FileStore so it can do a
// file-to-file copy (Linux sendfile, no copy through Java heap) rather than streaming
@@ -48,7 +52,7 @@ public class FileStorage {
if (res != null && res.isFile()) {
try {
FileStore.Stored stored =
fileStore.store(res.getFile().toPath(), file.getOriginalFilename());
fileStore.store(res.getFile().toPath(), file.getOriginalFilename(), owner);
log.debug("Stored file with ID: {} (fast path)", stored.fileId());
return stored.fileId();
} catch (IOException ex) {
@@ -57,40 +61,45 @@ public class FileStorage {
}
}
try (InputStream in = file.getInputStream()) {
FileStore.Stored stored = fileStore.store(in, file.getOriginalFilename());
FileStore.Stored stored = fileStore.store(in, file.getOriginalFilename(), owner);
log.debug("Stored file with ID: {}", stored.fileId());
return stored.fileId();
}
}
public String storeBytes(byte[] bytes, String originalName) throws IOException {
FileStore.Stored stored = fileStore.store(new ByteArrayInputStream(bytes), originalName);
FileStore.Stored stored =
fileStore.store(new ByteArrayInputStream(bytes), originalName, resolveOwner());
log.debug("Stored byte array with ID: {}", stored.fileId());
return stored.fileId();
}
public MultipartFile retrieveFile(String fileId) throws IOException {
enforceOwnership(fileId);
byte[] fileData = fileStore.retrieveBytes(fileId);
return fileOrUploadService.toMockMultipartFile(fileId, fileData);
}
public byte[] retrieveBytes(String fileId) throws IOException {
enforceOwnership(fileId);
return fileStore.retrieveBytes(fileId);
}
public InputStream retrieveInputStream(String fileId) throws IOException {
enforceOwnership(fileId);
return fileStore.retrieve(fileId);
}
public StoredFile storeInputStream(InputStream inputStream, String originalName)
throws IOException {
FileStore.Stored stored = fileStore.store(inputStream, originalName);
FileStore.Stored stored = fileStore.store(inputStream, originalName, resolveOwner());
log.debug("Stored input stream with ID: {}", stored.fileId());
return new StoredFile(stored.fileId(), stored.size());
}
public String storeFromStreamingBody(StreamingResponseBody body, String originalName)
throws IOException {
String owner = resolveOwner();
// Hold Throwable not IOException: an unchecked failure (NPE, IllegalState, OOM, etc.)
// from the body writer would otherwise close the pipe with EOF and the consumer would
// return a truncated file with no error surfaced to the caller.
@@ -115,7 +124,7 @@ public class FileStorage {
}
}
});
FileStore.Stored stored = fileStore.store(in, originalName);
FileStore.Stored stored = fileStore.store(in, originalName, owner);
Throwable writerErr = bodyError.get();
if (writerErr != null) {
// Body failed mid-write: the FileStore persisted a truncated entry.
@@ -159,21 +168,62 @@ public class FileStorage {
public String storeFromResource(Resource resource, String originalName) throws IOException {
try (InputStream in = resource.getInputStream()) {
FileStore.Stored stored = fileStore.store(in, originalName);
FileStore.Stored stored = fileStore.store(in, originalName, resolveOwner());
log.debug("Stored Resource with ID: {}", stored.fileId());
return stored.fileId();
}
}
public boolean deleteFile(String fileId) {
enforceOwnership(fileId);
return fileStore.delete(fileId);
}
public boolean fileExists(String fileId) {
enforceOwnership(fileId);
return fileStore.exists(fileId);
}
public long getFileSize(String fileId) throws IOException {
enforceOwnership(fileId);
return fileStore.size(fileId);
}
private String resolveOwner() {
String propagated = JobContext.getOwner();
if (propagated != null) {
return propagated;
}
return jobOwnershipService.flatMap(JobOwnershipService::getCurrentUserId).orElse(null);
}
private void enforceOwnership(String fileId) {
if (jobOwnershipService.isEmpty()) {
return;
}
Optional<String> currentUser = jobOwnershipService.get().getCurrentUserId();
if (currentUser.isEmpty()) {
return;
}
String owner;
try {
owner = fileStore.getOwner(fileId);
} catch (IOException e) {
log.warn("Failed to read owner for file {}: {}", fileId, e.getMessage());
throw new SecurityException(
"Access denied: could not verify ownership of the requested file");
}
if (owner == null) {
return;
}
if (!owner.equals(currentUser.get())) {
log.warn(
"Access denied: user {} attempted to access file {} owned by {}",
currentUser.get(),
fileId,
owner);
throw new SecurityException(
"Access denied: you do not have permission to access this file");
}
}
}
@@ -89,6 +89,11 @@ public class JobExecutorService {
String jobId = scopedJobKey;
final String jobOwner =
jobOwnershipService != null
? jobOwnershipService.getCurrentUserId().orElse(null)
: null;
long timeoutToUse = customTimeoutMs > 0 ? customTimeoutMs : effectiveTimeoutMs;
log.debug(
@@ -119,6 +124,7 @@ public class JobExecutorService {
try {
stirling.software.common.util.JobContext.setJobId(
capturedJobIdForQueue);
stirling.software.common.util.JobContext.setOwner(jobOwner);
Object result = work.get();
processJobResult(capturedJobIdForQueue, result);
return result;
@@ -153,6 +159,7 @@ public class JobExecutorService {
timeoutToUse);
stirling.software.common.util.JobContext.setJobId(capturedJobId);
stirling.software.common.util.JobContext.setOwner(jobOwner);
Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
processJobResult(capturedJobId, result);
} catch (TimeoutException te) {
@@ -1,8 +1,9 @@
package stirling.software.common.util;
/** Thread-local context for passing job ID across async boundaries */
/** Thread-local context for passing job ID and owner across async boundaries */
public class JobContext {
private static final ThreadLocal<String> CURRENT_JOB_ID = new ThreadLocal<>();
private static final ThreadLocal<String> CURRENT_OWNER = new ThreadLocal<>();
public static void setJobId(String jobId) {
CURRENT_JOB_ID.set(jobId);
@@ -12,7 +13,16 @@ public class JobContext {
return CURRENT_JOB_ID.get();
}
public static void setOwner(String owner) {
CURRENT_OWNER.set(owner);
}
public static String getOwner() {
return CURRENT_OWNER.get();
}
public static void clear() {
CURRENT_JOB_ID.remove();
CURRENT_OWNER.remove();
}
}
@@ -3,11 +3,13 @@ package stirling.software.common.cluster.inprocess;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
@@ -39,4 +41,46 @@ class LocalDiskFileStoreTest {
assertThrows(IllegalArgumentException.class, () -> store.resolve("a/b"));
assertThrows(IllegalArgumentException.class, () -> store.resolve("a\\b"));
}
@Test
void ownerSidecarCannotBeReadAsFileId(@TempDir Path dir) throws IOException {
LocalDiskFileStore store = new LocalDiskFileStore(dir.toString());
FileStore.Stored stored =
store.store(new ByteArrayInputStream("hi".getBytes()), "f.bin", "alice");
String sidecarId = stored.fileId() + ".owner";
assertThrows(IllegalArgumentException.class, () -> store.resolve(sidecarId));
assertThrows(IllegalArgumentException.class, () -> store.retrieveBytes(sidecarId));
}
@Test
void ownerIsPersistedAndReturnedByGetOwner(@TempDir Path dir) throws IOException {
LocalDiskFileStore store = new LocalDiskFileStore(dir.toString());
FileStore.Stored stored =
store.store(new ByteArrayInputStream("hi".getBytes()), "f.bin", "alice");
assertEquals("alice", store.getOwner(stored.fileId()));
}
@Test
void getOwnerReturnsNullWhenNoOwnerWasRecorded(@TempDir Path dir) throws IOException {
LocalDiskFileStore store = new LocalDiskFileStore(dir.toString());
FileStore.Stored stored =
store.store(new ByteArrayInputStream("hi".getBytes()), "f.bin", null);
assertNull(store.getOwner(stored.fileId()));
}
@Test
void getOwnerReturnsNullForUnknownFileId(@TempDir Path dir) throws IOException {
LocalDiskFileStore store = new LocalDiskFileStore(dir.toString());
assertNull(store.getOwner("00000000-0000-0000-0000-000000000000"));
}
@Test
void deleteRemovesOwnerSidecar(@TempDir Path dir) throws IOException {
LocalDiskFileStore store = new LocalDiskFileStore(dir.toString());
FileStore.Stored stored =
store.store(new ByteArrayInputStream("hi".getBytes()), "f.bin", "alice");
assertTrue(store.delete(stored.fileId()));
assertFalse(Files.exists(dir.resolve(stored.fileId() + ".owner")));
assertNull(store.getOwner(stored.fileId()));
}
}
@@ -5,6 +5,7 @@ import static org.mockito.Mockito.mock;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -19,7 +20,8 @@ class FileStorageDelegationTest {
FileStorage fs =
new FileStorage(
mock(FileOrUploadService.class),
new LocalDiskFileStore(tempDir.toString()));
new LocalDiskFileStore(tempDir.toString()),
Optional.empty());
byte[] payload = "round-trip".getBytes();
String id = fs.storeBytes(payload, "x.bin");
assertArrayEquals(payload, fs.retrieveBytes(id));
@@ -0,0 +1,107 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import stirling.software.common.cluster.inprocess.LocalDiskFileStore;
import stirling.software.common.util.JobContext;
class FileStorageOwnershipTest {
private FileStorage newStorageWithoutSecurity(Path tempDir) {
return new FileStorage(
mock(FileOrUploadService.class),
new LocalDiskFileStore(tempDir.toString()),
Optional.empty());
}
private FileStorage newStorageWithCurrentUser(Path tempDir, AtomicReference<String> userRef) {
JobOwnershipService svc = mock(JobOwnershipService.class);
when(svc.getCurrentUserId()).thenAnswer(invocation -> Optional.ofNullable(userRef.get()));
return new FileStorage(
mock(FileOrUploadService.class),
new LocalDiskFileStore(tempDir.toString()),
Optional.of(svc));
}
@Test
void desktopMode_noOwnershipService_storesAndRetrievesWithoutChecks(@TempDir Path tempDir)
throws IOException {
FileStorage fs = newStorageWithoutSecurity(tempDir);
byte[] payload = "desktop".getBytes();
String id = fs.storeBytes(payload, "x.bin");
assertArrayEquals(payload, fs.retrieveBytes(id));
}
@Test
void sameUserStoresAndRetrieves_allowed(@TempDir Path tempDir) throws IOException {
AtomicReference<String> user = new AtomicReference<>("alice");
FileStorage fs = newStorageWithCurrentUser(tempDir, user);
byte[] payload = "alice's file".getBytes();
String id = fs.storeBytes(payload, "x.bin");
assertArrayEquals(payload, fs.retrieveBytes(id));
}
@Test
void differentUserRetrieves_throwsSecurityException(@TempDir Path tempDir) throws IOException {
AtomicReference<String> user = new AtomicReference<>("alice");
FileStorage fs = newStorageWithCurrentUser(tempDir, user);
String id = fs.storeBytes("alice's file".getBytes(), "x.bin");
user.set("bob");
assertThrows(SecurityException.class, () -> fs.retrieveBytes(id));
assertThrows(SecurityException.class, () -> fs.retrieveInputStream(id));
assertThrows(SecurityException.class, () -> fs.getFileSize(id));
assertThrows(SecurityException.class, () -> fs.fileExists(id));
assertThrows(SecurityException.class, () -> fs.deleteFile(id));
}
@Test
void anonymousRetrieveOfOwnedFile_allowed_noCurrentUserMeansNoCompare(@TempDir Path tempDir)
throws IOException {
AtomicReference<String> user = new AtomicReference<>("alice");
FileStorage fs = newStorageWithCurrentUser(tempDir, user);
byte[] payload = "alice's file".getBytes();
String id = fs.storeBytes(payload, "x.bin");
user.set(null);
assertArrayEquals(payload, fs.retrieveBytes(id));
}
@Test
void authedRetrieveOfAnonymousFile_allowed_noOwnerOnFile(@TempDir Path tempDir)
throws IOException {
AtomicReference<String> user = new AtomicReference<>(null);
FileStorage fs = newStorageWithCurrentUser(tempDir, user);
byte[] payload = "no-owner".getBytes();
String id = fs.storeBytes(payload, "x.bin");
user.set("alice");
assertArrayEquals(payload, fs.retrieveBytes(id));
}
@Test
void propagatedOwner_scopesAsyncWriteWithNoLiveUser(@TempDir Path tempDir) throws IOException {
AtomicReference<String> user = new AtomicReference<>(null);
FileStorage fs = newStorageWithCurrentUser(tempDir, user);
byte[] payload = "alice's async result".getBytes();
String id;
try {
JobContext.setOwner("alice");
id = fs.storeBytes(payload, "x.bin");
} finally {
JobContext.clear();
}
user.set("alice");
assertArrayEquals(payload, fs.retrieveBytes(id));
user.set("bob");
assertThrows(SecurityException.class, () -> fs.retrieveBytes(id));
}
}
@@ -9,6 +9,8 @@ import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeEach;
@@ -37,7 +39,10 @@ class FileStorageTest {
void setUp() throws IOException {
MockitoAnnotations.openMocks(this);
fileStorage =
new FileStorage(fileOrUploadService, new LocalDiskFileStore(tempDir.toString()));
new FileStorage(
fileOrUploadService,
new LocalDiskFileStore(tempDir.toString()),
Optional.empty());
// Create a mock MultipartFile
mockFile = mock(MultipartFile.class);
@@ -79,7 +84,7 @@ class FileStorageTest {
void testRetrieveFile() throws IOException {
// Arrange
byte[] fileContent = "Test PDF content".getBytes();
String fileId = "test-file-1";
String fileId = UUID.randomUUID().toString();
Path filePath = tempDir.resolve(fileId);
Files.write(filePath, fileContent);
@@ -99,7 +104,7 @@ class FileStorageTest {
void testRetrieveBytes() throws IOException {
// Arrange
byte[] fileContent = "Test PDF content".getBytes();
String fileId = "test-file-2";
String fileId = UUID.randomUUID().toString();
Path filePath = tempDir.resolve(fileId);
Files.write(filePath, fileContent);
@@ -113,7 +118,7 @@ class FileStorageTest {
@Test
void testRetrieveFile_FileNotFound() {
// Arrange
String nonExistentFileId = "non-existent-file";
String nonExistentFileId = UUID.randomUUID().toString();
// Act & Assert
assertThrows(IOException.class, () -> fileStorage.retrieveFile(nonExistentFileId));
@@ -122,7 +127,7 @@ class FileStorageTest {
@Test
void testRetrieveBytes_FileNotFound() {
// Arrange
String nonExistentFileId = "non-existent-file";
String nonExistentFileId = UUID.randomUUID().toString();
// Act & Assert
assertThrows(IOException.class, () -> fileStorage.retrieveBytes(nonExistentFileId));
@@ -132,7 +137,7 @@ class FileStorageTest {
void testDeleteFile() throws IOException {
// Arrange
byte[] fileContent = "Test PDF content".getBytes();
String fileId = "test-file-3";
String fileId = UUID.randomUUID().toString();
Path filePath = tempDir.resolve(fileId);
Files.write(filePath, fileContent);
@@ -147,7 +152,7 @@ class FileStorageTest {
@Test
void testDeleteFile_FileNotFound() {
// Arrange
String nonExistentFileId = "non-existent-file";
String nonExistentFileId = UUID.randomUUID().toString();
// Act
boolean result = fileStorage.deleteFile(nonExistentFileId);
@@ -160,7 +165,7 @@ class FileStorageTest {
void testFileExists() throws IOException {
// Arrange
byte[] fileContent = "Test PDF content".getBytes();
String fileId = "test-file-4";
String fileId = UUID.randomUUID().toString();
Path filePath = tempDir.resolve(fileId);
Files.write(filePath, fileContent);
@@ -174,7 +179,7 @@ class FileStorageTest {
@Test
void testFileExists_FileNotFound() {
// Arrange
String nonExistentFileId = "non-existent-file";
String nonExistentFileId = UUID.randomUUID().toString();
// Act
boolean result = fileStorage.fileExists(nonExistentFileId);
@@ -364,6 +364,24 @@ aiEngine:
url: http://localhost:5001 # URL of the Python AI engine
timeoutSeconds: 120 # Timeout in seconds for AI engine requests
# Model Context Protocol (MCP) server. Exposes Stirling's PDF tools (grouped by namespace)
# plus the AI agents to MCP clients (Inspector, Claude Desktop, custom). OAuth-protected.
# Disabled by default - enable explicitly per deployment after configuring mcp.auth.
mcp:
enabled: false # Master switch. 'false' (default) means no /mcp endpoint, no metadata, no beans wired.
scopesEnabled: true # Enforce mcp.tools.read / mcp.tools.write scopes derived from operation category
allowedOperations: [] # Tool allow-list (operation ids, e.g. ['compress-pdf']). Empty = all. When set, ONLY these are exposed over MCP.
blockedOperations: [] # Tool deny-list (operation ids). Always removed from MCP even if otherwise allowed.
auth:
mode: oauth # 'oauth' (full OAuth2 resource server) or 'apikey' (Stirling per-user API key via X-API-KEY header; no external IdP needed - the low-friction self-host option)
issuerUri: "" # OAuth2 issuer URI (e.g. http://localhost:9000). Required when mode=oauth.
jwksUri: "" # JWKS URI. Blank -> derived from issuer's /.well-known/openid-configuration.
resourceId: "" # RFC 8707 resource identifier of THIS MCP server (e.g. http://localhost:8080/mcp).
# Required: tokens must list this id in `aud` or the request is rejected.
usernameClaim: sub # JWT claim matched against a Stirling username (e.g. 'sub', 'email', 'preferred_username')
requireExistingAccount: true # Reject tokens whose subject has no enabled Stirling account (recommended)
engineCapabilityRefreshMinutes: 5 # How often to refresh the AI capabilities manifest from the engine
# Cluster configuration. NOT YET ENABLED - scaffolding for later work. Leave at defaults.
cluster:
enabled: false # Master switch. 'false' (default) wires the in-process backplane and skips all cluster checks. Single-instance installs do not need to change anything here.
+4
View File
@@ -52,6 +52,10 @@ dependencies {
api 'org.springframework.boot:spring-boot-starter-security'
api 'org.springframework.boot:spring-boot-starter-data-jpa'
api 'org.springframework.boot:spring-boot-starter-security-oauth2-client'
// MCP server (RFC 8707 audience binding + RFC 9728 metadata) - resource-server side only.
// Brings nimbus-jose-jwt onto the proprietary classpath if not already transitive via
// oauth2-client; on Boot 4.0.6 the delta is around 130KB because nimbus is already pulled.
api 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
api 'org.springframework.boot:spring-boot-starter-mail'
api 'org.springframework.boot:spring-boot-starter-cache'
api 'com.github.ben-manes.caffeine:caffeine'
@@ -6,6 +6,8 @@ import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Collections;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@@ -35,6 +37,7 @@ import software.amazon.awssdk.services.s3.model.S3Exception;
public class S3FileStore implements FileStore, AutoCloseable {
public static final String DEFAULT_KEY_PREFIX = "transient/";
static final String OWNER_METADATA_KEY = "owner";
private final S3Client s3Client;
private final String bucket;
@@ -64,7 +67,7 @@ public class S3FileStore implements FileStore, AutoCloseable {
}
@Override
public Stored store(InputStream in, String originalName) throws IOException {
public Stored store(InputStream in, String originalName, String owner) throws IOException {
String fileId = UUID.randomUUID().toString();
// S3 PUT requires a known content-length; spool to a temp file first so memory stays
// bounded for large payloads, then stream the file to S3 via RequestBody.fromFile.
@@ -75,10 +78,13 @@ public class S3FileStore implements FileStore, AutoCloseable {
Files.copy(src, tempFile, StandardCopyOption.REPLACE_EXISTING);
}
size = Files.size(tempFile);
PutObjectRequest request =
PutObjectRequest.builder().bucket(bucket).key(resolveKey(fileId)).build();
PutObjectRequest.Builder builder =
PutObjectRequest.builder().bucket(bucket).key(resolveKey(fileId));
if (owner != null && !owner.isBlank()) {
builder.metadata(Map.of(OWNER_METADATA_KEY, owner));
}
try {
s3Client.putObject(request, RequestBody.fromFile(tempFile));
s3Client.putObject(builder.build(), RequestBody.fromFile(tempFile));
} catch (SdkException e) {
throw new IOException("Failed to upload object to S3", e);
}
@@ -185,6 +191,36 @@ public class S3FileStore implements FileStore, AutoCloseable {
}
}
@Override
public String getOwner(String fileId) throws IOException {
try {
validateFileId(fileId);
} catch (IllegalArgumentException e) {
return null;
}
HeadObjectRequest request =
HeadObjectRequest.builder().bucket(bucket).key(resolveKey(fileId)).build();
try {
HeadObjectResponse response = s3Client.headObject(request);
Map<String, String> metadata =
Optional.ofNullable(response.metadata()).orElse(Collections.emptyMap());
String owner = metadata.get(OWNER_METADATA_KEY);
if (owner != null && !owner.isBlank()) {
return owner;
}
return null;
} catch (NoSuchKeyException e) {
return null;
} catch (S3Exception e) {
if (e.statusCode() == 404) {
return null;
}
throw new IOException("Failed to read owner metadata from S3", e);
} catch (SdkException e) {
throw new IOException("Failed to read owner metadata from S3", e);
}
}
@Override
public void close() {
if (!ownsClient) {
@@ -205,7 +241,7 @@ public class S3FileStore implements FileStore, AutoCloseable {
if (fileId == null || fileId.isBlank()) {
throw new IllegalArgumentException("File ID must not be blank");
}
if (fileId.contains("..") || fileId.contains("/") || fileId.contains("\\")) {
if (fileId.contains(".") || fileId.contains("/") || fileId.contains("\\")) {
throw new IllegalArgumentException("Invalid file ID");
}
}
@@ -0,0 +1,15 @@
package stirling.software.proprietary.mcp;
import java.util.Set;
/** Per-call context: resolved Stirling identity and granted scopes for an {@link McpTool#call}. */
public record McpCallContext(
String stirlingUserId, Set<String> grantedScopes, boolean scopesEnabled) {
public boolean hasScope(String required) {
if (!scopesEnabled) {
return true;
}
return required == null || required.isBlank() || grantedScopes.contains(required);
}
}
@@ -0,0 +1,217 @@
package stirling.software.proprietary.mcp;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.mcp.jsonrpc.JsonRpcError;
import stirling.software.proprietary.mcp.jsonrpc.JsonRpcRequest;
import stirling.software.proprietary.mcp.jsonrpc.JsonRpcResponse;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
/** Streamable-HTTP MCP server endpoint serving JSON-RPC 2.0 frames on {@code POST /mcp}. */
@Slf4j
@RestController
@RequestMapping
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
public class McpServerController {
private static final String PREFERRED_PROTOCOL_VERSION = "2025-06-18";
private static final Set<String> SUPPORTED_PROTOCOL_VERSIONS =
Set.of("2025-06-18", "2025-03-26", "2024-11-05");
private static final String SERVER_NAME = "stirling-pdf-mcp";
private final ObjectMapper mapper;
private final ApplicationProperties applicationProperties;
private final Map<String, McpTool> toolsByName;
public McpServerController(
ObjectMapper mapper, ApplicationProperties applicationProperties, List<McpTool> tools) {
this.mapper = mapper;
this.applicationProperties = applicationProperties;
this.toolsByName = new HashMap<>();
for (McpTool tool : tools) {
this.toolsByName.put(tool.name(), tool);
}
log.info(
"MCP server controller wired with {} tool(s): {}",
toolsByName.size(),
toolsByName.keySet());
}
@PostMapping(
path = "/mcp",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<?> handle(@RequestBody JsonNode body) {
JsonRpcRequest request = decode(body);
if (request == null) {
// Valid JSON but not a JSON-RPC request -> Invalid Request, not Parse error.
return ResponseEntity.badRequest()
.body(
JsonRpcResponse.failure(
null,
JsonRpcError.invalidRequest(
"Body is not a valid JSON-RPC 2.0 request")));
}
if (request.isNotification()) {
log.debug("Notification received: {}", sanitizeForLog(request.method()));
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}
JsonRpcResponse response;
try {
response = dispatch(request);
} catch (RuntimeException e) {
log.warn(
"MCP dispatch failed for method {}: {}",
sanitizeForLog(request.method()),
e.getMessage(),
e);
response =
JsonRpcResponse.failure(
request.id(),
JsonRpcError.internalError(
"Internal error handling " + request.method()));
}
return ResponseEntity.ok(response);
}
/** Wrap malformed-JSON failures (caught before {@link #handle}) as a JSON-RPC Parse error. */
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<JsonRpcResponse> handleUnreadable(HttpMessageNotReadableException ex) {
return ResponseEntity.badRequest()
.contentType(MediaType.APPLICATION_JSON)
.body(
JsonRpcResponse.failure(
null, JsonRpcError.parseError("Request body is not valid JSON")));
}
private static String sanitizeForLog(String value) {
return value == null ? null : value.replace('\r', ' ').replace('\n', ' ');
}
private JsonRpcRequest decode(JsonNode body) {
if (body == null || !body.isObject()) {
return null;
}
JsonNode jsonrpc = body.get("jsonrpc");
JsonNode method = body.get("method");
if (jsonrpc == null || !"2.0".equals(jsonrpc.asText())) {
return null;
}
if (method == null || !method.isTextual()) {
return null;
}
return new JsonRpcRequest(
jsonrpc.asText(), body.get("id"), method.asText(), body.get("params"));
}
private JsonRpcResponse dispatch(JsonRpcRequest request) {
return switch (request.method()) {
case "initialize" ->
JsonRpcResponse.success(request.id(), initializeResult(request.params()));
case "tools/list" -> JsonRpcResponse.success(request.id(), toolsListResult());
case "tools/call" -> handleToolsCall(request);
case "ping" -> JsonRpcResponse.success(request.id(), mapper.createObjectNode());
case "notifications/initialized" ->
JsonRpcResponse.success(request.id(), mapper.createObjectNode());
default ->
JsonRpcResponse.failure(
request.id(), JsonRpcError.methodNotFound(request.method()));
};
}
private ObjectNode initializeResult(JsonNode params) {
ObjectNode result = mapper.createObjectNode();
// Echo the client's requested protocolVersion when supported, else advertise our preferred.
String requested =
params != null && params.hasNonNull("protocolVersion")
? params.get("protocolVersion").asText()
: null;
String negotiated =
requested != null && SUPPORTED_PROTOCOL_VERSIONS.contains(requested)
? requested
: PREFERRED_PROTOCOL_VERSION;
result.put("protocolVersion", negotiated);
ObjectNode caps = result.putObject("capabilities");
caps.putObject("tools");
ObjectNode info = result.putObject("serverInfo");
info.put("name", SERVER_NAME);
info.put("version", applicationProperties.getAutomaticallyGenerated().getAppVersion());
return result;
}
private ObjectNode toolsListResult() {
ObjectNode result = mapper.createObjectNode();
ArrayNode tools = result.putArray("tools");
for (McpTool t : toolsByName.values()) {
ObjectNode entry = mapper.createObjectNode();
entry.put("name", t.name());
entry.put("description", t.description());
entry.set("inputSchema", t.inputSchema());
tools.add(entry);
}
return result;
}
private JsonRpcResponse handleToolsCall(JsonRpcRequest request) {
JsonNode params = request.params();
if (params == null || !params.isObject()) {
return JsonRpcResponse.failure(
request.id(), JsonRpcError.invalidParams("Missing params for tools/call"));
}
JsonNode nameNode = params.get("name");
if (nameNode == null || !nameNode.isTextual()) {
return JsonRpcResponse.failure(
request.id(), JsonRpcError.invalidParams("Missing tool name"));
}
McpTool tool = toolsByName.get(nameNode.asText());
if (tool == null) {
return JsonRpcResponse.failure(
request.id(), JsonRpcError.invalidParams("Unknown tool: " + nameNode.asText()));
}
JsonNode args = params.get("arguments");
McpCallContext context = resolveContext();
ObjectNode toolResult = tool.call(args == null ? mapper.createObjectNode() : args, context);
return JsonRpcResponse.success(request.id(), toolResult);
}
private McpCallContext resolveContext() {
boolean scopesEnabled = applicationProperties.getMcp().isScopesEnabled();
org.springframework.security.core.Authentication auth =
org.springframework.security.core.context.SecurityContextHolder.getContext()
.getAuthentication();
// Fail closed: no/unauthenticated principal yields an empty context so scoped ops are
// refused.
if (auth == null || !auth.isAuthenticated() || auth.getName() == null) {
return new McpCallContext(null, Set.of(), scopesEnabled);
}
java.util.Set<String> scopes = new java.util.HashSet<>();
for (org.springframework.security.core.GrantedAuthority ga : auth.getAuthorities()) {
String authority = ga.getAuthority();
if (authority != null && authority.startsWith("SCOPE_")) {
scopes.add(authority.substring("SCOPE_".length()));
}
}
return new McpCallContext(auth.getName(), scopes, scopesEnabled);
}
}
@@ -0,0 +1,18 @@
package stirling.software.proprietary.mcp;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.ObjectNode;
/** Contract every MCP tool registered with the server must satisfy. */
public interface McpTool {
String name();
String description();
/** The tool's {@code inputSchema} (an object JSON Schema) published in {@code tools/list}. */
ObjectNode inputSchema();
/** Execute the tool; the controller wraps any thrown exception as an MCP internal error. */
ObjectNode call(JsonNode arguments, McpCallContext context);
}
@@ -0,0 +1,266 @@
package stirling.software.proprietary.mcp.catalog;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.ApplicationContext;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import io.swagger.v3.oas.annotations.Operation;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.common.model.ApplicationProperties;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/**
* Discovers MCP-exposable operations and caches a per-op {@link OperationMeta}. Refreshed on {@link
* ContextRefreshedEvent} and filtered on read by {@link
* EndpointConfiguration#isEndpointEnabledForUri}. AI capabilities are fed in via {@link
* #replaceAiCapabilities}.
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
public class McpToolCatalog {
private static final String WRITE_SCOPE = "mcp.tools.write";
private final ApplicationContext applicationContext;
private final EndpointConfiguration endpointConfiguration;
private final ApplicationProperties applicationProperties;
private final SimpleSchemaGenerator schemaGenerator;
private final ObjectMapper objectMapper;
// Concurrent: written on the boot thread, read on request threads, AI map replaced at runtime.
private final Map<String, OperationMeta> pdfOps = new ConcurrentHashMap<>();
// Engine-driven AI capabilities. Replaced wholesale by the scheduled refresh task on a
// background thread while request threads read via findByOperationId/enabledOps. The volatile
// reference makes the swap publication-safe; readers either see the old or the new snapshot,
// never a partially-merged one.
private volatile Map<String, OperationMeta> aiOps = new ConcurrentHashMap<>();
public McpToolCatalog(
ApplicationContext applicationContext,
EndpointConfiguration endpointConfiguration,
ApplicationProperties applicationProperties,
ObjectMapper objectMapper) {
this.applicationContext = applicationContext;
this.endpointConfiguration = endpointConfiguration;
this.applicationProperties = applicationProperties;
this.schemaGenerator = new SimpleSchemaGenerator(objectMapper);
this.objectMapper = objectMapper;
}
/** Admin tool filter: non-empty allow list is a whitelist; block list always removes. */
private boolean isOperationAllowed(String id) {
ApplicationProperties.Mcp mcp = applicationProperties.getMcp();
List<String> allowed = mcp.getAllowedOperations();
List<String> blocked = mcp.getBlockedOperations();
if (blocked != null && blocked.contains(id)) {
return false;
}
if (allowed != null && !allowed.isEmpty()) {
return allowed.contains(id);
}
return true;
}
@EventListener(ContextRefreshedEvent.class)
public void discover() {
pdfOps.clear();
for (RequestMappingHandlerMapping mapping :
applicationContext.getBeansOfType(RequestMappingHandlerMapping.class).values()) {
for (Map.Entry<RequestMappingInfo, HandlerMethod> e :
mapping.getHandlerMethods().entrySet()) {
indexOne(e.getKey(), e.getValue());
}
}
log.info("MCP tool catalog discovered {} PDF operation(s)", pdfOps.size());
}
private void indexOne(RequestMappingInfo info, HandlerMethod handler) {
Set<String> patterns = extractPatterns(info);
if (patterns.isEmpty()) {
return;
}
Set<RequestMethod> methods = info.getMethodsCondition().getMethods();
if (!isInvocableMethod(methods)) {
return;
}
for (String pattern : patterns) {
OperationCategory category = OperationCategory.fromUrl(pattern);
if (category == null) {
continue;
}
String opId = extractOpId(pattern, category);
if (opId == null) {
continue;
}
OperationMeta meta = buildMeta(opId, category, pattern, handler);
// First handler wins on duplicate URLs.
pdfOps.putIfAbsent(opId, meta);
}
}
private OperationMeta buildMeta(
String opId, OperationCategory category, String url, HandlerMethod handler) {
Method method = handler.getMethod();
Operation opAnno = method.getAnnotation(Operation.class);
String summary =
opAnno != null && !opAnno.summary().isBlank()
? opAnno.summary()
: prettifyOpId(opId);
ObjectNode schema = paramSchemaFor(handler);
// Every mutating endpoint requires the write scope.
return new OperationMeta(
opId,
category,
summary,
schema,
WRITE_SCOPE,
OperationMeta.Target.JAVA_ENDPOINT,
url,
handler);
}
private ObjectNode paramSchemaFor(HandlerMethod handler) {
Optional<Class<?>> bodyType = firstComplexParamType(handler);
return bodyType.map(schemaGenerator::toSchema).orElseGet(() -> emptyObjectSchema());
}
private ObjectNode emptyObjectSchema() {
ObjectNode out = objectMapper.createObjectNode();
out.put("type", "object");
out.put("additionalProperties", true);
return out;
}
private Optional<Class<?>> firstComplexParamType(HandlerMethod handler) {
for (MethodParameter p : handler.getMethodParameters()) {
Class<?> type = p.getParameterType();
if (type.isPrimitive() || type == String.class || type.getName().startsWith("java.")) {
continue;
}
// Skip Spring-managed parameter types (HttpServletRequest, Principal, etc.).
String pkg = type.getPackageName();
if (pkg.startsWith("jakarta.") || pkg.startsWith("org.springframework.")) {
continue;
}
return Optional.of(type);
}
return Optional.empty();
}
public List<OperationMeta> enabledOps(OperationCategory category) {
if (category == OperationCategory.AI) {
List<OperationMeta> ai = new ArrayList<>();
for (OperationMeta m : aiOps.values()) {
if (isOperationAllowed(m.id())) {
ai.add(m);
}
}
return ai;
}
List<OperationMeta> out = new ArrayList<>();
for (OperationMeta m : pdfOps.values()) {
if (m.category() == category
&& isOperationAllowed(m.id())
&& endpointConfiguration.isEndpointEnabledForUri(m.endpointPath())) {
out.add(m);
}
}
out.sort((a, b) -> a.id().compareTo(b.id()));
return out;
}
public Optional<OperationMeta> findByOperationId(String id) {
if (!isOperationAllowed(id)) {
return Optional.empty();
}
// A disabled PDF op returns empty rather than falling through to a same-id AI capability.
OperationMeta meta = pdfOps.get(id);
if (meta != null) {
boolean enabled =
meta.target() != OperationMeta.Target.JAVA_ENDPOINT
|| endpointConfiguration.isEndpointEnabledForUri(meta.endpointPath());
return enabled ? Optional.of(meta) : Optional.empty();
}
return Optional.ofNullable(aiOps.get(id));
}
/** Replace the AI capabilities snapshot. Called by the engine refresh task. */
public void replaceAiCapabilities(Map<String, OperationMeta> updated) {
// Build a fresh map then swap atomically via the volatile reference. The previous
// implementation did putAll-then-retainAll on a shared ConcurrentHashMap, which left a
// transient window where readers could observe stale entries that should have been
// removed (race between the two structural updates).
Map<String, OperationMeta> next = new ConcurrentHashMap<>(updated);
this.aiOps = next;
log.info("MCP tool catalog AI capabilities replaced: {} entries", next.size());
}
/** Only POST/PUT endpoints are exposed as tools; DELETE and GET are excluded. */
static boolean isInvocableMethod(Set<RequestMethod> methods) {
return methods.contains(RequestMethod.POST) || methods.contains(RequestMethod.PUT);
}
private static String extractOpId(String pattern, OperationCategory category) {
if (category.urlPrefix() == null || !pattern.startsWith(category.urlPrefix())) {
return null;
}
String tail = pattern.substring(category.urlPrefix().length());
if (tail.isBlank() || tail.contains("/") || tail.contains("{")) {
// Skip nested paths and path-variable templates.
return null;
}
return tail;
}
private static String prettifyOpId(String id) {
return id.replace('-', ' ');
}
private static Set<String> extractPatterns(RequestMappingInfo info) {
try {
Method getDirectPaths = info.getClass().getMethod("getDirectPaths");
Object result = getDirectPaths.invoke(info);
if (result instanceof Set<?> set) {
Set<String> patterns = new TreeSet<>();
for (Object v : set) {
if (v instanceof String s) {
patterns.add(s);
}
}
return patterns;
}
} catch (Exception e) {
log.trace("getDirectPaths unavailable on RequestMappingInfo", e);
}
return Collections.emptySet();
}
public Map<String, OperationMeta> snapshotPdfOps() {
return new LinkedHashMap<>(pdfOps);
}
}
@@ -0,0 +1,38 @@
package stirling.software.proprietary.mcp.catalog;
/** MCP tool categories; {@link #urlPrefix} maps a {@code /api/v1/} namespace to a category. */
public enum OperationCategory {
CONVERT("/api/v1/convert/", "stirling_convert"),
PAGES("/api/v1/general/", "stirling_pages"),
MISC("/api/v1/misc/", "stirling_misc"),
SECURITY("/api/v1/security/", "stirling_security"),
AI(null, "stirling_ai");
private final String urlPrefix;
private final String toolName;
OperationCategory(String urlPrefix, String toolName) {
this.urlPrefix = urlPrefix;
this.toolName = toolName;
}
public String urlPrefix() {
return urlPrefix;
}
public String toolName() {
return toolName;
}
public static OperationCategory fromUrl(String url) {
if (url == null) {
return null;
}
for (OperationCategory c : values()) {
if (c.urlPrefix != null && url.startsWith(c.urlPrefix)) {
return c;
}
}
return null;
}
}
@@ -0,0 +1,22 @@
package stirling.software.proprietary.mcp.catalog;
import org.springframework.web.method.HandlerMethod;
import tools.jackson.databind.node.ObjectNode;
/** Metadata for one MCP-exposed operation (PDF endpoint or AI capability). */
public record OperationMeta(
String id,
OperationCategory category,
String summary,
ObjectNode paramSchema,
String requiredScope,
Target target,
String endpointPath,
HandlerMethod handlerMethod) {
public enum Target {
JAVA_ENDPOINT,
ENGINE_CAPABILITY
}
}
@@ -0,0 +1,178 @@
package stirling.software.proprietary.mcp.catalog;
import java.lang.reflect.Field;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
/**
* Reflection-based JSON Schema generator for controller request-body classes. {@link MultipartFile}
* fields are emitted as {@code "type":"string"} with a {@code "format":"file-id"} hint.
*/
public final class SimpleSchemaGenerator {
private final ObjectMapper mapper;
public SimpleSchemaGenerator(ObjectMapper mapper) {
this.mapper = mapper;
}
public ObjectNode toSchema(Class<?> type) {
return toSchema(type, new HashSet<>());
}
private ObjectNode toSchema(Class<?> type, Set<Class<?>> visited) {
ObjectNode schema = mapper.createObjectNode();
schema.put("type", "object");
schema.put("additionalProperties", false);
ObjectNode properties = schema.putObject("properties");
ArrayNode required = mapper.createArrayNode();
if (!visited.add(type)) {
// Cycle: emit a loose object and bail.
schema.put("additionalProperties", true);
return schema;
}
Set<String> seen = new HashSet<>();
for (Field field : collectFields(type)) {
if (java.lang.reflect.Modifier.isStatic(field.getModifiers())
|| java.lang.reflect.Modifier.isTransient(field.getModifiers())) {
continue;
}
// Skip fields Jackson won't (de)serialize.
if (field.isAnnotationPresent(JsonIgnore.class)) {
continue;
}
String name = jsonPropertyName(field);
if (!seen.add(name)) {
continue;
}
properties.set(name, typeSchema(field.getGenericType(), visited));
if (isRequired(field)) {
required.add(name);
}
}
if (!required.isEmpty()) {
schema.set("required", required);
}
return schema;
}
private List<Field> collectFields(Class<?> type) {
List<Field> all = new ArrayList<>();
for (Class<?> c = type; c != null && c != Object.class; c = c.getSuperclass()) {
for (Field f : c.getDeclaredFields()) {
all.add(f);
}
}
return all;
}
private static String jsonPropertyName(Field field) {
JsonProperty ann = field.getAnnotation(JsonProperty.class);
if (ann != null && !ann.value().isEmpty()) {
return ann.value();
}
return field.getName();
}
private boolean isRequired(Field field) {
JsonProperty json = field.getAnnotation(JsonProperty.class);
if (json != null && json.required()) {
return true;
}
return field.isAnnotationPresent(jakarta.validation.constraints.NotNull.class)
|| field.isAnnotationPresent(jakarta.validation.constraints.NotBlank.class)
|| field.isAnnotationPresent(jakarta.validation.constraints.NotEmpty.class);
}
private ObjectNode typeSchema(Type t, Set<Class<?>> visited) {
ObjectNode out = mapper.createObjectNode();
if (t instanceof Class<?> c) {
populatePrimitive(out, c, visited);
} else if (t instanceof ParameterizedType pt) {
Type raw = pt.getRawType();
if (raw instanceof Class<?> rawClass) {
if (java.util.Collection.class.isAssignableFrom(rawClass)) {
out.put("type", "array");
Type[] args = pt.getActualTypeArguments();
if (args.length == 1) {
out.set("items", typeSchema(args[0], visited));
}
} else if (java.util.Map.class.isAssignableFrom(rawClass)) {
out.put("type", "object");
out.put("additionalProperties", true);
} else {
populatePrimitive(out, rawClass, visited);
}
} else {
out.put("type", "object");
}
} else {
out.put("type", "object");
}
return out;
}
private void populatePrimitive(ObjectNode out, Class<?> c, Set<Class<?>> visited) {
if (MultipartFile.class.isAssignableFrom(c)) {
out.put("type", "string");
out.put("format", "file-id");
out.put(
"description",
"Reference to a previously-uploaded file in Stirling's job store.");
return;
}
if (c.isArray()) {
out.put("type", "array");
out.set("items", typeSchema(c.getComponentType(), visited));
return;
}
if (c == String.class) {
out.put("type", "string");
} else if (c == boolean.class || c == Boolean.class) {
out.put("type", "boolean");
} else if (c == int.class
|| c == Integer.class
|| c == long.class
|| c == Long.class
|| c == short.class
|| c == Short.class
|| c == byte.class
|| c == Byte.class) {
out.put("type", "integer");
} else if (c == float.class || c == Float.class || c == double.class || c == Double.class) {
out.put("type", "number");
} else if (c.isEnum()) {
out.put("type", "string");
ArrayNode values = out.putArray("enum");
for (Object constant : c.getEnumConstants()) {
values.add(constant.toString());
}
} else if (c == java.util.UUID.class) {
out.put("type", "string");
out.put("format", "uuid");
} else if (java.time.temporal.Temporal.class.isAssignableFrom(c)
|| c == java.util.Date.class) {
out.put("type", "string");
out.put("format", "date-time");
} else {
// Complex bean: recurse with the shared visited set.
ObjectNode nested = toSchema(c, visited);
out.setAll(nested);
}
}
}
@@ -0,0 +1,204 @@
package stirling.software.proprietary.mcp.engine;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
import stirling.software.proprietary.mcp.catalog.OperationCategory;
import stirling.software.proprietary.mcp.catalog.OperationMeta;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/**
* Pulls the engine's capabilities manifest at boot and on a schedule, feeding it into the shared
* {@link McpToolCatalog}.
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
public class EngineCapabilityClient {
private final ApplicationProperties applicationProperties;
private final McpToolCatalog catalog;
private final ObjectMapper mapper;
private final HttpClient httpClient;
private final String sharedSecret;
private ScheduledExecutorService scheduler;
public EngineCapabilityClient(
ApplicationProperties applicationProperties,
McpToolCatalog catalog,
ObjectMapper mapper) {
this.applicationProperties = applicationProperties;
this.catalog = catalog;
this.mapper = mapper;
this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
this.sharedSecret = System.getenv("STIRLING_ENGINE_SHARED_SECRET");
}
@PostConstruct
void start() {
scheduler =
Executors.newSingleThreadScheduledExecutor(
r -> {
Thread t = new Thread(r, "mcp-engine-capability-refresh");
t.setDaemon(true);
return t;
});
}
@EventListener(ApplicationReadyEvent.class)
public void onReady() {
long minutes =
Math.max(1, applicationProperties.getMcp().getEngineCapabilityRefreshMinutes());
// First refresh immediately, then on the configured cadence.
scheduler.schedule(this::refreshSafely, 0, TimeUnit.SECONDS);
scheduler.scheduleAtFixedRate(this::refreshSafely, minutes, minutes, TimeUnit.MINUTES);
log.info("MCP engine capability refresh scheduled every {} minute(s)", minutes);
}
@PreDestroy
void stop() {
if (scheduler != null) {
scheduler.shutdownNow();
}
}
private void refreshSafely() {
try {
refresh();
} catch (Exception e) {
log.warn(
"MCP engine capability refresh failed ({}). AI tool enum stays at the last"
+ " known state until the next successful pull.",
e.getMessage());
}
}
/** Visible for testing. */
public void refresh() throws IOException, InterruptedException {
if (!applicationProperties.getAiEngine().isEnabled()) {
log.debug("AI engine disabled; skipping MCP capability refresh");
catalog.replaceAiCapabilities(Map.of());
return;
}
// Trim whitespace and any trailing slash to avoid a malformed URI.
String base = applicationProperties.getAiEngine().getUrl().strip().replaceAll("/+$", "");
URI uri = URI.create(base + "/api/v1/agents/capabilities");
HttpRequest.Builder reqBuilder =
HttpRequest.newBuilder()
.uri(uri)
.timeout(Duration.ofSeconds(10))
.header("Accept", "application/json")
.GET();
if (sharedSecret != null && !sharedSecret.isBlank()) {
reqBuilder.header("X-Engine-Auth", sharedSecret);
}
HttpResponse<String> response =
httpClient.send(reqBuilder.build(), HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException(
"Engine capabilities endpoint returned HTTP " + response.statusCode());
}
Map<String, OperationMeta> parsed = parseManifest(response.body());
catalog.replaceAiCapabilities(parsed);
}
private Map<String, OperationMeta> parseManifest(String body) throws IOException {
JsonNode root = mapper.readTree(body);
JsonNode capabilities = root.get("capabilities");
if (capabilities == null || !capabilities.isArray()) {
throw new IOException("Manifest missing 'capabilities' array");
}
Map<String, OperationMeta> out = new LinkedHashMap<>();
for (JsonNode entry : capabilities) {
JsonNode id = entry.get("id");
JsonNode desc = entry.get("description");
JsonNode schema = entry.get("input_schema");
JsonNode scope = entry.get("required_scope");
JsonNode route = entry.get("route");
if (id == null || !id.isTextual() || schema == null || !schema.isObject()) {
log.warn("Skipping malformed capability entry: {}", entry);
continue;
}
String routeValue = route == null || !route.isTextual() ? null : route.asText();
if (routeValue != null && !isSafeRelativeRoute(routeValue)) {
// Defence in depth: a tampered manifest must not steer Java at an arbitrary
// host/path.
log.warn(
"Skipping capability '{}' with unsafe route '{}' (must be a server-relative"
+ " /api path with no scheme, authority, or '..')",
id.asText(),
routeValue);
continue;
}
// Fail safe: default to the stricter write scope when the manifest omits one.
String requiredScope =
scope != null && scope.isTextual() && !scope.asText().isBlank()
? scope.asText()
: WRITE_SCOPE;
ObjectNode schemaCopy = (ObjectNode) schema.deepCopy();
out.put(
id.asText(),
new OperationMeta(
id.asText(),
OperationCategory.AI,
desc == null ? id.asText() : desc.asText(),
schemaCopy,
requiredScope,
OperationMeta.Target.ENGINE_CAPABILITY,
routeValue,
null));
}
return out;
}
private static final String WRITE_SCOPE = "mcp.tools.write";
/**
* True only for a server-relative {@code /api/} path with no scheme, authority, {@code ..}, or
* control chars (blocks SSRF / path escape).
*/
static boolean isSafeRelativeRoute(String route) {
if (route == null || route.isBlank() || !route.startsWith("/api/")) {
return false;
}
if (route.startsWith("//")
|| route.contains("..")
|| route.contains("@")
|| route.contains("\\")
|| route.contains(":")) {
return false;
}
for (int i = 0; i < route.length(); i++) {
char c = route.charAt(i);
if (c <= ' ') {
return false;
}
}
return true;
}
}
@@ -0,0 +1,36 @@
package stirling.software.proprietary.mcp.jsonrpc;
import com.fasterxml.jackson.annotation.JsonInclude;
import tools.jackson.databind.JsonNode;
/** JSON-RPC 2.0 error object. */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record JsonRpcError(int code, String message, JsonNode data) {
public static final int PARSE_ERROR = -32700;
public static final int INVALID_REQUEST = -32600;
public static final int METHOD_NOT_FOUND = -32601;
public static final int INVALID_PARAMS = -32602;
public static final int INTERNAL_ERROR = -32603;
public static JsonRpcError parseError(String message) {
return new JsonRpcError(PARSE_ERROR, message, null);
}
public static JsonRpcError invalidRequest(String message) {
return new JsonRpcError(INVALID_REQUEST, message, null);
}
public static JsonRpcError methodNotFound(String method) {
return new JsonRpcError(METHOD_NOT_FOUND, "Method not found: " + method, null);
}
public static JsonRpcError invalidParams(String message) {
return new JsonRpcError(INVALID_PARAMS, message, null);
}
public static JsonRpcError internalError(String message) {
return new JsonRpcError(INTERNAL_ERROR, message, null);
}
}
@@ -0,0 +1,14 @@
package stirling.software.proprietary.mcp.jsonrpc;
import com.fasterxml.jackson.annotation.JsonInclude;
import tools.jackson.databind.JsonNode;
/** JSON-RPC 2.0 request frame; a null {@code id} marks a notification. */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record JsonRpcRequest(String jsonrpc, JsonNode id, String method, JsonNode params) {
public boolean isNotification() {
return id == null || id.isNull();
}
}
@@ -0,0 +1,18 @@
package stirling.software.proprietary.mcp.jsonrpc;
import com.fasterxml.jackson.annotation.JsonInclude;
import tools.jackson.databind.JsonNode;
/** JSON-RPC 2.0 response; exactly one of {@code result} or {@code error} is non-null. */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record JsonRpcResponse(String jsonrpc, JsonNode id, Object result, JsonRpcError error) {
public static JsonRpcResponse success(JsonNode id, Object result) {
return new JsonRpcResponse("2.0", id, result, null);
}
public static JsonRpcResponse failure(JsonNode id, JsonRpcError error) {
return new JsonRpcResponse("2.0", id, null, error);
}
}
@@ -0,0 +1,85 @@
package stirling.software.proprietary.mcp.security;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
/**
* API-key auth for the MCP endpoint: validates a Stirling per-user API key and binds the request to
* that user with the MCP scopes.
*/
@Slf4j
public class McpApiKeyAuthFilter extends OncePerRequestFilter {
private static final List<GrantedAuthority> MCP_SCOPES =
List.of(
new SimpleGrantedAuthority("SCOPE_mcp.tools.read"),
new SimpleGrantedAuthority("SCOPE_mcp.tools.write"));
private final UserService userService;
public McpApiKeyAuthFilter(UserService userService) {
this.userService = userService;
}
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
Authentication existing = SecurityContextHolder.getContext().getAuthentication();
// Treat an anonymous token as not authenticated so the key is still processed.
boolean unauthenticated =
existing == null
|| existing instanceof AnonymousAuthenticationToken
|| !existing.isAuthenticated();
if (unauthenticated) {
String apiKey = extractKey(request);
if (apiKey != null && !apiKey.isBlank()) {
Optional<User> user = userService.getUserByApiKey(apiKey);
if (user.isPresent() && user.get().isEnabled()) {
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
user.get().getUsername(), null, MCP_SCOPES);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(auth);
SecurityContextHolder.setContext(context);
} else {
log.warn(
"MCP access denied: presented API key did not match an active account");
}
}
}
filterChain.doFilter(request, response);
}
private String extractKey(HttpServletRequest request) {
String headerKey = request.getHeader("X-API-KEY");
if (headerKey != null && !headerKey.isBlank()) {
return headerKey.trim();
}
String authz = request.getHeader("Authorization");
if (authz != null && authz.regionMatches(true, 0, "Bearer ", 0, 7)) {
return authz.substring(7).trim();
}
return null;
}
}
@@ -0,0 +1,44 @@
package stirling.software.proprietary.mcp.security;
import java.util.List;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
/**
* RFC 8707 audience binding: a JWT at the MCP endpoint must list this server's resource id in its
* {@code aud} claim. Fails closed when the resource id is unset.
*/
public class McpAudienceValidator implements OAuth2TokenValidator<Jwt> {
private final String expectedResourceId;
public McpAudienceValidator(String expectedResourceId) {
this.expectedResourceId = expectedResourceId == null ? "" : expectedResourceId;
}
@Override
public OAuth2TokenValidatorResult validate(Jwt token) {
if (expectedResourceId.isBlank()) {
return OAuth2TokenValidatorResult.failure(
new OAuth2Error(
"invalid_token",
"MCP server has no resource id configured; rejecting all tokens"
+ " until mcp.auth.resource-id is set.",
null));
}
List<String> aud = token.getAudience();
if (aud == null || !aud.contains(expectedResourceId)) {
return OAuth2TokenValidatorResult.failure(
new OAuth2Error(
"invalid_token",
"Token audience does not include this server's resource id ("
+ expectedResourceId
+ ").",
null));
}
return OAuth2TokenValidatorResult.success();
}
}
@@ -0,0 +1,76 @@
package stirling.software.proprietary.mcp.security;
import java.io.IOException;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
/**
* Emits 401 + {@code WWW-Authenticate: Bearer resource_metadata="..."} (RFC 9728), preferring
* X-Forwarded-* headers to build the public-facing metadata URL.
*/
public class McpAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final String metadataPath;
public McpAuthenticationEntryPoint(String metadataPath) {
this.metadataPath =
metadataPath == null ? "/.well-known/oauth-protected-resource" : metadataPath;
}
@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException)
throws IOException {
String scheme = firstForwarded(request, "X-Forwarded-Proto", request.getScheme());
String authority = forwardedHost(request, scheme);
String metadataUrl = scheme + "://" + authority + metadataPath;
response.setHeader(
"WWW-Authenticate",
"Bearer error=\"invalid_token\", resource_metadata=\"" + metadataUrl + "\"");
response.sendError(HttpStatus.UNAUTHORIZED.value(), "Unauthorized");
}
/** host[:port] from forwarded headers when present, else the servlet host/port. */
private static String forwardedHost(HttpServletRequest request, String scheme) {
String host = firstForwarded(request, "X-Forwarded-Host", null);
if (host != null && !host.isBlank()) {
// X-Forwarded-Host may already carry a port.
if (host.contains(":")) {
return host;
}
String fwdPort = firstForwarded(request, "X-Forwarded-Port", null);
if (fwdPort != null && !isDefaultPort(scheme, fwdPort)) {
return host + ":" + fwdPort;
}
return host;
}
String authority = request.getServerName();
int port = request.getServerPort();
if (port > 0 && !isDefaultPort(scheme, Integer.toString(port))) {
authority = authority + ":" + port;
}
return authority;
}
/** First (client-most) value of a possibly comma-listed forwarded header, trimmed. */
private static String firstForwarded(HttpServletRequest request, String name, String fallback) {
String value = request.getHeader(name);
if (value == null || value.isBlank()) {
return fallback;
}
int comma = value.indexOf(',');
return (comma >= 0 ? value.substring(0, comma) : value).trim();
}
private static boolean isDefaultPort(String scheme, String port) {
return ("http".equals(scheme) && "80".equals(port))
|| ("https".equals(scheme) && "443".equals(port));
}
}
@@ -0,0 +1,128 @@
package stirling.software.proprietary.mcp.security;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpServletResponse;
/**
* Caps MCP request body size (via Content-Length and by buffering up to the cap) and rejects
* oversized bodies with a clean 413 before JSON parsing.
*/
public class McpRequestSizeFilter extends OncePerRequestFilter {
private final long maxBodyBytes;
public McpRequestSizeFilter(long maxBodyBytes) {
this.maxBodyBytes = maxBodyBytes > 0 ? maxBodyBytes : 256L * 1024L;
}
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
long declared = request.getContentLengthLong();
if (declared > maxBodyBytes) {
tooLarge(response);
return;
}
byte[] body;
try {
body = readUpTo(request.getInputStream(), maxBodyBytes);
} catch (BodyTooLargeException e) {
tooLarge(response);
return;
}
filterChain.doFilter(new CachedBodyRequest(request, body), response);
}
private static byte[] readUpTo(InputStream in, long max) throws IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
byte[] chunk = new byte[8192];
long total = 0;
int n;
while ((n = in.read(chunk)) != -1) {
total += n;
if (total > max) {
throw new BodyTooLargeException();
}
buffer.write(chunk, 0, n);
}
return buffer.toByteArray();
}
private void tooLarge(HttpServletResponse response) throws IOException {
response.setStatus(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
response.setContentType("application/json");
response.getWriter()
.write(
"{\"error\":\"payload_too_large\",\"message\":\"MCP request body exceeds the"
+ " configured limit of "
+ maxBodyBytes
+ " bytes.\"}");
}
private static final class BodyTooLargeException extends IOException {}
/** Re-serves the buffered body to the controller. */
private static final class CachedBodyRequest extends HttpServletRequestWrapper {
private final byte[] body;
CachedBodyRequest(HttpServletRequest request, byte[] body) {
super(request);
this.body = body;
}
@Override
public ServletInputStream getInputStream() {
ByteArrayInputStream source = new ByteArrayInputStream(body);
return new ServletInputStream() {
@Override
public int read() {
return source.read();
}
@Override
public int read(byte[] b, int off, int len) {
return source.read(b, off, len);
}
@Override
public boolean isFinished() {
return source.available() == 0;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setReadListener(ReadListener readListener) {
// Synchronous buffered body; no async reads.
}
};
}
@Override
public BufferedReader getReader() {
String enc = getCharacterEncoding();
Charset cs = enc == null ? StandardCharsets.UTF_8 : Charset.forName(enc);
return new BufferedReader(new InputStreamReader(new ByteArrayInputStream(body), cs));
}
}
}
@@ -0,0 +1,262 @@
package stirling.software.proprietary.mcp.security;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.core.convert.converter.Converter;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.intercept.AuthorizationFilter;
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
import org.springframework.web.cors.CorsConfigurationSource;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.service.UserService;
/**
* MCP security chain: validates JWTs (JWKS + RFC 8707 audience), maps scope claims to authorities,
* and fails closed when the issuer is unset.
*/
@Slf4j
@Configuration
@Order(Ordered.HIGHEST_PRECEDENCE)
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
public class McpSecurityConfig {
private final ApplicationProperties applicationProperties;
private final UserService userService;
// Reuse the app's CORS config; ObjectProvider so the chain still wires when no CORS bean
// exists.
private final ObjectProvider<CorsConfigurationSource> corsConfigurationSource;
private static final String BASE_PATH = "/mcp";
public McpSecurityConfig(
ApplicationProperties applicationProperties,
@Lazy UserService userService,
ObjectProvider<CorsConfigurationSource> corsConfigurationSource) {
this.applicationProperties = applicationProperties;
this.userService = userService;
this.corsConfigurationSource = corsConfigurationSource;
}
/** Enable CORS on the MCP chain using the app-wide source when available. */
private void applyCors(HttpSecurity http) throws Exception {
CorsConfigurationSource source = corsConfigurationSource.getIfAvailable();
if (source != null) {
http.cors(cors -> cors.configurationSource(source));
}
}
@PostConstruct
void warnIfMisconfigured() {
ApplicationProperties.Mcp mcp = applicationProperties.getMcp();
if (isApiKeyMode()) {
log.info(
"MCP auth mode = apikey: clients authenticate with a Stirling per-user API key"
+ " (X-API-KEY header). No OAuth issuer required.");
} else {
if (mcp.getAuth().getIssuerUri().isBlank()) {
log.warn(
"MCP enabled but mcp.auth.issuer-uri is blank - JWT decoder will reject"
+ " every token (fail-closed). Set mcp.auth.issuer-uri and"
+ " mcp.auth.resource-id before exposing /mcp to clients.");
}
if (mcp.getAuth().getResourceId().isBlank()) {
log.warn(
"MCP enabled but mcp.auth.resource-id is blank - audience validator will"
+ " reject every token. Set this to the public URL of the MCP"
+ " endpoint (RFC 8707).");
}
}
}
@Bean
@Order(0)
SecurityFilterChain mcpSecurityFilterChain(HttpSecurity http, JwtDecoder mcpJwtDecoder)
throws Exception {
ApplicationProperties.Mcp.Auth auth = applicationProperties.getMcp().getAuth();
if (isApiKeyMode()) {
return apiKeyFilterChain(http);
}
return oauthFilterChain(http, mcpJwtDecoder, auth);
}
private boolean isApiKeyMode() {
return "apikey".equalsIgnoreCase(applicationProperties.getMcp().getAuth().getMode());
}
/**
* API-key chain: a Stirling per-user API key is validated by {@link McpApiKeyAuthFilter};
* otherwise 401.
*/
private SecurityFilterChain apiKeyFilterChain(HttpSecurity http) throws Exception {
applyCors(http);
http.securityMatcher(BASE_PATH, BASE_PATH + "/**")
// CSRF intentionally disabled: /mcp is a stateless JSON-RPC API authenticated by an
// out-of-band X-API-KEY header (or Authorization: Bearer <key>). No cookies, no
// session, no form submissions; a browser cannot trick a victim into sending the
// header cross-origin, so the CSRF attack model does not apply. CodeQL flags this
// generically; the SessionCreationPolicy.STATELESS below is the relevant guarantee.
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.exceptionHandling(
e ->
e.authenticationEntryPoint(
(request, response, ex) -> {
response.setStatus(401);
response.setHeader(
"WWW-Authenticate",
"Bearer realm=\"Stirling MCP (API key)\"");
response.setContentType("application/json");
response.getWriter()
.write(
"{\"error\":\"unauthorized\",\"message\":\"Provide a valid Stirling API key via the X-API-KEY header (or Authorization: Bearer <key>).\"}");
}))
.addFilterBefore(
new McpRequestSizeFilter(
applicationProperties.getMcp().getMaxRequestBytes()),
AuthorizationFilter.class)
// Authenticate before the anonymous filter sets an anonymous token.
.addFilterBefore(
new McpApiKeyAuthFilter(userService), AnonymousAuthenticationFilter.class);
return http.build();
}
/** OAuth2 resource-server chain (JWT, RFC 8707 audience, RFC 9728 metadata). */
private SecurityFilterChain oauthFilterChain(
HttpSecurity http, JwtDecoder mcpJwtDecoder, ApplicationProperties.Mcp.Auth auth)
throws Exception {
String metadataPath = "/.well-known/oauth-protected-resource";
applyCors(http);
http.securityMatcher(BASE_PATH, BASE_PATH + "/**", metadataPath)
// CSRF intentionally disabled: /mcp is a stateless JSON-RPC resource server
// authenticated by OAuth2 Bearer JWTs (Authorization header). No cookies, no
// session, no form submissions; CSRF requires browser-attached ambient credentials
// and the bearer token is supplied per-request by the MCP client. CodeQL flags
// this generically; the SessionCreationPolicy.STATELESS below is the actual
// guarantee, and the .well-known metadata endpoint only serves GET.
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(
a ->
a.requestMatchers(HttpMethod.GET, metadataPath)
.permitAll()
.anyRequest()
.authenticated())
// Cap body size pre-auth, then bind the validated token to a Stirling user after
// the bearer filter.
.addFilterBefore(
new McpRequestSizeFilter(
applicationProperties.getMcp().getMaxRequestBytes()),
BearerTokenAuthenticationFilter.class)
.addFilterAfter(
new McpUserBindingFilter(
userService,
auth.getUsernameClaim(),
auth.isRequireExistingAccount()),
BearerTokenAuthenticationFilter.class)
.oauth2ResourceServer(
oauth2 ->
oauth2.authenticationEntryPoint(
new McpAuthenticationEntryPoint(metadataPath))
// RFC 9728 protected-resource metadata for OAuth discovery.
.protectedResourceMetadata(
prm ->
prm.protectedResourceMetadataCustomizer(
builder -> {
if (!auth.getResourceId()
.isBlank()) {
builder.resource(
auth
.getResourceId());
}
if (!auth.getIssuerUri()
.isBlank()) {
builder.authorizationServer(
auth
.getIssuerUri());
}
builder.scope("mcp.tools.read");
builder.scope(
"mcp.tools.write");
}))
.jwt(
jwt ->
jwt.decoder(mcpJwtDecoder)
.jwtAuthenticationConverter(
mcpJwtAuthenticationConverter())));
return http.build();
}
@Bean
JwtDecoder mcpJwtDecoder() {
ApplicationProperties.Mcp.Auth auth = applicationProperties.getMcp().getAuth();
if (auth.getIssuerUri().isBlank()) {
// Fail-closed decoder: rejects every token until the issuer is set.
return token -> {
throw new org.springframework.security.oauth2.jwt.BadJwtException(
"mcp.auth.issuer-uri is not configured");
};
}
String jwksUri = auth.getJwksUri();
NimbusJwtDecoder decoder =
jwksUri.isBlank()
? NimbusJwtDecoder.withIssuerLocation(auth.getIssuerUri()).build()
: NimbusJwtDecoder.withJwkSetUri(jwksUri).build();
OAuth2TokenValidator<Jwt> defaultValidators =
JwtValidators.createDefaultWithIssuer(auth.getIssuerUri());
OAuth2TokenValidator<Jwt> combined =
new DelegatingOAuth2TokenValidator<>(
defaultValidators, new McpAudienceValidator(auth.getResourceId()));
decoder.setJwtValidator(combined);
return decoder;
}
private Converter<Jwt, AbstractAuthenticationToken> mcpJwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();
scopes.setAuthorityPrefix("SCOPE_");
scopes.setAuthoritiesClaimName("scope");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(
jwt -> {
Collection<GrantedAuthority> out = new ArrayList<>(scopes.convert(jwt));
List<String> aud = jwt.getAudience();
if (aud != null) {
for (String a : aud) {
out.add(new SimpleGrantedAuthority("AUDIENCE_" + a));
}
}
return out;
});
return converter;
}
}
@@ -0,0 +1,115 @@
package stirling.software.proprietary.mcp.security;
import java.io.IOException;
import java.util.Optional;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/**
* Binds an MCP-validated JWT to a provisioned Stirling user: optionally rejects subjects with no
* enabled account, then rebinds the principal to the canonical Stirling username (scope authorities
* only) so audit/metering attribute correctly.
*/
@Slf4j
public class McpUserBindingFilter extends OncePerRequestFilter {
private static final ObjectMapper MAPPER = new ObjectMapper();
private final UserService userService;
private final String usernameClaim;
private final boolean requireExistingAccount;
public McpUserBindingFilter(
UserService userService, String usernameClaim, boolean requireExistingAccount) {
this.userService = userService;
this.usernameClaim =
(usernameClaim == null || usernameClaim.isBlank()) ? "sub" : usernameClaim;
this.requireExistingAccount = requireExistingAccount;
}
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
Authentication current = SecurityContextHolder.getContext().getAuthentication();
// Only act on a JWT-authenticated request; everything else passes through.
if (current instanceof JwtAuthenticationToken jwtAuth && jwtAuth.isAuthenticated()) {
Jwt jwt = jwtAuth.getToken();
String username = jwt.getClaimAsString(usernameClaim);
if (username == null || username.isBlank()) {
reject(
response,
"Token is missing the '"
+ usernameClaim
+ "' claim used to map to a"
+ " Stirling user.");
return;
}
// Prefer the canonical username from the account record; fall back to the claim when
// binding is off.
String boundUsername = username;
if (requireExistingAccount) {
Optional<User> account = userService.findByUsernameIgnoreCase(username);
if (account.isEmpty() || !account.get().isEnabled()) {
log.warn(
"MCP access denied: token subject '{}' has no active Stirling account",
sanitizeForLog(username));
reject(
response,
"MCP access requires a provisioned, enabled Stirling account for this"
+ " subject.");
return;
}
boundUsername = account.get().getUsername();
}
// Rebind to the Stirling username, carrying only the OAuth scope authorities.
UsernamePasswordAuthenticationToken bound =
new UsernamePasswordAuthenticationToken(
boundUsername, null, jwtAuth.getAuthorities());
bound.setDetails(jwtAuth.getDetails());
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(bound);
SecurityContextHolder.setContext(context);
}
filterChain.doFilter(request, response);
}
/** Strip CR/LF so a crafted claim value can't forge log lines. */
private static String sanitizeForLog(String value) {
return value == null ? null : value.replace('\r', ' ').replace('\n', ' ');
}
private void reject(HttpServletResponse response, String message) throws IOException {
SecurityContextHolder.clearContext();
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json");
ObjectNode body = MAPPER.createObjectNode();
body.put("error", "insufficient_account");
body.put("message", message);
response.getWriter().write(MAPPER.writeValueAsString(body));
}
}
@@ -0,0 +1,154 @@
package stirling.software.proprietary.mcp.tools;
import java.util.List;
import org.springframework.beans.factory.ObjectProvider;
import stirling.software.proprietary.mcp.McpCallContext;
import stirling.software.proprietary.mcp.McpTool;
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
import stirling.software.proprietary.mcp.catalog.OperationCategory;
import stirling.software.proprietary.mcp.catalog.OperationMeta;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
/**
* Common scaffolding for the PDF category tools. Operation ids and summaries come from the live
* {@link McpToolCatalog}.
*/
abstract class AbstractCategoryTool implements McpTool {
protected final ObjectMapper mapper;
protected final ObjectProvider<McpToolCatalog> catalogProvider;
protected final ObjectProvider<McpOperationExecutor> executorProvider;
protected AbstractCategoryTool(
ObjectMapper mapper,
ObjectProvider<McpToolCatalog> catalog,
ObjectProvider<McpOperationExecutor> executor) {
this.mapper = mapper;
this.catalogProvider = catalog;
this.executorProvider = executor;
}
protected abstract OperationCategory category();
protected List<OperationMeta> enabledOperations() {
McpToolCatalog catalog = catalogProvider.getIfAvailable();
if (catalog == null) {
return List.of();
}
return catalog.enabledOps(category());
}
@Override
public ObjectNode inputSchema() {
ObjectNode schema = mapper.createObjectNode();
schema.put("type", "object");
schema.put("additionalProperties", false);
ObjectNode props = schema.putObject("properties");
ObjectNode op = props.putObject("operation");
op.put("type", "string");
List<OperationMeta> enabled = enabledOperations();
StringBuilder opDesc = new StringBuilder();
opDesc.append(
"Operation id from this category. Call stirling_describe_operation first to learn"
+ " the exact parameters schema. Available operations:\n");
ArrayNode opEnum = op.putArray("enum");
for (OperationMeta m : enabled) {
opEnum.add(m.id());
opDesc.append("- ").append(m.id()).append(" - ").append(m.summary()).append('\n');
}
op.put("description", opDesc.toString().trim());
ObjectNode params = props.putObject("parameters");
params.put("type", "object");
params.put(
"description",
"Per-operation parameters. Schema available via stirling_describe_operation.");
params.put("additionalProperties", true);
McpToolSupport.stringProperty(
props,
"file",
"Base64-encoded file content to process. The recommended way to provide a file for"
+ " most uses. Bounded by the MCP request size limit; for very large files"
+ " use 'fileId' instead.");
McpToolSupport.stringProperty(
props,
"fileName",
"Optional original filename (with extension) for the input; helps operations that"
+ " key off file type.");
McpToolSupport.stringProperty(
props,
"fileId",
"Reference to a file already stored via stirling_upload. Recommended only for large"
+ " files or multi-step workflows; most users should pass the file inline"
+ " via 'file' instead.");
ArrayNode required = schema.putArray("required");
required.add("operation");
return schema;
}
@Override
public ObjectNode call(JsonNode arguments, McpCallContext context) {
JsonNode opNode = arguments == null ? null : arguments.get("operation");
// No operation chosen: return this category's operation list.
if (opNode == null || !opNode.isTextual() || opNode.asText().isBlank()) {
return operationListError(null);
}
String opId = opNode.asText();
McpToolCatalog catalog = catalogProvider.getIfAvailable();
if (catalog == null) {
return McpResponses.error(mapper, "MCP catalog is not available");
}
OperationMeta meta = catalog.findByOperationId(opId).orElse(null);
// Invalid/disabled/wrong-category op: return this category's operations.
if (meta == null || meta.category() != category()) {
return operationListError(opId);
}
if (!context.hasScope(meta.requiredScope())) {
return McpResponses.error(
mapper,
"Insufficient scope: this operation requires '" + meta.requiredScope() + "'.");
}
McpOperationExecutor executor = executorProvider.getIfAvailable();
if (executor == null) {
return McpResponses.error(mapper, "MCP execution is not available.");
}
return executor.execute(meta, arguments);
}
/**
* Error for a missing/unknown operation, listing this category's available operation ids and
* summaries.
*/
private ObjectNode operationListError(String badOpId) {
StringBuilder sb = new StringBuilder();
if (badOpId == null) {
sb.append("Missing required argument 'operation' for ").append(category().toolName());
} else {
sb.append("Unknown or disabled operation '")
.append(badOpId)
.append("' for ")
.append(category().toolName());
}
List<OperationMeta> ops = enabledOperations();
if (ops.isEmpty()) {
sb.append(". No operations are currently available in this category.");
} else {
sb.append(". Available operations:");
for (OperationMeta m : ops) {
sb.append("\n- ").append(m.id()).append(" - ").append(m.summary());
}
sb.append("\nRe-call this tool with a valid 'operation'.");
}
return McpResponses.error(mapper, sb.toString());
}
}
@@ -0,0 +1,84 @@
package stirling.software.proprietary.mcp.tools;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import stirling.software.proprietary.mcp.McpCallContext;
import stirling.software.proprietary.mcp.McpTool;
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
import stirling.software.proprietary.mcp.catalog.OperationMeta;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
/** Returns the JSON Schema for one operation's parameters, from the live {@link McpToolCatalog}. */
@Component
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
public class DescribeOperationTool implements McpTool {
private final ObjectMapper mapper;
private final ObjectProvider<McpToolCatalog> catalogProvider;
public DescribeOperationTool(ObjectMapper mapper, ObjectProvider<McpToolCatalog> catalog) {
this.mapper = mapper;
this.catalogProvider = catalog;
}
@Override
public String name() {
return "stirling_describe_operation";
}
@Override
public String description() {
return "Return the full JSON Schema for one Stirling operation's parameters. Call this "
+ "before invoking a category tool to learn the exact shape of `parameters`. "
+ "Argument: { operation: <op-id> } where <op-id> appears in the enum of any "
+ "category tool (stirling_convert, _pages, _misc, _security, _ai).";
}
@Override
public ObjectNode inputSchema() {
ObjectNode schema = mapper.createObjectNode();
schema.put("type", "object");
schema.put("additionalProperties", false);
ObjectNode props = schema.putObject("properties");
ObjectNode op = props.putObject("operation");
op.put("type", "string");
op.put(
"description",
"Operation id (e.g. compress-pdf, pdf-to-word, q-and-a). See category tool enums.");
ArrayNode required = schema.putArray("required");
required.add("operation");
return schema;
}
@Override
public ObjectNode call(JsonNode arguments, McpCallContext context) {
JsonNode opNode = arguments == null ? null : arguments.get("operation");
if (opNode == null || !opNode.isTextual() || opNode.asText().isBlank()) {
return McpResponses.error(mapper, "Missing required argument: operation");
}
String opId = opNode.asText();
McpToolCatalog catalog = catalogProvider.getIfAvailable();
if (catalog == null) {
return McpResponses.error(mapper, "MCP catalog is not available");
}
OperationMeta meta = catalog.findByOperationId(opId).orElse(null);
if (meta == null) {
return McpResponses.error(mapper, "Unknown or disabled operation: " + opId);
}
ObjectNode payload = mapper.createObjectNode();
payload.put("operation", meta.id());
payload.put("category", meta.category().toolName());
payload.put("summary", meta.summary());
payload.put("endpoint", meta.endpointPath());
payload.put("requiredScope", meta.requiredScope());
payload.set("parametersSchema", meta.paramSchema());
return McpResponses.json(mapper, payload);
}
}
@@ -0,0 +1,255 @@
package stirling.software.proprietary.mcp.tools;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestClientResponseException;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.InternalApiClient;
import stirling.software.common.service.InternalApiTimeoutException;
import stirling.software.proprietary.mcp.catalog.OperationMeta;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/**
* Runs a JAVA_ENDPOINT operation: resolves the input file (inline base64 or a fileId), dispatches
* to the Stirling endpoint over the loopback via {@link InternalApiClient}, and stores the result.
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
public class McpOperationExecutor {
private final ObjectMapper mapper;
private final InternalApiClient internalApiClient;
private final FileStorage fileStorage;
private final ApplicationProperties applicationProperties;
public McpOperationExecutor(
ObjectMapper mapper,
InternalApiClient internalApiClient,
FileStorage fileStorage,
ApplicationProperties applicationProperties) {
this.mapper = mapper;
this.internalApiClient = internalApiClient;
this.fileStorage = fileStorage;
this.applicationProperties = applicationProperties;
}
public ObjectNode execute(OperationMeta meta, JsonNode arguments) {
String fileName = McpToolSupport.textArg(arguments, "fileName");
String fileId = McpToolSupport.textArg(arguments, "fileId");
byte[] inputBytes;
String inputName;
if (fileId != null) {
try {
if (!fileStorage.fileExists(fileId)) {
return McpResponses.error(
mapper,
"Unknown or inaccessible fileId '"
+ fileId
+ "'. Re-upload with stirling_upload.");
}
inputBytes = fileStorage.retrieveBytes(fileId);
} catch (SecurityException e) {
return McpResponses.error(
mapper,
"Unknown or inaccessible fileId '"
+ fileId
+ "'. Re-upload with stirling_upload.");
} catch (IOException e) {
return McpResponses.error(mapper, "Could not read fileId '" + fileId + "'.");
}
inputName = fileName != null ? fileName : fileId;
} else {
String base64 = McpToolSupport.textArg(arguments, "file");
if (base64 == null) {
return McpResponses.error(
mapper,
"This operation needs an input file. Pass 'file' as base64 (recommended for"
+ " most files), or 'fileId' from stirling_upload for large files.");
}
inputBytes = McpToolSupport.decodeBase64OrNull(base64);
if (inputBytes == null) {
return McpResponses.error(mapper, "The 'file' argument is not valid base64.");
}
inputName = fileName != null ? fileName : "input.pdf";
}
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("fileInput", bytesResource(inputBytes, inputName));
addParameters(body, arguments == null ? null : arguments.get("parameters"));
ResponseEntity<Resource> response;
try {
response = internalApiClient.post(meta.endpointPath(), body);
} catch (InternalApiTimeoutException e) {
return McpResponses.error(
mapper,
meta.id()
+ " timed out after "
+ e.getReadTimeout().toSeconds()
+ "s. Try a smaller file or a different approach.");
} catch (RestClientResponseException e) {
log.warn(
"MCP {} upstream error: HTTP {} - {}",
meta.id(),
e.getStatusCode().value(),
snippet(e.getResponseBodyAsString()));
return McpResponses.error(
mapper, meta.id() + " failed: HTTP " + e.getStatusCode().value() + ".");
} catch (SecurityException e) {
return McpResponses.error(
mapper, meta.id() + " endpoint is not permitted for MCP dispatch.");
} catch (RuntimeException e) {
log.warn("MCP execution of {} failed", meta.id(), e);
return McpResponses.error(
mapper, meta.id() + " failed unexpectedly. See server logs for details.");
}
return buildResult(meta, response);
}
private ObjectNode buildResult(OperationMeta meta, ResponseEntity<Resource> response) {
Resource body = response.getBody();
if (body == null) {
return McpResponses.error(mapper, meta.id() + " returned an empty response.");
}
MediaType contentType = response.getHeaders().getContentType();
// A JSON body is a structured report (e.g. get-info), not a file.
if (contentType != null && MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
try (InputStream is = body.getInputStream()) {
return McpResponses.text(
mapper, new String(is.readAllBytes(), StandardCharsets.UTF_8));
} catch (IOException e) {
return McpResponses.error(mapper, "Failed to read " + meta.id() + " result.");
}
}
String filename =
body.getFilename() == null || body.getFilename().isBlank()
? meta.id()
: body.getFilename();
String mimeType =
contentType != null
? contentType.toString()
: MediaType.APPLICATION_OCTET_STREAM_VALUE;
long maxInline = applicationProperties.getMcp().getMaxInlineResponseBytes();
try {
long size = body.contentLength();
byte[] inline = null;
if (size >= 0 && size <= maxInline) {
try (InputStream is = body.getInputStream()) {
inline = is.readAllBytes();
}
}
String fileId =
inline != null
? fileStorage.storeBytes(inline, filename)
: storeStreamed(body, filename);
String summary =
meta.id()
+ " succeeded. Result: "
+ filename
+ " ("
+ size
+ " bytes), fileId="
+ fileId
+ ". ";
if (inline != null) {
return McpResponses.result(
mapper,
false,
McpResponses.textBlock(
mapper, summary + "The file is included inline below."),
McpResponses.resourceBlock(
mapper,
"stirling://file/" + fileId,
mimeType,
Base64.getEncoder().encodeToString(inline)));
}
return McpResponses.result(
mapper,
false,
McpResponses.textBlock(
mapper,
summary
+ "Large result - fetch it with stirling_download {\"fileId\":\""
+ fileId
+ "\"}, or pass this fileId to another operation."));
} catch (IOException e) {
return McpResponses.error(mapper, "Failed to store " + meta.id() + " result.");
}
}
private String storeStreamed(Resource body, String filename) throws IOException {
try (InputStream is = body.getInputStream()) {
return fileStorage.storeInputStream(is, filename).fileId();
}
}
private void addParameters(MultiValueMap<String, Object> body, JsonNode params) {
if (params == null || !params.isObject()) {
return;
}
Map<String, Object> map =
mapper.convertValue(params, new TypeReference<Map<String, Object>>() {});
for (Map.Entry<String, Object> entry : map.entrySet()) {
Object value = entry.getValue();
if (value == null) {
continue;
}
if (value instanceof List<?> list) {
if (containsStructured(list)) {
body.add(entry.getKey(), mapper.writeValueAsString(list));
} else {
list.forEach(item -> body.add(entry.getKey(), item));
}
} else if (value instanceof Map<?, ?>) {
body.add(entry.getKey(), mapper.writeValueAsString(value));
} else {
body.add(entry.getKey(), value);
}
}
}
private static boolean containsStructured(List<?> list) {
return list.stream().anyMatch(item -> item instanceof Map<?, ?> || item instanceof List<?>);
}
private static Resource bytesResource(byte[] bytes, String filename) {
return new ByteArrayResource(bytes) {
@Override
public String getFilename() {
return filename;
}
};
}
private static String snippet(String body) {
if (body == null || body.isBlank()) {
return "(no body)";
}
String trimmed = body.strip();
return trimmed.length() > 300 ? trimmed.substring(0, 300) + "..." : trimmed;
}
}
@@ -0,0 +1,80 @@
package stirling.software.proprietary.mcp.tools;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
/** Helpers for the MCP {@code CallToolResult} response shape. */
public final class McpResponses {
private McpResponses() {}
/** Plain-text content block. */
public static ObjectNode text(ObjectMapper mapper, String text) {
ObjectNode block = mapper.createObjectNode();
block.put("type", "text");
block.put("text", text);
return wrap(mapper, block, false);
}
/** Plain-text error ({@code isError:true}). */
public static ObjectNode error(ObjectMapper mapper, String message) {
ObjectNode block = mapper.createObjectNode();
block.put("type", "text");
block.put("text", message);
return wrap(mapper, block, true);
}
/** JSON payload as embedded text. */
public static ObjectNode json(ObjectMapper mapper, ObjectNode payload) {
ObjectNode block = mapper.createObjectNode();
block.put("type", "text");
block.put("text", payload.toString());
return wrap(mapper, block, false);
}
/** A text content block (unwrapped). */
public static ObjectNode textBlock(ObjectMapper mapper, String text) {
ObjectNode block = mapper.createObjectNode();
block.put("type", "text");
block.put("text", text);
return block;
}
/** An embedded-resource content block carrying base64 file content. */
public static ObjectNode resourceBlock(
ObjectMapper mapper, String uri, String mimeType, String base64) {
ObjectNode block = mapper.createObjectNode();
block.put("type", "resource");
ObjectNode res = block.putObject("resource");
res.put("uri", uri);
if (mimeType != null) {
res.put("mimeType", mimeType);
}
res.put("blob", base64);
return block;
}
/** Build a result from explicit content blocks. */
public static ObjectNode result(ObjectMapper mapper, boolean isError, ObjectNode... blocks) {
ObjectNode result = mapper.createObjectNode();
ArrayNode content = result.putArray("content");
for (ObjectNode b : blocks) {
content.add(b);
}
if (isError) {
result.put("isError", true);
}
return result;
}
private static ObjectNode wrap(ObjectMapper mapper, ObjectNode block, boolean isError) {
ObjectNode result = mapper.createObjectNode();
ArrayNode content = result.putArray("content");
content.add(block);
if (isError) {
result.put("isError", true);
}
return result;
}
}
@@ -0,0 +1,43 @@
package stirling.software.proprietary.mcp.tools;
import java.util.Base64;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.ObjectNode;
/** Shared helpers for MCP tools: argument parsing and JSON-Schema building. */
final class McpToolSupport {
private McpToolSupport() {}
/** Trimmed text value of an argument, or null if absent, blank, or not a string. */
static String textArg(JsonNode args, String field) {
if (args == null) {
return null;
}
JsonNode node = args.get(field);
if (node == null || !node.isTextual()) {
return null;
}
String value = node.asText().trim();
return value.isEmpty() ? null : value;
}
/** Decode base64 content, or null if the input is not valid base64. */
static byte[] decodeBase64OrNull(String base64) {
try {
return Base64.getDecoder().decode(base64);
} catch (IllegalArgumentException e) {
return null;
}
}
/**
* Add a {@code string} property with a description to a JSON-Schema {@code properties} node.
*/
static void stringProperty(ObjectNode properties, String name, String description) {
ObjectNode prop = properties.putObject(name);
prop.put("type", "string");
prop.put("description", description);
}
}
@@ -0,0 +1,149 @@
package stirling.software.proprietary.mcp.tools;
import java.io.IOException;
import java.util.List;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.mcp.McpCallContext;
import stirling.software.proprietary.mcp.McpTool;
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
import stirling.software.proprietary.mcp.catalog.OperationCategory;
import stirling.software.proprietary.mcp.catalog.OperationMeta;
import stirling.software.proprietary.service.AiEngineClient;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
/**
* Exposes curated Python agent capabilities as a single MCP tool, sourced from the engine
* capabilities manifest.
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
public class StirlingAiTool implements McpTool {
private final ObjectMapper mapper;
private final ObjectProvider<McpToolCatalog> catalogProvider;
private final ObjectProvider<AiEngineClient> engineClientProvider;
public StirlingAiTool(
ObjectMapper mapper,
ObjectProvider<McpToolCatalog> catalog,
ObjectProvider<AiEngineClient> engineClient) {
this.mapper = mapper;
this.catalogProvider = catalog;
this.engineClientProvider = engineClient;
}
@Override
public String name() {
return "stirling_ai";
}
@Override
public String description() {
return "Invoke a Stirling AI agent capability (Q&A about a PDF, edit-plan generation,"
+ " inline comments, math audit, draft-spec helper). Call"
+ " stirling_describe_operation with the chosen capability id to get its"
+ " parameters schema before invoking this tool. Some capabilities return content"
+ " inline; others return a job reference that resolves to a file when ready.";
}
@Override
public ObjectNode inputSchema() {
ObjectNode schema = mapper.createObjectNode();
schema.put("type", "object");
schema.put("additionalProperties", false);
ObjectNode props = schema.putObject("properties");
ObjectNode op = props.putObject("operation");
op.put("type", "string");
StringBuilder desc = new StringBuilder();
desc.append("Capability id from the engine manifest. Available capabilities:\n");
ArrayNode opEnum = op.putArray("enum");
for (OperationMeta m : aiOps()) {
opEnum.add(m.id());
desc.append("- ").append(m.id()).append(" - ").append(m.summary()).append('\n');
}
op.put("description", desc.toString().trim());
ObjectNode params = props.putObject("parameters");
params.put("type", "object");
params.put("description", "Per-capability parameters.");
params.put("additionalProperties", true);
ObjectNode fileId = props.putObject("fileId");
fileId.put("type", "string");
fileId.put(
"description",
"Reference to a previously-uploaded PDF in Stirling's job store. Required for"
+ " capabilities that consume a document.");
ArrayNode required = schema.putArray("required");
required.add("operation");
return schema;
}
@Override
public ObjectNode call(JsonNode arguments, McpCallContext context) {
JsonNode opNode = arguments == null ? null : arguments.get("operation");
if (opNode == null || !opNode.isTextual() || opNode.asText().isBlank()) {
return McpResponses.error(mapper, "Missing required argument: operation");
}
String opId = opNode.asText();
McpToolCatalog catalog = catalogProvider.getIfAvailable();
if (catalog == null) {
return McpResponses.error(mapper, "MCP catalog is not available");
}
OperationMeta meta = catalog.findByOperationId(opId).orElse(null);
if (meta == null || meta.category() != OperationCategory.AI) {
return McpResponses.error(
mapper,
"Unknown AI capability '"
+ opId
+ "'. The engine manifest may not be loaded yet - retry shortly or"
+ " confirm the engine is reachable.");
}
if (!context.hasScope(meta.requiredScope())) {
return McpResponses.error(
mapper,
"Insufficient scope: this capability requires '" + meta.requiredScope() + "'.");
}
AiEngineClient client = engineClientProvider.getIfAvailable();
if (client == null) {
return McpResponses.error(
mapper, "AI engine client is not configured - enable aiEngine in settings.");
}
if (meta.endpointPath() == null) {
return McpResponses.error(
mapper,
"Capability '" + opId + "' has no route configured in the engine manifest.");
}
JsonNode params = arguments.get("parameters");
String body = (params == null ? mapper.createObjectNode() : params).toString();
try {
String response = client.post(meta.endpointPath(), body, context.stirlingUserId());
return McpResponses.text(mapper, response);
} catch (IOException e) {
log.warn("MCP AI capability '{}' engine request failed", opId, e);
return McpResponses.error(
mapper, "Engine request failed for capability '" + opId + "'.");
}
}
private List<OperationMeta> aiOps() {
McpToolCatalog catalog = catalogProvider.getIfAvailable();
if (catalog == null) {
return List.of();
}
return catalog.enabledOps(OperationCategory.AI);
}
}
@@ -0,0 +1,41 @@
package stirling.software.proprietary.mcp.tools;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
import stirling.software.proprietary.mcp.catalog.OperationCategory;
import tools.jackson.databind.ObjectMapper;
/** Exposes the {@code /api/v1/convert/*} namespace as a single MCP tool. */
@Component
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
public class StirlingConvertTool extends AbstractCategoryTool {
public StirlingConvertTool(
ObjectMapper mapper,
ObjectProvider<McpToolCatalog> catalog,
ObjectProvider<McpOperationExecutor> executor) {
super(mapper, catalog, executor);
}
@Override
public String name() {
return "stirling_convert";
}
@Override
public String description() {
return "Convert files between PDF and other formats (PDF<->Word, PDF<->image, HTML->PDF,"
+ " etc.). Inspect the `operation` enum, then call stirling_describe_operation"
+ " with the chosen op to get its parameters JSON Schema before calling this"
+ " tool.";
}
@Override
protected OperationCategory category() {
return OperationCategory.CONVERT;
}
}
@@ -0,0 +1,113 @@
package stirling.software.proprietary.mcp.tools;
import java.io.IOException;
import java.util.Base64;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.FileStorage;
import stirling.software.proprietary.mcp.McpCallContext;
import stirling.software.proprietary.mcp.McpTool;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/**
* Fetches a stored file's content by fileId, returned inline as base64. For large results that were
* not returned inline by an operation.
*/
@Component
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
public class StirlingDownloadTool implements McpTool {
private final ObjectMapper mapper;
private final FileStorage fileStorage;
private final ApplicationProperties applicationProperties;
public StirlingDownloadTool(
ObjectMapper mapper,
FileStorage fileStorage,
ApplicationProperties applicationProperties) {
this.mapper = mapper;
this.fileStorage = fileStorage;
this.applicationProperties = applicationProperties;
}
@Override
public String name() {
return "stirling_download";
}
@Override
public String description() {
return "Fetch a stored file's content by fileId (e.g. an operation result), returned inline"
+ " as base64. Recommended only when a result was too large to be returned inline."
+ " Argument: { fileId: <id> }.";
}
@Override
public ObjectNode inputSchema() {
ObjectNode schema = mapper.createObjectNode();
schema.put("type", "object");
schema.put("additionalProperties", false);
ObjectNode props = schema.putObject("properties");
McpToolSupport.stringProperty(
props, "fileId", "Id of a stored file (e.g. an operation result's fileId).");
schema.putArray("required").add("fileId");
return schema;
}
@Override
public ObjectNode call(JsonNode arguments, McpCallContext context) {
if (!context.hasScope("mcp.tools.read")) {
return McpResponses.error(
mapper, "Insufficient scope: stirling_download requires 'mcp.tools.read'.");
}
String fileId = McpToolSupport.textArg(arguments, "fileId");
if (fileId == null) {
return McpResponses.error(mapper, "Missing required argument: fileId.");
}
long maxInline = applicationProperties.getMcp().getMaxInlineResponseBytes();
try {
if (!fileStorage.fileExists(fileId)) {
return McpResponses.error(
mapper, "Unknown or inaccessible fileId '" + fileId + "'.");
}
long size = fileStorage.getFileSize(fileId);
if (size > maxInline) {
return McpResponses.error(
mapper,
"File is "
+ size
+ " bytes, over the inline limit of "
+ maxInline
+ " bytes. Raise mcp.maxInlineResponseBytes or retrieve it via the"
+ " Stirling UI/API.");
}
byte[] bytes = fileStorage.retrieveBytes(fileId);
return McpResponses.result(
mapper,
false,
McpResponses.textBlock(
mapper,
"File "
+ fileId
+ " ("
+ bytes.length
+ " bytes) included inline below."),
McpResponses.resourceBlock(
mapper,
"stirling://file/" + fileId,
MediaType.APPLICATION_OCTET_STREAM_VALUE,
Base64.getEncoder().encodeToString(bytes)));
} catch (SecurityException e) {
return McpResponses.error(mapper, "Unknown or inaccessible fileId '" + fileId + "'.");
} catch (IOException e) {
return McpResponses.error(mapper, "Failed to read fileId '" + fileId + "'.");
}
}
}
@@ -0,0 +1,40 @@
package stirling.software.proprietary.mcp.tools;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
import stirling.software.proprietary.mcp.catalog.OperationCategory;
import tools.jackson.databind.ObjectMapper;
/** Exposes the {@code /api/v1/misc/*} namespace as a single MCP tool. */
@Component
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
public class StirlingMiscTool extends AbstractCategoryTool {
public StirlingMiscTool(
ObjectMapper mapper,
ObjectProvider<McpToolCatalog> catalog,
ObjectProvider<McpOperationExecutor> executor) {
super(mapper, catalog, executor);
}
@Override
public String name() {
return "stirling_misc";
}
@Override
public String description() {
return "Miscellaneous PDF operations: compress, OCR, stamp / watermark, edit metadata,"
+ " flatten, repair, and similar utilities. Call stirling_describe_operation with"
+ " the chosen op to get its parameters schema before invoking this tool.";
}
@Override
protected OperationCategory category() {
return OperationCategory.MISC;
}
}
@@ -0,0 +1,40 @@
package stirling.software.proprietary.mcp.tools;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
import stirling.software.proprietary.mcp.catalog.OperationCategory;
import tools.jackson.databind.ObjectMapper;
/** Exposes the {@code /api/v1/general/*} (page operations) namespace as a single MCP tool. */
@Component
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
public class StirlingPagesTool extends AbstractCategoryTool {
public StirlingPagesTool(
ObjectMapper mapper,
ObjectProvider<McpToolCatalog> catalog,
ObjectProvider<McpOperationExecutor> executor) {
super(mapper, catalog, executor);
}
@Override
public String name() {
return "stirling_pages";
}
@Override
public String description() {
return "Manipulate PDF pages: merge, split, rotate, rearrange, crop, delete, overlay,"
+ " add blank pages. Call stirling_describe_operation with the chosen op to get"
+ " its parameters schema before invoking this tool.";
}
@Override
protected OperationCategory category() {
return OperationCategory.PAGES;
}
}
@@ -0,0 +1,41 @@
package stirling.software.proprietary.mcp.tools;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
import stirling.software.proprietary.mcp.catalog.OperationCategory;
import tools.jackson.databind.ObjectMapper;
/** Exposes the {@code /api/v1/security/*} namespace as a single MCP tool. */
@Component
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
public class StirlingSecurityTool extends AbstractCategoryTool {
public StirlingSecurityTool(
ObjectMapper mapper,
ObjectProvider<McpToolCatalog> catalog,
ObjectProvider<McpOperationExecutor> executor) {
super(mapper, catalog, executor);
}
@Override
public String name() {
return "stirling_security";
}
@Override
public String description() {
return "Security-related PDF operations: password add/remove, redact, sanitize, certify"
+ " / sign with cert, validate signature, add watermark. Call"
+ " stirling_describe_operation with the chosen op to get its parameters schema"
+ " before invoking this tool.";
}
@Override
protected OperationCategory category() {
return OperationCategory.SECURITY;
}
}
@@ -0,0 +1,96 @@
package stirling.software.proprietary.mcp.tools;
import java.io.IOException;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.FileStorage;
import stirling.software.proprietary.mcp.McpCallContext;
import stirling.software.proprietary.mcp.McpTool;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/**
* Stores a file server-side and returns a fileId. For large files or multi-step workflows only -
* most operations accept the file inline via their {@code file} argument.
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
public class StirlingUploadTool implements McpTool {
private final ObjectMapper mapper;
private final FileStorage fileStorage;
public StirlingUploadTool(ObjectMapper mapper, FileStorage fileStorage) {
this.mapper = mapper;
this.fileStorage = fileStorage;
}
@Override
public String name() {
return "stirling_upload";
}
@Override
public String description() {
return "Store a file server-side and get back a fileId to reuse across operations."
+ " Recommended only for large files or multi-step workflows; for a single"
+ " operation on a typical file, pass the file inline via the operation's `file`"
+ " argument instead. Argument: { file: <base64>, fileName?: <name> }.";
}
@Override
public ObjectNode inputSchema() {
ObjectNode schema = mapper.createObjectNode();
schema.put("type", "object");
schema.put("additionalProperties", false);
ObjectNode props = schema.putObject("properties");
McpToolSupport.stringProperty(props, "file", "Base64-encoded file content.");
McpToolSupport.stringProperty(
props, "fileName", "Optional original filename (with extension).");
schema.putArray("required").add("file");
return schema;
}
@Override
public ObjectNode call(JsonNode arguments, McpCallContext context) {
if (!context.hasScope("mcp.tools.write")) {
return McpResponses.error(
mapper, "Insufficient scope: stirling_upload requires 'mcp.tools.write'.");
}
String base64 = McpToolSupport.textArg(arguments, "file");
if (base64 == null) {
return McpResponses.error(
mapper, "Missing required argument: file (base64-encoded content).");
}
byte[] bytes = McpToolSupport.decodeBase64OrNull(base64);
if (bytes == null) {
return McpResponses.error(mapper, "The 'file' argument is not valid base64.");
}
String name = McpToolSupport.textArg(arguments, "fileName");
if (name == null) {
name = "upload.bin";
}
try {
String fileId = fileStorage.storeBytes(bytes, name);
return McpResponses.text(
mapper,
"Stored '"
+ name
+ "' ("
+ bytes.length
+ " bytes) as fileId="
+ fileId
+ ". Pass this fileId to a Stirling operation's 'fileId' argument.");
} catch (IOException e) {
log.warn("MCP upload failed to store file", e);
return McpResponses.error(mapper, "Failed to store the uploaded file.");
}
}
}
@@ -618,6 +618,8 @@ public class AdminSettingsController {
case "autopipeline", "autoPipeline" -> applicationProperties.getAutoPipeline();
case "legal" -> applicationProperties.getLegal();
case "telegram" -> applicationProperties.getTelegram();
case "aiengine", "aiEngine" -> applicationProperties.getAiEngine();
case "mcp" -> applicationProperties.getMcp();
default -> null;
};
}
@@ -641,7 +643,10 @@ public class AdminSettingsController {
"autoPipeline",
"autopipeline",
"legal",
"telegram");
"telegram",
"aiEngine",
"aiengine",
"mcp");
// Pattern to validate safe property paths - only alphanumeric, dots, and underscores
private static final Pattern SAFE_KEY_PATTERN =
@@ -25,6 +25,7 @@ public class AiEngineClient {
private final ApplicationProperties applicationProperties;
private final HttpClient httpClient;
private final String engineSharedSecret;
@Autowired
public AiEngineClient(ApplicationProperties applicationProperties) {
@@ -39,8 +40,17 @@ public class AiEngineClient {
/** Package-private constructor that accepts an HttpClient directly; intended for tests. */
AiEngineClient(ApplicationProperties applicationProperties, HttpClient httpClient) {
this(applicationProperties, httpClient, System.getenv("STIRLING_ENGINE_SHARED_SECRET"));
}
/** Package-private constructor that also injects the engine shared secret; for tests. */
AiEngineClient(
ApplicationProperties applicationProperties,
HttpClient httpClient,
String engineSharedSecret) {
this.applicationProperties = applicationProperties;
this.httpClient = httpClient;
this.engineSharedSecret = engineSharedSecret;
}
public String post(String path, String jsonBody, String userId) throws IOException {
@@ -78,6 +88,7 @@ public class AiEngineClient {
.timeout(timeout)
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
addUserHeader(builder, userId);
addEngineAuthHeader(builder);
HttpResponse<String> response = sendRequest(builder.build());
log.debug("AI engine responded with status {}", response.statusCode());
@@ -86,10 +97,15 @@ public class AiEngineClient {
}
/**
* Attach the X-User-Id header so the engine can scope per-user storage (RAG documents, search
* results) to the caller. Skipped when {@code userId} is blank: the engine treats the request
* as anonymous and refuses any route that requires tenancy.
* Attach the {@code X-Engine-Auth} shared secret when configured so the engine trusts this
* backend request.
*/
private void addEngineAuthHeader(HttpRequest.Builder builder) {
if (engineSharedSecret != null && !engineSharedSecret.isBlank()) {
builder.header("X-Engine-Auth", engineSharedSecret);
}
}
private static void addUserHeader(HttpRequest.Builder builder, String userId) {
if (userId != null && !userId.isBlank()) {
builder.header("X-User-Id", userId);
@@ -129,6 +145,7 @@ public class AiEngineClient {
.timeout(timeout)
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
addUserHeader(builder, userId);
addEngineAuthHeader(builder);
HttpRequest request = builder.build();
HttpResponse<Stream<String>> response;
@@ -184,6 +201,7 @@ public class AiEngineClient {
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()))
.DELETE();
addUserHeader(builder, userId);
addEngineAuthHeader(builder);
HttpResponse<String> response = sendRequest(builder.build());
log.debug("AI engine responded with status {}", response.statusCode());
@@ -208,6 +226,7 @@ public class AiEngineClient {
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()))
.GET();
addUserHeader(builder, userId);
addEngineAuthHeader(builder);
HttpResponse<String> response = sendRequest(builder.build());
log.debug("AI engine responded with status {}", response.statusCode());
@@ -250,4 +250,27 @@ class S3FileStoreTest {
return toWrite;
}
}
@Test
void store_withOwner_persistsOwnerMetadata() throws IOException {
FileStore.Stored stored =
store.store(
new ByteArrayInputStream("owned".getBytes(StandardCharsets.UTF_8)),
"o.txt",
"alice");
assertThat(store.getOwner(stored.fileId())).isEqualTo("alice");
}
@Test
void store_withoutOwner_yieldsNullFromGetOwner() throws IOException {
FileStore.Stored stored =
store.store(
new ByteArrayInputStream("anon".getBytes(StandardCharsets.UTF_8)), "a.txt");
assertThat(store.getOwner(stored.fileId())).isNull();
}
@Test
void getOwner_returnsNullForUnknownFileId() throws IOException {
assertThat(store.getOwner("00000000-0000-0000-0000-000000000000")).isNull();
}
}
@@ -0,0 +1,99 @@
package stirling.software.proprietary.mcp;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Arrays;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
import stirling.software.proprietary.mcp.engine.EngineCapabilityClient;
import stirling.software.proprietary.mcp.security.McpSecurityConfig;
import stirling.software.proprietary.mcp.tools.DescribeOperationTool;
import stirling.software.proprietary.mcp.tools.McpOperationExecutor;
import stirling.software.proprietary.mcp.tools.StirlingAiTool;
import stirling.software.proprietary.mcp.tools.StirlingConvertTool;
import stirling.software.proprietary.mcp.tools.StirlingDownloadTool;
import stirling.software.proprietary.mcp.tools.StirlingMiscTool;
import stirling.software.proprietary.mcp.tools.StirlingPagesTool;
import stirling.software.proprietary.mcp.tools.StirlingSecurityTool;
import stirling.software.proprietary.mcp.tools.StirlingUploadTool;
/** Verifies MCP beans are gated behind {@code @ConditionalOnProperty(name="mcp.enabled")}. */
class McpConditionalTest {
@Test
void serverController_isGatedByMcpEnabled() {
assertGatedByEnabled(McpServerController.class);
}
@Test
void securityConfig_isGatedByMcpEnabled() {
assertGatedByEnabled(McpSecurityConfig.class);
}
@Test
void categoryToolsAndDescribeOperation_doNotNeedOwnGate() {
// The tool beans are only wired into the gated controller; sanity-check their signatures.
Class<?>[] tools = {
DescribeOperationTool.class,
StirlingConvertTool.class,
StirlingPagesTool.class,
StirlingMiscTool.class,
StirlingSecurityTool.class,
StirlingAiTool.class
};
for (Class<?> t : tools) {
assertTrue(
McpTool.class.isAssignableFrom(t),
t.getSimpleName() + " must implement McpTool");
assertNotNull(
t.getAnnotation(org.springframework.stereotype.Component.class),
t.getSimpleName() + " must be @Component");
}
}
@Test
void mcpBeans_areNotSaasProfileRestricted() {
// Beans gate on mcp.enabled only; no @Profile, so MCP can run under the saas profile too.
Class<?>[] beans = {
McpServerController.class,
McpSecurityConfig.class,
McpToolCatalog.class,
EngineCapabilityClient.class,
McpOperationExecutor.class,
DescribeOperationTool.class,
StirlingAiTool.class,
StirlingConvertTool.class,
StirlingMiscTool.class,
StirlingPagesTool.class,
StirlingSecurityTool.class,
StirlingUploadTool.class,
StirlingDownloadTool.class
};
for (Class<?> bean : beans) {
assertNull(
bean.getAnnotation(Profile.class),
bean.getSimpleName()
+ " must not be @Profile-restricted so MCP can run under saas");
}
}
private static void assertGatedByEnabled(Class<?> beanClass) {
ConditionalOnProperty conditional = beanClass.getAnnotation(ConditionalOnProperty.class);
assertNotNull(conditional, beanClass.getSimpleName() + " missing @ConditionalOnProperty");
assertTrue(
Arrays.asList(conditional.name()).contains("mcp.enabled")
|| Arrays.asList(conditional.value()).contains("mcp.enabled"),
beanClass.getSimpleName() + " must gate on mcp.enabled");
assertEquals(
"true",
conditional.havingValue(),
beanClass.getSimpleName() + " must require mcp.enabled=true");
}
}
@@ -0,0 +1,238 @@
package stirling.software.proprietary.mcp;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
import stirling.software.proprietary.mcp.tools.DescribeOperationTool;
import stirling.software.proprietary.mcp.tools.McpOperationExecutor;
import stirling.software.proprietary.mcp.tools.StirlingAiTool;
import stirling.software.proprietary.mcp.tools.StirlingConvertTool;
import stirling.software.proprietary.mcp.tools.StirlingMiscTool;
import stirling.software.proprietary.mcp.tools.StirlingPagesTool;
import stirling.software.proprietary.mcp.tools.StirlingSecurityTool;
import stirling.software.proprietary.service.AiEngineClient;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
/** Unit test of the MCP server controller: JSON-RPC framing and the 6-tool contract. */
class McpServerControllerTest {
private final ObjectMapper mapper = new ObjectMapper();
private final McpServerController controller = buildController();
private McpServerController buildController() {
ApplicationProperties props = new ApplicationProperties();
props.getAutomaticallyGenerated().setAppVersion("test-version");
ObjectProvider<McpToolCatalog> emptyCatalog = emptyProvider();
ObjectProvider<AiEngineClient> emptyEngine = emptyProvider();
ObjectProvider<McpOperationExecutor> emptyExecutor = emptyProvider();
List<McpTool> tools =
List.of(
new DescribeOperationTool(mapper, emptyCatalog),
new StirlingConvertTool(mapper, emptyCatalog, emptyExecutor),
new StirlingPagesTool(mapper, emptyCatalog, emptyExecutor),
new StirlingMiscTool(mapper, emptyCatalog, emptyExecutor),
new StirlingSecurityTool(mapper, emptyCatalog, emptyExecutor),
new StirlingAiTool(mapper, emptyCatalog, emptyEngine));
return new McpServerController(mapper, props, tools);
}
private static <T> ObjectProvider<T> emptyProvider() {
return new ObjectProvider<>() {
@Override
public T getObject() {
throw new UnsupportedOperationException("no bean in unit tests");
}
@Override
public T getObject(Object... args) {
return getObject();
}
@Override
public T getIfAvailable() {
return null;
}
@Override
public T getIfUnique() {
return null;
}
@Override
public T getIfAvailable(Supplier<T> defaultSupplier) {
return defaultSupplier == null ? null : defaultSupplier.get();
}
@Override
public void ifAvailable(Consumer<T> dependencyConsumer) {}
@Override
public Iterator<T> iterator() {
return java.util.Collections.emptyIterator();
}
};
}
@Test
void toolsList_returnsExactlySixTools() throws Exception {
JsonNode body = mapper.readTree("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}");
ResponseEntity<?> response = controller.handle(body);
assertEquals(HttpStatus.OK, response.getStatusCode());
JsonNode tools = mapper.valueToTree(response.getBody()).get("result").get("tools");
assertEquals(6, tools.size(), "tools/list must return exactly 6 tools");
Set<String> names =
Set.of(
"stirling_describe_operation",
"stirling_convert",
"stirling_pages",
"stirling_misc",
"stirling_security",
"stirling_ai");
Set<String> seen = new java.util.HashSet<>();
tools.forEach(t -> seen.add(t.get("name").asText()));
assertEquals(names, seen);
for (JsonNode tool : tools) {
assertTrue(tool.get("description").asText().length() > 10, "description present");
assertEquals("object", tool.get("inputSchema").get("type").asText());
}
}
@Test
void initialize_returnsServerInfoAndProtocolVersion() throws Exception {
JsonNode body = mapper.readTree("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}");
ResponseEntity<?> response = controller.handle(body);
JsonNode result = mapper.valueToTree(response.getBody()).get("result");
assertNotNull(result.get("protocolVersion"));
assertEquals("stirling-pdf-mcp", result.get("serverInfo").get("name").asText());
assertEquals("test-version", result.get("serverInfo").get("version").asText());
assertNotNull(result.get("capabilities").get("tools"), "tools capability advertised");
}
@Test
void ping_returnsEmptyResult() throws Exception {
JsonNode body = mapper.readTree("{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"ping\"}");
ResponseEntity<?> response = controller.handle(body);
JsonNode out = mapper.valueToTree(response.getBody());
assertEquals(7, out.get("id").asInt());
assertNotNull(out.get("result"));
assertNull(out.get("error"));
}
@Test
void notification_returnsNoContentWithEmptyBody() throws Exception {
// No id field: a JSON-RPC notification gets no response object.
JsonNode body =
mapper.readTree("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}");
ResponseEntity<?> response = controller.handle(body);
assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode());
assertNull(response.getBody());
}
@Test
void unknownMethod_returnsMethodNotFoundError() throws Exception {
JsonNode body =
mapper.readTree("{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"does/not/exist\"}");
ResponseEntity<?> response = controller.handle(body);
JsonNode error = mapper.valueToTree(response.getBody()).get("error");
assertEquals(-32601, error.get("code").asInt());
assertTrue(error.get("message").asText().contains("does/not/exist"));
}
@Test
void toolsCall_unknownTool_returnsInvalidParams() throws Exception {
JsonNode body =
mapper.readTree(
"{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\","
+ "\"params\":{\"name\":\"stirling_does_not_exist\",\"arguments\":{}}}");
ResponseEntity<?> response = controller.handle(body);
JsonNode error = mapper.valueToTree(response.getBody()).get("error");
assertEquals(-32602, error.get("code").asInt());
}
@Test
void toolsCall_describeOperation_withoutCatalog_returnsErrorContent() throws Exception {
// Null catalog: describe must surface an isError content block, not crash.
JsonNode body =
mapper.readTree(
"{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\","
+ "\"params\":{\"name\":\"stirling_describe_operation\","
+ "\"arguments\":{\"operation\":\"compress-pdf\"}}}");
ResponseEntity<?> response = controller.handle(body);
JsonNode result = mapper.valueToTree(response.getBody()).get("result");
assertTrue(result.get("isError").asBoolean());
String text = result.get("content").get(0).get("text").asText();
assertTrue(text.toLowerCase().contains("catalog") || text.contains("compress-pdf"));
}
@Test
void wrongShapeJson_returnsInvalidRequest() throws Exception {
// Valid JSON but not a JSON-RPC request object -> Invalid Request (-32600).
JsonNode body = mapper.readTree("{\"not\":\"a json-rpc frame\"}");
ResponseEntity<?> response = controller.handle(body);
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
JsonNode error = mapper.valueToTree(response.getBody()).get("error");
assertEquals(-32600, error.get("code").asInt());
}
@Test
void initialize_echoesSupportedClientProtocolVersion() throws Exception {
// Older but supported revision -> server echoes it.
JsonNode body =
mapper.readTree(
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\","
+ "\"params\":{\"protocolVersion\":\"2025-03-26\"}}");
ResponseEntity<?> response = controller.handle(body);
JsonNode result = mapper.valueToTree(response.getBody()).get("result");
assertEquals("2025-03-26", result.get("protocolVersion").asText());
}
@Test
void initialize_unknownClientProtocolVersion_fallsBackToPreferred() throws Exception {
JsonNode body =
mapper.readTree(
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\","
+ "\"params\":{\"protocolVersion\":\"1999-01-01\"}}");
ResponseEntity<?> response = controller.handle(body);
JsonNode result = mapper.valueToTree(response.getBody()).get("result");
assertEquals("2025-06-18", result.get("protocolVersion").asText());
}
}
@@ -0,0 +1,185 @@
package stirling.software.proprietary.mcp.catalog;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.lang.reflect.Field;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.common.model.ApplicationProperties;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/** Catalog tests: DELETE/GET exclusion and disabled-PDF-op AI fall-through. */
class McpToolCatalogTest {
private final ObjectMapper mapper = new ObjectMapper();
@Test
void isInvocableMethod_excludesDeleteAndGet() {
assertTrue(McpToolCatalog.isInvocableMethod(Set.of(RequestMethod.POST)));
assertTrue(McpToolCatalog.isInvocableMethod(Set.of(RequestMethod.PUT)));
// DELETE/GET handlers must never be cataloged as runnable tools.
assertFalse(McpToolCatalog.isInvocableMethod(Set.of(RequestMethod.DELETE)));
assertFalse(McpToolCatalog.isInvocableMethod(Set.of(RequestMethod.GET)));
// Empty method set (matches all verbs) is not invocable.
assertFalse(McpToolCatalog.isInvocableMethod(Set.of()));
// Multi-verb mapping including POST stays invocable.
assertTrue(
McpToolCatalog.isInvocableMethod(Set.of(RequestMethod.POST, RequestMethod.DELETE)));
}
@Test
void findByOperationId_disabledPdfOp_doesNotFallThroughToAi() throws Exception {
ApplicationContext ctx = mock(ApplicationContext.class);
when(ctx.getBeansOfType(RequestMappingHandlerMapping.class)).thenReturn(Map.of());
EndpointConfiguration endpoints = mock(EndpointConfiguration.class);
// The PDF op's endpoint is disabled.
when(endpoints.isEndpointEnabledForUri(anyString())).thenReturn(false);
McpToolCatalog catalog =
new McpToolCatalog(ctx, endpoints, new ApplicationProperties(), mapper);
ObjectNode schema = mapper.createObjectNode();
OperationMeta pdf =
new OperationMeta(
"collide",
OperationCategory.MISC,
"pdf op",
schema,
"mcp.tools.write",
OperationMeta.Target.JAVA_ENDPOINT,
"/api/v1/misc/collide",
null);
OperationMeta ai =
new OperationMeta(
"collide",
OperationCategory.AI,
"ai op",
schema,
"mcp.tools.write",
OperationMeta.Target.ENGINE_CAPABILITY,
"collide",
null);
seed(catalog, "pdfOps", "collide", pdf);
seed(catalog, "aiOps", "collide", ai);
// A disabled PDF op must resolve to empty, not a colliding AI capability of the same id.
assertTrue(
catalog.findByOperationId("collide").isEmpty(),
"disabled PDF op must not resolve to a colliding AI capability");
// A genuine AI-only id still resolves.
seed(
catalog,
"aiOps",
"ai-only",
new OperationMeta(
"ai-only",
OperationCategory.AI,
"ai op",
schema,
"mcp.tools.write",
OperationMeta.Target.ENGINE_CAPABILITY,
"ai-only",
null));
assertEquals("ai-only", catalog.findByOperationId("ai-only").orElseThrow().id());
}
@Test
void blockedOperations_hidesOp() throws Exception {
ApplicationProperties props = new ApplicationProperties();
props.getMcp().setBlockedOperations(List.of("compress-pdf"));
McpToolCatalog catalog = catalogWithEndpointsEnabled(props);
seed(catalog, "pdfOps", "compress-pdf", miscOp("compress-pdf"));
seed(catalog, "pdfOps", "ocr-pdf", miscOp("ocr-pdf"));
assertTrue(
catalog.findByOperationId("compress-pdf").isEmpty(), "blocked op must be hidden");
assertEquals("ocr-pdf", catalog.findByOperationId("ocr-pdf").orElseThrow().id());
assertFalse(idsOf(catalog).contains("compress-pdf"));
assertTrue(idsOf(catalog).contains("ocr-pdf"));
}
@Test
void allowedOperations_isWhitelist() throws Exception {
ApplicationProperties props = new ApplicationProperties();
props.getMcp().setAllowedOperations(List.of("compress-pdf"));
McpToolCatalog catalog = catalogWithEndpointsEnabled(props);
seed(catalog, "pdfOps", "compress-pdf", miscOp("compress-pdf"));
seed(catalog, "pdfOps", "ocr-pdf", miscOp("ocr-pdf"));
assertEquals("compress-pdf", catalog.findByOperationId("compress-pdf").orElseThrow().id());
assertTrue(
catalog.findByOperationId("ocr-pdf").isEmpty(),
"op not on the allow-list must be hidden");
assertEquals(List.of("compress-pdf"), idsOf(catalog));
}
@Test
void blockedOperations_takePrecedenceOverAllowed() throws Exception {
ApplicationProperties props = new ApplicationProperties();
props.getMcp().setAllowedOperations(List.of("compress-pdf"));
props.getMcp().setBlockedOperations(List.of("compress-pdf"));
McpToolCatalog catalog = catalogWithEndpointsEnabled(props);
seed(catalog, "pdfOps", "compress-pdf", miscOp("compress-pdf"));
assertTrue(
catalog.findByOperationId("compress-pdf").isEmpty(),
"block-list must win over allow-list");
}
@Test
void emptyAllowAndBlockLists_exposeAllEnabledOps() throws Exception {
McpToolCatalog catalog = catalogWithEndpointsEnabled(new ApplicationProperties());
seed(catalog, "pdfOps", "compress-pdf", miscOp("compress-pdf"));
assertEquals("compress-pdf", catalog.findByOperationId("compress-pdf").orElseThrow().id());
assertTrue(idsOf(catalog).contains("compress-pdf"));
}
private McpToolCatalog catalogWithEndpointsEnabled(ApplicationProperties props) {
ApplicationContext ctx = mock(ApplicationContext.class);
when(ctx.getBeansOfType(RequestMappingHandlerMapping.class)).thenReturn(Map.of());
EndpointConfiguration endpoints = mock(EndpointConfiguration.class);
when(endpoints.isEndpointEnabledForUri(anyString())).thenReturn(true);
return new McpToolCatalog(ctx, endpoints, props, mapper);
}
private OperationMeta miscOp(String id) {
return new OperationMeta(
id,
OperationCategory.MISC,
id,
mapper.createObjectNode(),
"mcp.tools.write",
OperationMeta.Target.JAVA_ENDPOINT,
"/api/v1/misc/" + id,
null);
}
private static List<String> idsOf(McpToolCatalog catalog) {
return catalog.enabledOps(OperationCategory.MISC).stream().map(OperationMeta::id).toList();
}
@SuppressWarnings("unchecked")
private static void seed(McpToolCatalog catalog, String field, String id, OperationMeta meta)
throws Exception {
Field f = McpToolCatalog.class.getDeclaredField(field);
f.setAccessible(true);
((Map<String, OperationMeta>) f.get(catalog)).put(id, meta);
}
}
@@ -0,0 +1,59 @@
package stirling.software.proprietary.mcp.catalog;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.HashSet;
import java.util.Set;
import org.junit.jupiter.api.Test;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/** Schema must describe the JSON wire contract, not raw Java field names. */
class SimpleSchemaGeneratorTest {
private final SimpleSchemaGenerator gen = new SimpleSchemaGenerator(new ObjectMapper());
@SuppressWarnings("unused")
static class SampleRequest {
@JsonProperty("file_name")
String fileName;
@JsonProperty(required = true)
String mode;
@JsonIgnore String internalSecret;
boolean flag;
@jakarta.validation.constraints.NotBlank String title;
}
@Test
void usesJsonPropertyNames_skipsJsonIgnore_marksRequired() {
ObjectNode schema = gen.toSchema(SampleRequest.class);
ObjectNode props = (ObjectNode) schema.get("properties");
assertTrue(props.has("file_name"), "must use the @JsonProperty name");
assertFalse(props.has("fileName"), "must not emit the raw field name");
assertFalse(props.has("internalSecret"), "@JsonIgnore field must be skipped");
assertTrue(props.has("flag"));
assertEquals("boolean", props.get("flag").get("type").asText());
assertNotNull(schema.get("required"), "required array expected");
Set<String> required = new HashSet<>();
schema.get("required").forEach(n -> required.add(n.asText()));
assertTrue(required.contains("mode"), "@JsonProperty(required=true) -> required");
assertTrue(required.contains("title"), "@NotBlank -> required");
assertFalse(required.contains("file_name"), "optional field not required");
}
}
@@ -0,0 +1,138 @@
package stirling.software.proprietary.mcp.engine;
import static org.assertj.core.api.Assertions.assertThat;
import java.lang.reflect.Method;
import java.util.Map;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
import stirling.software.proprietary.mcp.catalog.OperationCategory;
import stirling.software.proprietary.mcp.catalog.OperationMeta;
import tools.jackson.databind.ObjectMapper;
/**
* Verifies {@link EngineCapabilityClient#parseManifest} maps a manifest to {@link OperationMeta}.
*/
class EngineCapabilityParseTest {
@Test
void manifest_maps_to_operation_meta() throws Exception {
ObjectMapper mapper = new ObjectMapper();
ApplicationProperties props = new ApplicationProperties();
// parseManifest doesn't touch the catalog, so a null catalog is fine.
EngineCapabilityClient client =
new EngineCapabilityClient(props, (McpToolCatalog) null, mapper);
String body =
"""
{
"version": 1,
"capabilities": [
{
"id": "pdf-question-answer",
"description": "Answer a question about a PDF.",
"input_schema": {"type": "object", "properties": {"question": {"type": "string"}}},
"mode": "sync",
"required_scope": "mcp.tools.read",
"route": "/api/v1/pdf-question"
},
{
"id": "pdf-edit-plan",
"description": "Produce an edit plan from natural language.",
"input_schema": {"type": "object"},
"mode": "async",
"required_scope": "mcp.tools.write",
"route": "/api/v1/pdf-edit"
}
]
}
""";
Method parse =
EngineCapabilityClient.class.getDeclaredMethod("parseManifest", String.class);
parse.setAccessible(true);
@SuppressWarnings("unchecked")
Map<String, OperationMeta> parsed = (Map<String, OperationMeta>) parse.invoke(client, body);
assertThat(parsed).hasSize(2);
OperationMeta qa = parsed.get("pdf-question-answer");
assertThat(qa).isNotNull();
assertThat(qa.category()).isEqualTo(OperationCategory.AI);
assertThat(qa.requiredScope()).isEqualTo("mcp.tools.read");
assertThat(qa.endpointPath()).isEqualTo("/api/v1/pdf-question");
assertThat(qa.target()).isEqualTo(OperationMeta.Target.ENGINE_CAPABILITY);
assertThat(qa.paramSchema().get("type").asText()).isEqualTo("object");
OperationMeta edit = parsed.get("pdf-edit-plan");
assertThat(edit).isNotNull();
assertThat(edit.requiredScope()).isEqualTo("mcp.tools.write");
assertThat(edit.endpointPath()).isEqualTo("/api/v1/pdf-edit");
}
@Test
void missing_capabilities_array_throws() {
ObjectMapper mapper = new ObjectMapper();
EngineCapabilityClient client =
new EngineCapabilityClient(
new ApplicationProperties(), (McpToolCatalog) null, mapper);
try {
Method parse =
EngineCapabilityClient.class.getDeclaredMethod("parseManifest", String.class);
parse.setAccessible(true);
parse.invoke(client, "{\"version\":1}");
org.junit.jupiter.api.Assertions.fail("Expected IOException");
} catch (java.lang.reflect.InvocationTargetException e) {
assertThat(e.getCause()).isInstanceOf(java.io.IOException.class);
} catch (Exception e) {
org.junit.jupiter.api.Assertions.fail("Unexpected exception: " + e);
}
}
@Test
void unsafe_routes_are_skipped_and_blank_scope_fails_safe() throws Exception {
ObjectMapper mapper = new ObjectMapper();
EngineCapabilityClient client =
new EngineCapabilityClient(
new ApplicationProperties(), (McpToolCatalog) null, mapper);
String body =
"""
{"version":1,"capabilities":[
{"id":"good","description":"ok","input_schema":{"type":"object"},"required_scope":"","route":"/api/v1/pdf-question"},
{"id":"ssrf-at","description":"x","input_schema":{"type":"object"},"route":"@evil.com/steal"},
{"id":"ssrf-proto","description":"x","input_schema":{"type":"object"},"route":"//evil.com/x"},
{"id":"escape","description":"x","input_schema":{"type":"object"},"route":"/api/../../internal/secret"},
{"id":"scheme","description":"x","input_schema":{"type":"object"},"route":"http://evil.com/x"},
{"id":"non-api","description":"x","input_schema":{"type":"object"},"route":"/admin/settings"}
]}
""";
Method parse =
EngineCapabilityClient.class.getDeclaredMethod("parseManifest", String.class);
parse.setAccessible(true);
@SuppressWarnings("unchecked")
Map<String, OperationMeta> parsed = (Map<String, OperationMeta>) parse.invoke(client, body);
// Only the safe, server-relative /api route survives.
assertThat(parsed.keySet()).containsExactly("good");
// Blank required_scope fails safe to the stricter write scope.
assertThat(parsed.get("good").requiredScope()).isEqualTo("mcp.tools.write");
}
@Test
void isSafeRelativeRoute_acceptsOnlyServerRelativeApiPaths() {
assertThat(EngineCapabilityClient.isSafeRelativeRoute("/api/v1/pdf-question")).isTrue();
assertThat(EngineCapabilityClient.isSafeRelativeRoute("@evil.com/x")).isFalse();
assertThat(EngineCapabilityClient.isSafeRelativeRoute("//evil.com/x")).isFalse();
assertThat(EngineCapabilityClient.isSafeRelativeRoute("/api/../secret")).isFalse();
assertThat(EngineCapabilityClient.isSafeRelativeRoute("http://evil/x")).isFalse();
assertThat(EngineCapabilityClient.isSafeRelativeRoute("/admin/x")).isFalse();
assertThat(EngineCapabilityClient.isSafeRelativeRoute("/api/v1/x y")).isFalse();
}
}
@@ -0,0 +1,148 @@
package stirling.software.proprietary.mcp.security;
import static org.assertj.core.api.Assertions.assertThat;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.mcp.McpServerController;
import stirling.software.proprietary.mcp.tools.DescribeOperationTool;
import stirling.software.proprietary.mcp.tools.StirlingAiTool;
import stirling.software.proprietary.mcp.tools.StirlingConvertTool;
import stirling.software.proprietary.mcp.tools.StirlingMiscTool;
import stirling.software.proprietary.mcp.tools.StirlingPagesTool;
import stirling.software.proprietary.mcp.tools.StirlingSecurityTool;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
/**
* End-to-end test of {@code mcp.auth.mode=apikey} against the real security chain on live Jetty.
*/
@SpringBootTest(
classes = McpApiKeyIntegrationTest.TestApp.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class McpApiKeyIntegrationTest {
private static final String VALID_KEY = "stirling-test-key-abc123";
@LocalServerPort private int port;
private final HttpClient http = HttpClient.newHttpClient();
@DynamicPropertySource
static void mcpProperties(DynamicPropertyRegistry registry) {
registry.add("mcp.enabled", () -> "true");
registry.add("mcp.auth.mode", () -> "apikey");
}
@Test
void validApiKeyViaHeader_callsToolsList() throws Exception {
HttpResponse<String> response =
postMcp(
b -> b.header("X-API-KEY", VALID_KEY),
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}");
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.body()).contains("stirling_describe_operation");
}
@Test
void validApiKeyViaBearer_callsToolsList() throws Exception {
HttpResponse<String> response =
postMcp(
b -> b.header("Authorization", "Bearer " + VALID_KEY),
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}");
assertThat(response.statusCode()).isEqualTo(200);
}
@Test
void noKey_isRejectedWith401() throws Exception {
HttpResponse<String> response =
postMcp(b -> b, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}");
assertThat(response.statusCode()).isEqualTo(401);
}
@Test
void wrongKey_isRejectedWith401() throws Exception {
HttpResponse<String> response =
postMcp(
b -> b.header("X-API-KEY", "not-a-real-key"),
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}");
assertThat(response.statusCode()).isEqualTo(401);
}
@Test
void noOAuthMetadataInApiKeyMode() throws Exception {
HttpRequest req =
HttpRequest.newBuilder()
.uri(URI.create(base() + "/.well-known/oauth-protected-resource"))
.GET()
.build();
HttpResponse<String> response = http.send(req, HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isNotEqualTo(200);
}
private HttpResponse<String> postMcp(
java.util.function.UnaryOperator<HttpRequest.Builder> headers, String body)
throws Exception {
HttpRequest.Builder builder =
HttpRequest.newBuilder()
.uri(URI.create(base() + "/mcp"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body));
return http.send(headers.apply(builder).build(), HttpResponse.BodyHandlers.ofString());
}
private String base() {
return "http://localhost:" + port;
}
@SpringBootConfiguration
@EnableAutoConfiguration
@Import({
McpSecurityConfig.class,
McpServerController.class,
DescribeOperationTool.class,
StirlingConvertTool.class,
StirlingPagesTool.class,
StirlingMiscTool.class,
StirlingSecurityTool.class,
StirlingAiTool.class
})
static class TestApp {
@Bean
ApplicationProperties applicationProperties() {
ApplicationProperties props = new ApplicationProperties();
props.getMcp().setEnabled(true);
props.getMcp().getAuth().setMode("apikey");
props.getAutomaticallyGenerated().setAppVersion("test");
return props;
}
@Bean
UserService userService() {
UserService mock = org.mockito.Mockito.mock(UserService.class);
User account = org.mockito.Mockito.mock(User.class);
org.mockito.Mockito.when(account.isEnabled()).thenReturn(true);
org.mockito.Mockito.when(account.getUsername()).thenReturn("alice");
org.mockito.Mockito.when(mock.getUserByApiKey(org.mockito.ArgumentMatchers.anyString()))
.thenReturn(Optional.empty());
org.mockito.Mockito.when(mock.getUserByApiKey(VALID_KEY))
.thenReturn(Optional.of(account));
return mock;
}
}
}
@@ -0,0 +1,64 @@
package stirling.software.proprietary.mcp.security;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
/** RFC 8707 audience validator: token {@code aud} must list the resource id; blank fails closed. */
class McpAudienceValidatorTest {
private static final String RESOURCE = "http://localhost:8080/mcp";
private final McpAudienceValidator validator = new McpAudienceValidator(RESOURCE);
@Test
void matchingAudience_isAccepted() {
Jwt token = tokenWithAudience(List.of(RESOURCE));
OAuth2TokenValidatorResult result = validator.validate(token);
assertThat(result.hasErrors()).isFalse();
}
@Test
void multiAudienceIncludingResource_isAccepted() {
Jwt token = tokenWithAudience(List.of("https://other.example.com", RESOURCE));
OAuth2TokenValidatorResult result = validator.validate(token);
assertThat(result.hasErrors()).isFalse();
}
@Test
void wrongAudience_isRejected() {
Jwt token = tokenWithAudience(List.of("https://other.example.com"));
OAuth2TokenValidatorResult result = validator.validate(token);
assertThat(result.hasErrors()).isTrue();
assertThat(result.getErrors()).anyMatch(e -> e.getErrorCode().equals("invalid_token"));
}
@Test
void missingAudienceClaim_isRejected() {
Jwt token = tokenWithAudience(null);
OAuth2TokenValidatorResult result = validator.validate(token);
assertThat(result.hasErrors()).isTrue();
}
@Test
void blankResourceId_failsClosed_rejectingEvenMatchingTokens() {
McpAudienceValidator blank = new McpAudienceValidator("");
OAuth2TokenValidatorResult result = blank.validate(tokenWithAudience(List.of(RESOURCE)));
assertThat(result.hasErrors()).isTrue();
}
private static Jwt tokenWithAudience(List<String> audience) {
return new Jwt(
"header.payload.signature",
Instant.now(),
Instant.now().plusSeconds(60),
Map.of("alg", "RS256"),
audience == null ? Map.of("sub", "u1") : Map.of("sub", "u1", "aud", audience));
}
}
@@ -0,0 +1,61 @@
package stirling.software.proprietary.mcp.security;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
/** The resource_metadata URL must reflect the public host behind a reverse proxy. */
class McpAuthenticationEntryPointTest {
private static final String META = "/.well-known/oauth-protected-resource";
private final McpAuthenticationEntryPoint entryPoint = new McpAuthenticationEntryPoint(META);
@Test
void usesForwardedHeadersForMetadataUrl() throws Exception {
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getScheme()).thenReturn("http");
when(req.getServerName()).thenReturn("internal-host");
when(req.getServerPort()).thenReturn(8080);
when(req.getHeader("X-Forwarded-Proto")).thenReturn("https");
when(req.getHeader("X-Forwarded-Host")).thenReturn("mcp.example.com");
when(req.getHeader("X-Forwarded-Port")).thenReturn("443");
HttpServletResponse resp = mock(HttpServletResponse.class);
entryPoint.commence(req, resp, null);
ArgumentCaptor<String> header = ArgumentCaptor.forClass(String.class);
verify(resp).setHeader(eq("WWW-Authenticate"), header.capture());
String www = header.getValue();
assertTrue(
www.contains("resource_metadata=\"https://mcp.example.com" + META + "\""),
"must use forwarded host/proto, got: " + www);
assertFalse(www.contains("internal-host"), "internal host must not leak");
verify(resp).sendError(anyInt(), anyString());
}
@Test
void fallsBackToServletHostWithoutForwardedHeaders() throws Exception {
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getScheme()).thenReturn("http");
when(req.getServerName()).thenReturn("localhost");
when(req.getServerPort()).thenReturn(8080);
HttpServletResponse resp = mock(HttpServletResponse.class);
entryPoint.commence(req, resp, null);
ArgumentCaptor<String> header = ArgumentCaptor.forClass(String.class);
verify(resp).setHeader(eq("WWW-Authenticate"), header.capture());
assertTrue(header.getValue().contains("http://localhost:8080" + META), header.getValue());
}
}
@@ -0,0 +1,337 @@
package stirling.software.proprietary.mcp.security;
import static org.assertj.core.api.Assertions.assertThat;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Instant;
import java.util.Date;
import java.util.List;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.mcp.McpServerController;
import stirling.software.proprietary.mcp.tools.DescribeOperationTool;
import stirling.software.proprietary.mcp.tools.StirlingAiTool;
import stirling.software.proprietary.mcp.tools.StirlingConvertTool;
import stirling.software.proprietary.mcp.tools.StirlingMiscTool;
import stirling.software.proprietary.mcp.tools.StirlingPagesTool;
import stirling.software.proprietary.mcp.tools.StirlingSecurityTool;
import stirling.software.proprietary.security.service.UserService;
import okhttp3.mockwebserver.Dispatcher;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
/**
* End-to-end OAuth test against the real {@link McpSecurityConfig} chain. A real RSA keypair signs
* JWTs; the public key is served as JWKS over HTTP (mockwebserver) and the resource server fetches
* and validates against it. The JDK HttpClient drives a live Jetty instance on a random port.
*/
@SpringBootTest(
classes = McpOAuthIntegrationTest.TestApp.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class McpOAuthIntegrationTest {
private static final String ISSUER = "https://test-issuer.example.com";
private static final String RESOURCE_ID = "http://localhost/mcp";
private static MockWebServer jwksServer;
private static RSAPrivateKey privateKey;
private static final String KEY_ID = "mcp-test-key";
@LocalServerPort private int port;
private final HttpClient http = HttpClient.newHttpClient();
@BeforeAll
static void startJwks() throws Exception {
KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
gen.initialize(2048);
KeyPair kp = gen.generateKeyPair();
privateKey = (RSAPrivateKey) kp.getPrivate();
RSAPublicKey publicKey = (RSAPublicKey) kp.getPublic();
RSAKey jwk = new RSAKey.Builder(publicKey).keyID(KEY_ID).build();
String jwksJson = new JWKSet(jwk).toString();
jwksServer = new MockWebServer();
jwksServer.setDispatcher(
new Dispatcher() {
@Override
public MockResponse dispatch(RecordedRequest request) {
return new MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(jwksJson);
}
});
jwksServer.start();
}
@AfterAll
static void stopJwks() throws Exception {
if (jwksServer != null) {
jwksServer.shutdown();
}
}
@DynamicPropertySource
static void mcpProperties(DynamicPropertyRegistry registry) {
registry.add("mcp.enabled", () -> "true");
registry.add("mcp.auth.issuer-uri", () -> ISSUER);
registry.add("mcp.auth.jwks-uri", () -> jwksServer.url("/jwks").toString());
registry.add("mcp.auth.resource-id", () -> RESOURCE_ID);
}
@Test
void noToken_returns401WithResourceMetadataHeader() throws Exception {
HttpResponse<String> response =
postMcp(null, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}");
assertThat(response.statusCode()).isEqualTo(401);
String wwwAuth = response.headers().firstValue("WWW-Authenticate").orElse("");
assertThat(wwwAuth).contains("resource_metadata=");
assertThat(wwwAuth).contains("/.well-known/oauth-protected-resource");
}
@Test
void validToken_callsToolsListSuccessfully() throws Exception {
String token =
mintToken(
ISSUER,
List.of(RESOURCE_ID),
"mcp.tools.read mcp.tools.write",
Instant.now().plusSeconds(300));
HttpResponse<String> response =
postMcp(token, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}");
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.body()).contains("stirling_describe_operation");
}
@Test
void wrongAudience_isRejected() throws Exception {
String token =
mintToken(
ISSUER,
List.of("https://some-other-resource.example.com"),
"mcp.tools.read",
Instant.now().plusSeconds(300));
HttpResponse<String> response =
postMcp(token, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}");
assertThat(response.statusCode()).isEqualTo(401);
}
@Test
void expiredToken_isRejected() throws Exception {
String token =
mintToken(
ISSUER,
List.of(RESOURCE_ID),
"mcp.tools.read",
Instant.now().minusSeconds(60));
HttpResponse<String> response =
postMcp(token, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}");
assertThat(response.statusCode()).isEqualTo(401);
}
@Test
void validTokenButNoStirlingAccount_isRejectedWith403() throws Exception {
// 'ghost-user' is not provisioned, so account-binding rejects an otherwise-valid token.
String token =
mintToken(
"ghost-user",
ISSUER,
List.of(RESOURCE_ID),
"mcp.tools.read mcp.tools.write",
Instant.now().plusSeconds(300));
HttpResponse<String> response =
postMcp(token, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}");
assertThat(response.statusCode()).isEqualTo(403);
}
@Test
void oversizedBody_isRejectedWith413() throws Exception {
String token =
mintToken(
ISSUER,
List.of(RESOURCE_ID),
"mcp.tools.read mcp.tools.write",
Instant.now().plusSeconds(300));
StringBuilder big =
new StringBuilder("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"x\",\"params\":\"");
big.append("A".repeat(300 * 1024));
big.append("\"}");
HttpResponse<String> response = postMcp(token, big.toString());
assertThat(response.statusCode()).isEqualTo(413);
}
@Test
void oversizedChunkedBody_isRejectedWith413() throws Exception {
// No Content-Length (chunked transfer) exercises the streaming cap rather than the fast
// check.
String token =
mintToken(
ISSUER,
List.of(RESOURCE_ID),
"mcp.tools.read mcp.tools.write",
Instant.now().plusSeconds(300));
byte[] big =
("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"x\",\"params\":\""
+ "A".repeat(300 * 1024)
+ "\"}")
.getBytes(java.nio.charset.StandardCharsets.UTF_8);
HttpRequest request =
HttpRequest.newBuilder()
.uri(URI.create(base() + "/mcp"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token)
.POST(
HttpRequest.BodyPublishers.ofInputStream(
() -> new java.io.ByteArrayInputStream(big)))
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(413);
}
@Test
void malformedJson_returnsJsonRpcParseErrorEnvelope() throws Exception {
// Unparseable JSON must be wrapped as a JSON-RPC Parse error (-32700), not Spring's HTML
// 400.
String token =
mintToken(
ISSUER,
List.of(RESOURCE_ID),
"mcp.tools.read mcp.tools.write",
Instant.now().plusSeconds(300));
HttpResponse<String> response = postMcp(token, "{ this is not valid json ");
assertThat(response.statusCode()).isEqualTo(400);
assertThat(response.body()).contains("-32700");
}
@Test
void metadataEndpoint_isReachableWithoutToken() throws Exception {
HttpRequest request =
HttpRequest.newBuilder()
.uri(URI.create(base() + "/.well-known/oauth-protected-resource"))
.GET()
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.body()).contains(RESOURCE_ID);
assertThat(response.body()).contains(ISSUER);
assertThat(response.body()).contains("mcp.tools.read");
}
private HttpResponse<String> postMcp(String token, String body) throws Exception {
HttpRequest.Builder builder =
HttpRequest.newBuilder()
.uri(URI.create(base() + "/mcp"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body));
if (token != null) {
builder.header("Authorization", "Bearer " + token);
}
return http.send(builder.build(), HttpResponse.BodyHandlers.ofString());
}
private String base() {
return "http://localhost:" + port;
}
private static String mintToken(
String issuer, List<String> audience, String scope, Instant expiry) {
return mintToken("test-user", issuer, audience, scope, expiry);
}
private static String mintToken(
String subject, String issuer, List<String> audience, String scope, Instant expiry) {
try {
JWTClaimsSet claims =
new JWTClaimsSet.Builder()
.issuer(issuer)
.subject(subject)
.audience(audience)
.claim("scope", scope)
.issueTime(Date.from(Instant.now().minusSeconds(5)))
.expirationTime(Date.from(expiry))
.build();
SignedJWT jwt =
new SignedJWT(
new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(KEY_ID).build(),
claims);
jwt.sign(new RSASSASigner(privateKey));
return jwt.serialize();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@SpringBootConfiguration
@EnableAutoConfiguration
@Import({
McpSecurityConfig.class,
McpServerController.class,
DescribeOperationTool.class,
StirlingConvertTool.class,
StirlingPagesTool.class,
StirlingMiscTool.class,
StirlingSecurityTool.class,
StirlingAiTool.class
})
static class TestApp {
@Bean
ApplicationProperties applicationProperties() {
ApplicationProperties props = new ApplicationProperties();
props.getMcp().setEnabled(true);
props.getMcp().setMaxRequestBytes(256L * 1024);
props.getMcp().getAuth().setIssuerUri(ISSUER);
props.getMcp().getAuth().setJwksUri(jwksServer.url("/jwks").toString());
props.getMcp().getAuth().setResourceId(RESOURCE_ID);
props.getAutomaticallyGenerated().setAppVersion("test");
return props;
}
/** Stub UserService: only 'test-user' is a provisioned, enabled account. */
@Bean
UserService userService() {
UserService mock = org.mockito.Mockito.mock(UserService.class);
stirling.software.proprietary.security.model.User account =
org.mockito.Mockito.mock(
stirling.software.proprietary.security.model.User.class);
org.mockito.Mockito.when(account.isEnabled()).thenReturn(true);
org.mockito.Mockito.when(account.getUsername()).thenReturn("test-user");
org.mockito.Mockito.when(
mock.findByUsernameIgnoreCase(org.mockito.ArgumentMatchers.anyString()))
.thenReturn(java.util.Optional.empty());
org.mockito.Mockito.when(mock.findByUsernameIgnoreCase("test-user"))
.thenReturn(java.util.Optional.of(account));
return mock;
}
}
}
@@ -0,0 +1,121 @@
package stirling.software.proprietary.mcp.tools;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import stirling.software.proprietary.mcp.McpCallContext;
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
import stirling.software.proprietary.mcp.catalog.OperationCategory;
import stirling.software.proprietary.mcp.catalog.OperationMeta;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/**
* PDF category tools must not fake success: a bad/missing op returns the operation list, a valid
* scoped op delegates to the executor, and a missing scope is refused.
*/
class CategoryToolDispatchTest {
private final ObjectMapper mapper = new ObjectMapper();
private OperationMeta miscOp() {
return new OperationMeta(
"compress-pdf",
OperationCategory.MISC,
"Compress a PDF",
mapper.createObjectNode(),
"mcp.tools.write",
OperationMeta.Target.JAVA_ENDPOINT,
"/api/v1/misc/compress-pdf",
null);
}
private McpOperationExecutor executorReturning(ObjectNode sentinel) {
McpOperationExecutor executor = mock(McpOperationExecutor.class);
when(executor.execute(any(), any())).thenReturn(sentinel);
return executor;
}
private StirlingMiscTool toolWith(McpOperationExecutor executor) {
OperationMeta meta = miscOp();
McpToolCatalog catalog = mock(McpToolCatalog.class);
when(catalog.findByOperationId("compress-pdf")).thenReturn(Optional.of(meta));
when(catalog.enabledOps(OperationCategory.MISC)).thenReturn(List.of(meta));
@SuppressWarnings("unchecked")
ObjectProvider<McpToolCatalog> catalogProvider = mock(ObjectProvider.class);
when(catalogProvider.getIfAvailable()).thenReturn(catalog);
@SuppressWarnings("unchecked")
ObjectProvider<McpOperationExecutor> executorProvider = mock(ObjectProvider.class);
when(executorProvider.getIfAvailable()).thenReturn(executor);
return new StirlingMiscTool(mapper, catalogProvider, executorProvider);
}
private ObjectNode args(String op) {
ObjectNode a = mapper.createObjectNode();
if (op != null) {
a.put("operation", op);
}
return a;
}
private String textOf(ObjectNode result) {
return result.get("content").get(0).get("text").asText();
}
@Test
void validOpWithScope_delegatesToExecutor() {
ObjectNode sentinel = McpResponses.text(mapper, "EXECUTED");
StirlingMiscTool tool = toolWith(executorReturning(sentinel));
McpCallContext ctx = new McpCallContext("user", Set.of("mcp.tools.write"), true);
ObjectNode result = tool.call(args("compress-pdf"), ctx);
assertEquals("EXECUTED", textOf(result), "valid scoped op must run via the executor");
}
@Test
void unknownOperation_returnsAvailableOperationList() {
StirlingMiscTool tool = toolWith(executorReturning(mapper.createObjectNode()));
McpCallContext ctx = new McpCallContext("user", Set.of("mcp.tools.write"), true);
ObjectNode result = tool.call(args("does-not-exist"), ctx);
assertTrue(result.path("isError").asBoolean(false));
String text = textOf(result);
assertTrue(text.contains("Available operations"), "should list available ops: " + text);
assertTrue(text.contains("compress-pdf"), "should include the valid op id: " + text);
}
@Test
void missingOperation_returnsAvailableOperationList() {
StirlingMiscTool tool = toolWith(executorReturning(mapper.createObjectNode()));
McpCallContext ctx = new McpCallContext("user", Set.of("mcp.tools.write"), true);
ObjectNode result = tool.call(args(null), ctx);
assertTrue(result.path("isError").asBoolean(false));
assertTrue(textOf(result).contains("Available operations"));
}
@Test
void missingScope_returnsScopeError() {
StirlingMiscTool tool = toolWith(executorReturning(mapper.createObjectNode()));
McpCallContext ctx = new McpCallContext("user", Set.of("mcp.tools.read"), true);
ObjectNode result = tool.call(args("compress-pdf"), ctx);
assertTrue(result.path("isError").asBoolean(false));
assertTrue(textOf(result).toLowerCase().contains("scope"));
}
}
@@ -0,0 +1,55 @@
package stirling.software.proprietary.mcp.tools;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import java.util.Set;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.FileStorage;
import stirling.software.proprietary.mcp.McpCallContext;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/** stirling_upload requires write scope; stirling_download requires read scope. */
class FileToolScopeTest {
private final ObjectMapper mapper = new ObjectMapper();
private McpCallContext noScopes() {
return new McpCallContext("user", Set.of(), true);
}
private String text(ObjectNode result) {
return result.get("content").get(0).get("text").asText();
}
@Test
void upload_withoutWriteScope_isRefused() {
StirlingUploadTool tool = new StirlingUploadTool(mapper, mock(FileStorage.class));
ObjectNode args = mapper.createObjectNode();
args.put("file", "YWJj");
ObjectNode result = tool.call(args, noScopes());
assertTrue(result.path("isError").asBoolean(false));
assertTrue(text(result).toLowerCase().contains("scope"));
}
@Test
void download_withoutReadScope_isRefused() {
StirlingDownloadTool tool =
new StirlingDownloadTool(
mapper, mock(FileStorage.class), new ApplicationProperties());
ObjectNode args = mapper.createObjectNode();
args.put("fileId", "abc");
ObjectNode result = tool.call(args, noScopes());
assertTrue(result.path("isError").asBoolean(false));
assertTrue(text(result).toLowerCase().contains("scope"));
}
}
@@ -0,0 +1,146 @@
package stirling.software.proprietary.mcp.tools;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.MultiValueMap;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.InternalApiClient;
import stirling.software.proprietary.mcp.catalog.OperationCategory;
import stirling.software.proprietary.mcp.catalog.OperationMeta;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
class McpOperationExecutorTest {
private final ObjectMapper mapper = new ObjectMapper();
private OperationMeta compressOp() {
return new OperationMeta(
"compress-pdf",
OperationCategory.MISC,
"Compress",
mapper.createObjectNode(),
"mcp.tools.write",
OperationMeta.Target.JAVA_ENDPOINT,
"/api/v1/misc/compress-pdf",
null);
}
private ResponseEntity<Resource> pdfResponse(byte[] bytes) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_PDF);
Resource body =
new ByteArrayResource(bytes) {
@Override
public String getFilename() {
return "out.pdf";
}
};
return ResponseEntity.ok().headers(headers).body(body);
}
@Test
@SuppressWarnings({"unchecked", "rawtypes"})
void inlineBase64Input_runsAndReturnsResultInline() throws Exception {
InternalApiClient api = mock(InternalApiClient.class);
FileStorage storage = mock(FileStorage.class);
ApplicationProperties props = new ApplicationProperties();
when(api.post(eq("/api/v1/misc/compress-pdf"), any()))
.thenReturn(pdfResponse("RESULT".getBytes(StandardCharsets.UTF_8)));
when(storage.storeBytes(any(), anyString())).thenReturn("result-123");
McpOperationExecutor executor = new McpOperationExecutor(mapper, api, storage, props);
ObjectNode args = mapper.createObjectNode();
args.put("operation", "compress-pdf");
args.put(
"file",
Base64.getEncoder().encodeToString("INPUT".getBytes(StandardCharsets.UTF_8)));
args.putObject("parameters").put("optimizeLevel", 2);
ObjectNode result = executor.execute(compressOp(), args);
assertFalse(result.path("isError").asBoolean(false));
ArgumentCaptor<MultiValueMap> bodyCap = ArgumentCaptor.forClass(MultiValueMap.class);
verify(api).post(eq("/api/v1/misc/compress-pdf"), bodyCap.capture());
MultiValueMap captured = bodyCap.getValue();
assertTrue(captured.containsKey("fileInput"), "must send fileInput");
assertEquals("2", String.valueOf(captured.getFirst("optimizeLevel")), "must pass params");
String text = result.get("content").get(0).get("text").asText();
assertTrue(text.contains("result-123"), "must report the result fileId: " + text);
ObjectNode resBlock = (ObjectNode) result.get("content").get(1);
assertEquals("resource", resBlock.get("type").asText());
String blob = resBlock.get("resource").get("blob").asText();
assertEquals(
"RESULT", new String(Base64.getDecoder().decode(blob), StandardCharsets.UTF_8));
}
@Test
void missingFile_returnsError() {
McpOperationExecutor executor =
new McpOperationExecutor(
mapper,
mock(InternalApiClient.class),
mock(FileStorage.class),
new ApplicationProperties());
ObjectNode args = mapper.createObjectNode();
args.put("operation", "compress-pdf");
ObjectNode result = executor.execute(compressOp(), args);
assertTrue(result.path("isError").asBoolean(false));
assertTrue(
result.get("content")
.get(0)
.get("text")
.asText()
.toLowerCase()
.contains("input file"));
}
@Test
void fileIdInput_retrievesStoredBytes() throws Exception {
InternalApiClient api = mock(InternalApiClient.class);
FileStorage storage = mock(FileStorage.class);
when(storage.fileExists("abc")).thenReturn(true);
when(storage.retrieveBytes("abc")).thenReturn("INPUT".getBytes(StandardCharsets.UTF_8));
when(api.post(anyString(), any()))
.thenReturn(pdfResponse("OUT".getBytes(StandardCharsets.UTF_8)));
when(storage.storeBytes(any(), anyString())).thenReturn("res");
McpOperationExecutor executor =
new McpOperationExecutor(mapper, api, storage, new ApplicationProperties());
ObjectNode args = mapper.createObjectNode();
args.put("operation", "compress-pdf");
args.put("fileId", "abc");
ObjectNode result = executor.execute(compressOp(), args);
assertFalse(result.path("isError").asBoolean(false));
verify(storage).retrieveBytes("abc");
}
}
@@ -84,4 +84,47 @@ class AiEngineClientTest {
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
}
@Test
@SuppressWarnings("unchecked")
void allVerbsSendEngineAuthHeaderWhenSecretConfigured() throws Exception {
// Regression for the engine shared-secret hardening: every verb that hits a non-public
// engine route (post/delete/get) must present X-Engine-Auth, or the route 401s once the
// secret is set. delete() backs the logout-time RAG purge, so a miss silently leaks data.
AiEngineClient secured =
new AiEngineClient(applicationProperties, httpClient, "top-secret");
HttpResponse<String> ok = mock(HttpResponse.class);
when(ok.statusCode()).thenReturn(200);
when(ok.body()).thenReturn("{}");
org.mockito.ArgumentCaptor<java.net.http.HttpRequest> captor =
org.mockito.ArgumentCaptor.forClass(java.net.http.HttpRequest.class);
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class))).thenReturn(ok);
secured.post("/api/v1/pdf-question", "{}", "alice");
secured.delete("/api/v1/documents/by-owner", "alice");
secured.get("/api/v1/x", "alice");
for (java.net.http.HttpRequest req : captor.getAllValues()) {
assertEquals(
"top-secret",
req.headers().firstValue("X-Engine-Auth").orElse(null),
req.method() + " must carry the engine shared secret");
}
}
@Test
@SuppressWarnings("unchecked")
void noEngineAuthHeaderWhenSecretUnset() throws Exception {
AiEngineClient noSecret = new AiEngineClient(applicationProperties, httpClient, null);
HttpResponse<String> ok = mock(HttpResponse.class);
when(ok.statusCode()).thenReturn(200);
when(ok.body()).thenReturn("{}");
org.mockito.ArgumentCaptor<java.net.http.HttpRequest> captor =
org.mockito.ArgumentCaptor.forClass(java.net.http.HttpRequest.class);
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class))).thenReturn(ok);
noSecret.delete("/api/v1/documents/by-owner", "alice");
assertEquals(null, captor.getValue().headers().firstValue("X-Engine-Auth").orElse(null));
}
}
@@ -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
@@ -1110,6 +1110,66 @@ label = "SMTP Port"
description = "Username for SMTP authentication"
label = "SMTP Username"
[admin.settings.mcp]
apikeyNote = "Clients send a Stirling API key in the X-API-KEY header (or Authorization: Bearer <key>). The key maps to its owning Stirling user - only provisioned accounts get in, and actions are audited as that user. Manage keys under Account > API Keys."
description = "Expose Stirling's PDF tools and AI agents to MCP clients over an OAuth-protected endpoint."
title = "MCP Server"
[admin.settings.mcp.allowedOps]
description = "If set, ONLY these operation ids are exposed (an allow-list; comma or space separated). Leave blank to expose all enabled tools except any blocked below."
label = "Allowed tools"
[admin.settings.mcp.blockedOps]
description = "Operation ids to hide from MCP (comma or space separated), e.g. add-password remove-password. Leave blank to expose all enabled tools."
label = "Blocked tools"
[admin.settings.mcp.enabled]
description = "When off (default), no /mcp endpoint, metadata, or MCP beans are loaded."
label = "Enable MCP Server"
[admin.settings.mcp.guide]
step1 = "Enter your OAuth issuer + resource ID above, Save, and restart."
step1ApiKey = "Create an API key under Account > API Keys (each user uses their own)."
step2 = "In your MCP client add a"
step2b = "server pointing at:"
step3 = "The client auto-discovers OAuth from:"
step3ApiKey = "Send the key in an"
step3ApiKeyb = "header (or Authorization: Bearer <key>). No OAuth or metadata discovery is used."
step4 = "Approve the sign-in; the client retries with a token. Tools appear grouped: convert, pages, misc, security, ai."
step4ApiKey = "Tools appear grouped: convert, pages, misc, security, ai - and every call is audited as the key's owner."
tip = "Tip: register the resource ID as an allowed audience in your IdP. Tested with MCP Inspector and Claude Desktop."
tipApiKey = "Tip: API-key mode needs no external IdP - simplest for self-host. The key maps to its owning Stirling user."
title = "Connect an MCP client"
[admin.settings.mcp.issuerUri]
description = "Your OAuth2 authorization server (must publish /.well-known/openid-configuration). Required when enabled."
label = "OAuth Issuer URL"
[admin.settings.mcp.jwksUri]
description = "Leave blank to discover it from the issuer. Set only if your IdP serves keys at a non-standard URL."
label = "JWKS URL (optional)"
placeholder = "Auto-discovered from issuer"
[admin.settings.mcp.mode]
description = "OAuth needs an external IdP. API key uses a Stirling per-user API key (X-API-KEY) - simplest for self-host."
label = "Authentication mode"
[admin.settings.mcp.requireAccount]
description = "Only let tokens through if their subject maps to a provisioned, enabled Stirling user."
label = "Require an existing Stirling account"
[admin.settings.mcp.resourceId]
description = "This server's public /mcp URL. Tokens must list it in their audience (RFC 8707) or they are rejected."
label = "Resource ID"
[admin.settings.mcp.scopes]
description = "Require mcp.tools.read for read ops and mcp.tools.write for write/AI ops."
label = "Enforce OAuth scopes"
[admin.settings.mcp.usernameClaim]
description = "JWT claim matched against a Stirling username (e.g. sub, email, preferred_username)."
label = "Username claim"
[admin.settings.plan.noData]
message = "Plans data is not available at the moment."
title = "No data available"
@@ -7219,6 +7279,7 @@ advanced = "Advanced"
database = "Database"
endpoints = "Endpoints"
features = "Features"
mcp = "MCP Server"
storageSharing = "File Storage & Sharing"
systemSettings = "System Settings"
title = "Configuration"
@@ -40,6 +40,7 @@ const SETTINGS_SEARCH_TRANSLATION_PREFIXES: Partial<Record<string, string[]>> =
adminDatabase: ["admin.settings.database"],
adminAdvanced: ["admin.settings.advanced"],
adminSecurity: ["admin.settings.security"],
adminMcp: ["admin.settings.mcp"],
adminConnections: [
"admin.settings.connections",
"admin.settings.mail",
@@ -29,6 +29,7 @@ export const VALID_NAV_KEYS = [
"adminUsage",
"adminEndpoints",
"adminStorageSharing",
"adminMcp",
"help",
] as const;
@@ -17,6 +17,7 @@ import AdminLegalSection from "@app/components/shared/config/configSections/Admi
import AdminPlanSection from "@app/components/shared/config/configSections/AdminPlanSection";
import AdminFeaturesSection from "@app/components/shared/config/configSections/AdminFeaturesSection";
import AdminEndpointsSection from "@app/components/shared/config/configSections/AdminEndpointsSection";
import AdminMcpSection from "@app/components/shared/config/configSections/AdminMcpSection";
import AdminAuditSection from "@app/components/shared/config/configSections/AdminAuditSection";
import AdminUsageSection from "@app/components/shared/config/configSections/AdminUsageSection";
import AdminStorageSharingSection from "@app/components/shared/config/configSections/AdminStorageSharingSection";
@@ -136,6 +137,14 @@ export const useConfigNavSections = (
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "adminMcp",
label: t("settings.configuration.mcp", "MCP Server"),
icon: "smart-toy-rounded",
component: <AdminMcpSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "adminDatabase",
label: t("settings.configuration.database", "Database"),
@@ -0,0 +1,551 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
TextInput,
Textarea,
Switch,
Select,
Stack,
Paper,
Text,
Loader,
Group,
Alert,
Code,
List,
} from "@mantine/core";
import { alert } from "@app/components/toast";
import LocalIcon from "@app/components/shared/LocalIcon";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import apiClient from "@app/services/apiClient";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
interface McpAuthData {
mode?: string;
issuerUri?: string;
jwksUri?: string;
resourceId?: string;
usernameClaim?: string;
requireExistingAccount?: boolean;
}
interface McpSettingsData {
enabled?: boolean;
scopesEnabled?: boolean;
allowedOperations?: string[];
blockedOperations?: string[];
auth?: McpAuthData;
}
/** Parse a comma/space/newline separated list into a string[]. */
function parseOpList(raw: string): string[] {
return raw
.split(/[\s,]+/)
.map((s) => s.trim())
.filter(Boolean);
}
interface ApiResponseWithPending<T> {
_pending?: Partial<T>;
}
type McpApiResponse = McpSettingsData & ApiResponseWithPending<McpSettingsData>;
export default function AdminMcpSection() {
const { t } = useTranslation();
const { loginEnabled } = useLoginRequired();
const {
restartModalOpened,
showRestartModal,
closeRestartModal,
restartServer,
} = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<McpSettingsData>({
sectionName: "mcp",
fetchTransformer: async (): Promise<
McpSettingsData & { _pending?: Partial<McpSettingsData> }
> => {
const response = await apiClient.get<McpApiResponse>(
"/api/v1/admin/settings/section/mcp",
);
return response.data || {};
},
// Save nested auth.* keys as dot-notation through the root endpoint so siblings are preserved.
saveTransformer: (s: McpSettingsData) => ({
sectionData: {},
deltaSettings: {
"mcp.enabled": s.enabled ?? false,
"mcp.scopesEnabled": s.scopesEnabled ?? true,
"mcp.allowedOperations": s.allowedOperations ?? [],
"mcp.blockedOperations": s.blockedOperations ?? [],
"mcp.auth.mode": s.auth?.mode ?? "oauth",
"mcp.auth.issuerUri": s.auth?.issuerUri ?? "",
"mcp.auth.jwksUri": s.auth?.jwksUri ?? "",
"mcp.auth.resourceId": s.auth?.resourceId ?? "",
"mcp.auth.usernameClaim": s.auth?.usernameClaim ?? "sub",
"mcp.auth.requireExistingAccount":
s.auth?.requireExistingAccount ?? true,
},
}),
});
useEffect(() => {
fetchSettings();
}, []);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleSave = async () => {
try {
await saveSettings();
markSaved();
showRestartModal();
} catch (_error) {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
const handleDiscard = useCallback(() => {
setSettings(resetToSnapshot());
}, [resetToSnapshot, setSettings]);
const setAuth = (patch: Partial<McpAuthData>) =>
setSettings({ ...settings, auth: { ...(settings.auth || {}), ...patch } });
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
const baseUrl =
typeof window !== "undefined"
? window.location.origin
: "https://your-host";
const mcpUrl = `${baseUrl}/mcp`;
const metadataUrl = `${baseUrl}/.well-known/oauth-protected-resource`;
const authMode = settings.auth?.mode || "oauth";
return (
<div className="settings-section-container">
<Stack gap="lg" className="settings-section-content">
<div>
<Text fw={600} size="lg">
{t("admin.settings.mcp.title", "MCP Server")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.mcp.description",
"Expose Stirling's PDF tools and AI agents to MCP clients over an OAuth-protected endpoint.",
)}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text fw={500} size="sm">
{t("admin.settings.mcp.enabled.label", "Enable MCP Server")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.mcp.enabled.description",
"When off (default), no /mcp endpoint, metadata, or MCP beans are loaded.",
)}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.enabled || false}
onChange={(e) =>
setSettings({ ...settings, enabled: e.target.checked })
}
/>
<PendingBadge show={isFieldPending("enabled")} />
</Group>
</Group>
<Select
label={
<Group gap="xs">
<span>
{t("admin.settings.mcp.mode.label", "Authentication mode")}
</span>
<PendingBadge show={isFieldPending("auth.mode")} />
</Group>
}
description={t(
"admin.settings.mcp.mode.description",
"OAuth needs an external IdP. API key uses a Stirling per-user API key (X-API-KEY) - simplest for self-host.",
)}
data={[
{ value: "oauth", label: "OAuth 2.1 (external IdP)" },
{ value: "apikey", label: "API key (Stirling per-user key)" },
]}
value={authMode}
onChange={(v) => setAuth({ mode: v || "oauth" })}
allowDeselect={false}
disabled={!settings.enabled}
/>
{authMode === "apikey" && (
<Alert variant="light" color="gray">
<Text size="xs">
{t(
"admin.settings.mcp.apikeyNote",
"Clients send a Stirling API key in the X-API-KEY header (or Authorization: Bearer <key>). The key maps to its owning Stirling user - only provisioned accounts get in, and actions are audited as that user. Manage keys under Account → API Keys.",
)}
</Text>
</Alert>
)}
{authMode === "oauth" && (
<>
<TextInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.mcp.issuerUri.label",
"OAuth Issuer URL",
)}
</span>
<PendingBadge show={isFieldPending("auth.issuerUri")} />
</Group>
}
description={t(
"admin.settings.mcp.issuerUri.description",
"Your OAuth2 authorization server (must publish /.well-known/openid-configuration). Required when enabled.",
)}
value={settings.auth?.issuerUri || ""}
onChange={(e) => setAuth({ issuerUri: e.target.value })}
placeholder="https://auth.example.com"
disabled={!settings.enabled}
/>
<TextInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.mcp.resourceId.label",
"Resource ID",
)}
</span>
<PendingBadge show={isFieldPending("auth.resourceId")} />
</Group>
}
description={t(
"admin.settings.mcp.resourceId.description",
"This server's public /mcp URL. Tokens must list it in their audience (RFC 8707) or they are rejected.",
)}
value={settings.auth?.resourceId || ""}
onChange={(e) => setAuth({ resourceId: e.target.value })}
placeholder={mcpUrl}
disabled={!settings.enabled}
/>
<TextInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.mcp.jwksUri.label",
"JWKS URL (optional)",
)}
</span>
<PendingBadge show={isFieldPending("auth.jwksUri")} />
</Group>
}
description={t(
"admin.settings.mcp.jwksUri.description",
"Leave blank to discover it from the issuer. Set only if your IdP serves keys at a non-standard URL.",
)}
value={settings.auth?.jwksUri || ""}
onChange={(e) => setAuth({ jwksUri: e.target.value })}
placeholder={t(
"admin.settings.mcp.jwksUri.placeholder",
"Auto-discovered from issuer",
)}
disabled={!settings.enabled}
/>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text fw={500} size="sm">
{t(
"admin.settings.mcp.scopes.label",
"Enforce OAuth scopes",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.mcp.scopes.description",
"Require mcp.tools.read for read ops and mcp.tools.write for write/AI ops.",
)}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.scopesEnabled ?? true}
onChange={(e) =>
setSettings({
...settings,
scopesEnabled: e.target.checked,
})
}
disabled={!settings.enabled}
/>
<PendingBadge show={isFieldPending("scopesEnabled")} />
</Group>
</Group>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text fw={500} size="sm">
{t(
"admin.settings.mcp.requireAccount.label",
"Require an existing Stirling account",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.mcp.requireAccount.description",
"Only let tokens through if their subject maps to a provisioned, enabled Stirling user.",
)}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.auth?.requireExistingAccount ?? true}
onChange={(e) =>
setAuth({ requireExistingAccount: e.target.checked })
}
disabled={!settings.enabled}
/>
<PendingBadge
show={isFieldPending("auth.requireExistingAccount")}
/>
</Group>
</Group>
<TextInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.mcp.usernameClaim.label",
"Username claim",
)}
</span>
<PendingBadge
show={isFieldPending("auth.usernameClaim")}
/>
</Group>
}
description={t(
"admin.settings.mcp.usernameClaim.description",
"JWT claim matched against a Stirling username (e.g. sub, email, preferred_username).",
)}
value={settings.auth?.usernameClaim || ""}
onChange={(e) => setAuth({ usernameClaim: e.target.value })}
placeholder="sub"
disabled={!settings.enabled}
/>
</>
)}
<Textarea
label={
<Group gap="xs">
<span>
{t("admin.settings.mcp.allowedOps.label", "Allowed tools")}
</span>
<PendingBadge show={isFieldPending("allowedOperations")} />
</Group>
}
description={t(
"admin.settings.mcp.allowedOps.description",
"If set, ONLY these operation ids are exposed (an allow-list; comma or space separated). Leave blank to expose all enabled tools except any blocked below.",
)}
value={(settings.allowedOperations || []).join(" ")}
onChange={(e) =>
setSettings({
...settings,
allowedOperations: parseOpList(e.target.value),
})
}
placeholder="merge-pdfs split-pdf rotate-pdf"
autosize
minRows={1}
maxRows={3}
disabled={!settings.enabled}
/>
<Textarea
label={
<Group gap="xs">
<span>
{t("admin.settings.mcp.blockedOps.label", "Blocked tools")}
</span>
<PendingBadge show={isFieldPending("blockedOperations")} />
</Group>
}
description={t(
"admin.settings.mcp.blockedOps.description",
"Operation ids to hide from MCP (comma or space separated), e.g. add-password remove-password. Leave blank to expose all enabled tools.",
)}
value={(settings.blockedOperations || []).join(" ")}
onChange={(e) =>
setSettings({
...settings,
blockedOperations: parseOpList(e.target.value),
})
}
placeholder="add-password sanitize-pdf"
autosize
minRows={1}
maxRows={3}
disabled={!settings.enabled}
/>
</Stack>
</Paper>
{/* Compact in-page setup guide */}
<Alert
variant="light"
color="blue"
title={t("admin.settings.mcp.guide.title", "Connect an MCP client")}
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Stack gap={6}>
<List size="xs" type="ordered" spacing={4}>
{authMode === "oauth" ? (
<>
<List.Item>
{t(
"admin.settings.mcp.guide.step1",
"Enter your OAuth issuer + resource ID above, Save, and restart.",
)}
</List.Item>
<List.Item>
{t(
"admin.settings.mcp.guide.step2",
"In your MCP client add a",
)}{" "}
<b>streamable-HTTP</b>{" "}
{t(
"admin.settings.mcp.guide.step2b",
"server pointing at:",
)}{" "}
<Code>{mcpUrl}</Code>
</List.Item>
<List.Item>
{t(
"admin.settings.mcp.guide.step3",
"The client auto-discovers OAuth from:",
)}{" "}
<Code>{metadataUrl}</Code>
</List.Item>
<List.Item>
{t(
"admin.settings.mcp.guide.step4",
"Approve the sign-in; the client retries with a token. Tools appear grouped: convert, pages, misc, security, ai.",
)}
</List.Item>
</>
) : (
<>
<List.Item>
{t(
"admin.settings.mcp.guide.step1ApiKey",
"Create an API key under Account → API Keys (each user uses their own).",
)}
</List.Item>
<List.Item>
{t(
"admin.settings.mcp.guide.step2",
"In your MCP client add a",
)}{" "}
<b>streamable-HTTP</b>{" "}
{t(
"admin.settings.mcp.guide.step2b",
"server pointing at:",
)}{" "}
<Code>{mcpUrl}</Code>
</List.Item>
<List.Item>
{t(
"admin.settings.mcp.guide.step3ApiKey",
"Send the key in an",
)}{" "}
<Code>X-API-KEY</Code>{" "}
{t(
"admin.settings.mcp.guide.step3ApiKeyb",
"header (or Authorization: Bearer <key>). No OAuth or metadata discovery is used.",
)}
</List.Item>
<List.Item>
{t(
"admin.settings.mcp.guide.step4ApiKey",
"Tools appear grouped: convert, pages, misc, security, ai - and every call is audited as the key's owner.",
)}
</List.Item>
</>
)}
</List>
<Text size="xs" c="dimmed">
{authMode === "oauth"
? t(
"admin.settings.mcp.guide.tip",
"Tip: register the resource ID as an allowed audience in your IdP. Tested with MCP Inspector and Claude Desktop.",
)
: t(
"admin.settings.mcp.guide.tipApiKey",
"Tip: API-key mode needs no external IdP - simplest for self-host. The key maps to its owning Stirling user.",
)}
</Text>
</Stack>
</Alert>
</Stack>
<SettingsStickyFooter
isDirty={isDirty}
saving={saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</div>
);
}
@@ -0,0 +1,134 @@
services:
keycloak-mcp-db:
container_name: stirling-keycloak-mcp-db
image: postgres:16-alpine
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: keycloak
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keycloak"]
interval: 5s
timeout: 5s
retries: 10
networks:
- stirling-mcp-test
keycloak-mcp:
container_name: stirling-keycloak-mcp
image: quay.io/keycloak/keycloak:24.0
command:
- start-dev
- --import-realm
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://keycloak-mcp-db:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: keycloak
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
# Issuer hostname must resolve identically from host and Stirling container (see start-mcp-test.sh preflight).
KC_HOSTNAME: "${KEYCLOAK_HOST:-kubernetes.docker.internal}"
KC_HOSTNAME_PORT: 9080
KC_HOSTNAME_STRICT: "false"
KC_HTTP_ENABLED: "true"
ports:
- "9080:8080"
volumes:
- ./keycloak-realm-mcp.json:/opt/keycloak/data/import/realm-export.json:ro
depends_on:
keycloak-mcp-db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && echo -e 'GET /realms/stirling-mcp HTTP/1.1\\nHost: localhost\\nConnection: close\\n\\n' >&3 && timeout 2 cat <&3 | head -n 1 | grep -q '200'"]
interval: 10s
timeout: 10s
retries: 30
start_period: 60s
networks:
- stirling-mcp-test
stirling-pdf-mcp:
container_name: stirling-pdf-mcp-test
image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest
build:
context: ../..
dockerfile: docker/embedded/Dockerfile
extra_hosts:
localhost: host-gateway
kubernetes.docker.internal: host-gateway
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
interval: 5s
timeout: 10s
retries: 30
ports:
- "8080:8080"
volumes:
- ../../../stirling/keycloak-mcp-test/data:/usr/share/tessdata:rw
- ../../../stirling/keycloak-mcp-test/config:/configs:rw
- ../../../stirling/keycloak-mcp-test/logs:/logs:rw
environment:
# Security/login on so we have real Stirling accounts to bind to.
DOCKER_ENABLE_SECURITY: "true"
SECURITY_ENABLELOGIN: "true"
SECURITY_LOGINMETHOD: "normal"
SYSTEM_DEFAULTLOCALE: en-US
SYSTEM_BACKENDURL: "http://localhost:8080"
PREMIUM_KEY: "${PREMIUM_KEY:-00000000-0000-0000-0000-000000000000}"
PREMIUM_ENABLED: "true"
UI_APPNAME: Stirling-PDF MCP Test
UI_HOMEDESCRIPTION: Keycloak MCP (OAuth resource server) Test Instance
UI_APPNAMENAVBAR: Stirling-PDF MCP
SYSTEM_MAXFILESIZE: "100"
# Seed the Stirling account the Keycloak "email" claim binds to (proves account-binding).
SECURITY_INITIALLOGIN_USERNAME: "[email protected]"
SECURITY_INITIALLOGIN_PASSWORD: "mcppassword"
# MCP server: OAuth2 resource-server mode (validates Keycloak tokens at /mcp, no web SSO).
MCP_ENABLED: "true"
MCP_AUTH_MODE: "${MCP_AUTH_MODE:-oauth}"
# Lowered so the oversized-body 413 check stays fast; production default is 10MB.
MCP_MAXREQUESTBYTES: "262144"
# Must match the token "iss" exactly.
MCP_AUTH_ISSUERURI: "http://${KEYCLOAK_HOST:-kubernetes.docker.internal}:9080/realms/stirling-mcp"
# RFC 8707 audience - must equal the "aud" the realm's mapper injects.
MCP_AUTH_RESOURCEID: "http://localhost:8080/mcp"
# Bind the JWT to a Stirling user by the "email" claim.
MCP_AUTH_USERNAMECLAIM: "email"
MCP_AUTH_REQUIREEXISTINGACCOUNT: "true"
# Resource server only - disable the web SSO client paths.
SECURITY_OAUTH2_ENABLED: "false"
SECURITY_SAML2_ENABLED: "false"
# Debug logging
LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_SECURITY_OAUTH2: DEBUG
LOGGING_LEVEL_STIRLING_SOFTWARE_PROPRIETARY_MCP: DEBUG
# LibreOffice settings
PROCESS_EXECUTOR_AUTO_UNO_SERVER: "true"
PROCESS_EXECUTOR_SESSION_LIMIT_LIBRE_OFFICE_SESSION_LIMIT: "1"
# Permissions
PUID: 1002
PGID: 1002
UMASK: "022"
# Features
DISABLE_ADDITIONAL_FEATURES: "false"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "false"
SHOW_SURVEY: "false"
depends_on:
keycloak-mcp:
condition: service_healthy
networks:
- stirling-mcp-test
restart: on-failure:5
networks:
stirling-mcp-test:
driver: bridge
+438
View File
@@ -0,0 +1,438 @@
{
"id": "stirling-mcp",
"realm": "stirling-mcp",
"displayName": "Stirling PDF MCP Test",
"displayNameHtml": "<div class=\"kc-logo-text\"><span>Stirling PDF MCP</span></div>",
"enabled": true,
"sslRequired": "none",
"registrationAllowed": false,
"registrationEmailAsUsername": true,
"rememberMe": true,
"verifyEmail": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": true,
"editUsernameAllowed": false,
"bruteForceProtected": false,
"accessTokenLifespan": 300,
"accessTokenLifespanForImplicitFlow": 900,
"ssoSessionIdleTimeout": 1800,
"ssoSessionMaxLifespan": 36000,
"offlineSessionIdleTimeout": 2592000,
"users": [
{
"username": "[email protected]",
"email": "[email protected]",
"emailVerified": true,
"firstName": "MCP",
"lastName": "TestUser",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "mcppassword",
"temporary": false
}
],
"realmRoles": ["user"]
},
{
"username": "[email protected]",
"email": "[email protected]",
"emailVerified": true,
"firstName": "Ghost",
"lastName": "NoAccount",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "ghostpassword",
"temporary": false
}
],
"realmRoles": ["user"]
}
],
"roles": {
"realm": [
{
"name": "user",
"description": "Regular user role",
"composite": false,
"clientRole": false
}
]
},
"clients": [
{
"clientId": "mcp-test-client",
"name": "Stirling PDF MCP Test Client (confidential)",
"description": "Confidential client for scripted MCP testing: ROPC (direct grant) for the validation script and standard/PKCE flow for browser-based MCP clients.",
"rootUrl": "http://localhost:8080",
"baseUrl": "http://localhost:8080",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"secret": "mcp-test-secret",
"redirectUris": [
"http://localhost:8080/*",
"http://localhost:6274/*",
"http://127.0.0.1:6274/*"
],
"webOrigins": ["*"],
"bearerOnly": false,
"consentRequired": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": true,
"serviceAccountsEnabled": false,
"publicClient": false,
"frontchannelLogout": false,
"protocol": "openid-connect",
"attributes": {
"oidc.ciba.grant.enabled": "false",
"backchannel.logout.session.required": "true",
"oauth2.device.authorization.grant.enabled": "false",
"display.on.consent.screen": "false",
"backchannel.logout.revoke.offline.tokens": "false",
"pkce.code.challenge.method": "S256"
},
"fullScopeAllowed": true,
"protocolMappers": [
{
"name": "email",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "email",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "email",
"jsonType.label": "String"
}
},
{
"name": "preferred_username",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "username",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "preferred_username",
"jsonType.label": "String"
}
},
{
"name": "mcp-audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.custom.audience": "http://localhost:8080/mcp",
"id.token.claim": "false",
"access.token.claim": "true"
}
}
],
"defaultClientScopes": [
"web-origins",
"acr",
"profile",
"roles",
"email",
"mcp.tools.read",
"mcp.tools.write"
],
"optionalClientScopes": ["offline_access", "microprofile-jwt"]
},
{
"clientId": "mcp-client",
"name": "MCP public client (authorization-code + PKCE)",
"description": "Generic public client for any browser-based MCP client. Authorization-code + PKCE, no secret. Broad localhost redirect URIs so any local MCP client callback port is accepted.",
"rootUrl": "http://localhost",
"baseUrl": "http://localhost",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"redirectUris": [
"http://localhost:*",
"http://127.0.0.1:*",
"https://localhost:*",
"https://127.0.0.1:*"
],
"webOrigins": ["*"],
"bearerOnly": false,
"consentRequired": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"publicClient": true,
"frontchannelLogout": false,
"protocol": "openid-connect",
"attributes": {
"oidc.ciba.grant.enabled": "false",
"oauth2.device.authorization.grant.enabled": "false",
"display.on.consent.screen": "false",
"pkce.code.challenge.method": "S256"
},
"fullScopeAllowed": true,
"protocolMappers": [
{
"name": "email",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "email",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "email",
"jsonType.label": "String"
}
},
{
"name": "preferred_username",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "username",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "preferred_username",
"jsonType.label": "String"
}
},
{
"name": "mcp-audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.custom.audience": "http://localhost:8080/mcp",
"id.token.claim": "false",
"access.token.claim": "true"
}
}
],
"defaultClientScopes": [
"web-origins",
"acr",
"profile",
"roles",
"email",
"mcp.tools.read",
"mcp.tools.write"
],
"optionalClientScopes": ["offline_access", "microprofile-jwt"]
},
{
"clientId": "other-client",
"name": "Unrelated client (negative test)",
"description": "Issues VALID tokens (good signature + issuer) that do NOT carry the MCP audience and have no mcp.tools.* scopes - used to prove the resource server rejects tokens minted for a different audience (RFC 8707).",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"publicClient": true,
"bearerOnly": false,
"standardFlowEnabled": false,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": true,
"serviceAccountsEnabled": false,
"protocol": "openid-connect",
"attributes": {
"pkce.code.challenge.method": "S256"
},
"fullScopeAllowed": false,
"defaultClientScopes": ["web-origins", "acr", "profile", "roles", "email"],
"optionalClientScopes": ["offline_access", "microprofile-jwt"]
}
],
"clientScopes": [
{
"name": "mcp.tools.read",
"description": "MCP: read-only tool access (list / describe / inspect PDF operations)",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true"
}
},
{
"name": "mcp.tools.write",
"description": "MCP: write tool access (run PDF operations that produce output)",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true"
}
},
{
"name": "mcp-audience",
"description": "Injects the MCP resource-server audience (RFC 8707) into access tokens. A default scope so even dynamically-registered clients produce tokens Stirling accepts.",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "false",
"display.on.consent.screen": "false"
},
"protocolMappers": [
{
"name": "mcp-audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.custom.audience": "http://localhost:8080/mcp",
"id.token.claim": "false",
"access.token.claim": "true"
}
}
]
},
{
"name": "email",
"description": "OpenID Connect built-in scope: email",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true"
},
"protocolMappers": [
{
"name": "email",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "email",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "email",
"jsonType.label": "String"
}
},
{
"name": "email verified",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "emailVerified",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "email_verified",
"jsonType.label": "boolean"
}
}
]
},
{
"name": "profile",
"description": "OpenID Connect built-in scope: profile",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true"
},
"protocolMappers": [
{
"name": "username",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "username",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "preferred_username",
"jsonType.label": "String"
}
}
]
},
{
"name": "roles",
"description": "OpenID Connect scope for user roles",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "false",
"display.on.consent.screen": "true"
},
"protocolMappers": [
{
"name": "realm roles",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-realm-role-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "roles",
"jsonType.label": "String",
"multivalued": "true"
}
}
]
}
],
"defaultDefaultClientScopes": [
"profile",
"email",
"roles",
"web-origins",
"acr",
"mcp.tools.read",
"mcp.tools.write",
"mcp-audience"
],
"defaultOptionalClientScopes": ["offline_access", "microprofile-jwt"],
"browserSecurityHeaders": {
"contentSecurityPolicyReportOnly": "",
"xContentTypeOptions": "nosniff",
"referrerPolicy": "no-referrer",
"xRobotsTag": "none",
"xFrameOptions": "SAMEORIGIN",
"contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';",
"xXSSProtection": "1; mode=block",
"strictTransportSecurity": "max-age=31536000; includeSubDomains"
},
"eventsEnabled": false,
"eventsListeners": ["jboss-logging"],
"enabledEventTypes": [],
"adminEventsEnabled": false,
"adminEventsDetailsEnabled": false,
"internationalizationEnabled": false,
"supportedLocales": [],
"components": {
"org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [
{
"name": "Trusted Hosts",
"providerId": "trusted-hosts",
"subType": "anonymous",
"config": {
"trusted-hosts": ["localhost", "127.0.0.1"],
"host-sending-registration-request-must-match": ["false"],
"client-uris-must-match": ["true"]
}
},
{
"name": "Allowed Client Scopes",
"providerId": "allowed-client-templates",
"subType": "anonymous",
"config": {
"allow-default-scopes": ["true"]
}
}
]
},
"keycloakVersion": "24.0.0"
}
@@ -0,0 +1,2 @@
node_modules/
package-lock.json
+100
View File
@@ -0,0 +1,100 @@
/**
* Real MCP client validation via the official @modelcontextprotocol/sdk over streamable HTTP.
*
* Auth header (non-interactive):
* MCP_BEARER=<jwt> -> Authorization: Bearer <jwt> (oauth)
* MCP_APIKEY=<key> -> X-API-KEY: <key> (apikey)
* Env: MCP_URL (default http://localhost:8080/mcp), MODE (output label). Exit 0 = passed.
*/
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const url = process.env.MCP_URL || "http://localhost:8080/mcp";
const mode = process.env.MODE || "oauth";
const bearer = process.env.MCP_BEARER;
const apikey = process.env.MCP_APIKEY;
const headers = {};
if (bearer) headers["Authorization"] = `Bearer ${bearer}`;
if (apikey) headers["X-API-KEY"] = apikey;
function fail(msg) {
console.error(`[${mode}] FAIL: ${msg}`);
process.exit(1);
}
const REQUIRED = [
"stirling_describe_operation",
"stirling_convert",
"stirling_pages",
"stirling_misc",
"stirling_security",
"stirling_upload",
"stirling_download",
];
const transport = new StreamableHTTPClientTransport(new URL(url), {
requestInit: { headers },
});
const client = new Client(
{ name: "stirling-mcp-validator", version: "1.0.0" },
{ capabilities: {} },
);
try {
// connect() performs the initialize handshake (version negotiation + capabilities)
await client.connect(transport);
const { tools } = await client.listTools();
const names = tools.map((t) => t.name).sort();
for (const r of REQUIRED) {
if (!names.includes(r)) fail(`tools/list missing ${r} (got: ${names.join(", ")})`);
}
const res = await client.callTool({
name: "stirling_describe_operation",
arguments: { operation: "add-password" },
});
if (!JSON.stringify(res).includes("parametersSchema")) {
fail(`describe_operation returned no parametersSchema: ${JSON.stringify(res).slice(0, 300)}`);
}
// File I/O round-trip: upload bytes, then download them back unchanged.
const original = "hello mcp round-trip";
const up = await client.callTool({
name: "stirling_upload",
arguments: { file: Buffer.from(original, "utf8").toString("base64"), fileName: "hello.txt" },
});
const upMatch = JSON.stringify(up).match(/fileId=([A-Za-z0-9_-]+)/);
if (!upMatch) fail(`stirling_upload returned no fileId: ${JSON.stringify(up).slice(0, 200)}`);
const fileId = upMatch[1];
const down = await client.callTool({ name: "stirling_download", arguments: { fileId } });
const resBlock = (down.content || []).find((b) => b.type === "resource");
if (!resBlock?.resource?.blob) {
fail(`stirling_download returned no resource blob: ${JSON.stringify(down).slice(0, 200)}`);
}
const roundTripped = Buffer.from(resBlock.resource.blob, "base64").toString("utf8");
if (roundTripped !== original) {
fail(`upload/download round-trip mismatch: got "${roundTripped}"`);
}
// A category tool with no file must surface an honest error, not a fake success.
const cat = await client.callTool({
name: "stirling_security",
arguments: { operation: "add-password" },
});
if (cat.isError !== true) {
fail(`stirling_security with no file should report isError, got: ${JSON.stringify(cat).slice(0, 200)}`);
}
console.log(
`[${mode}] OK: real MCP SDK client connected, negotiated protocol, listed ${names.length} tools, ` +
`describe_operation returned a schema, upload/download round-trip matched, category tool returned isError.`,
);
console.log(`[${mode}] tools: ${names.join(", ")}`);
await client.close();
process.exit(0);
} catch (e) {
fail(`${e?.stack || e?.message || e}`);
}
@@ -0,0 +1,10 @@
{
"name": "stirling-mcp-client-check",
"private": true,
"type": "module",
"version": "1.0.0",
"description": "Real MCP client (official @modelcontextprotocol/sdk) used to validate the Stirling MCP server end-to-end over streamable HTTP.",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0"
}
}
+290
View File
@@ -0,0 +1,290 @@
#!/bin/bash
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# Manual-testing panel: generic "connect your MCP client" settings.
print_manual_panel() {
echo ""
echo -e "${BLUE}╔════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ MCP MANUAL TESTING - connect a client ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "${GREEN}Add an MCP server in your client with these settings:${NC}"
echo ""
echo -e " Server URL ${YELLOW}http://localhost:8080/mcp${NC}"
echo -e " Connection type ${YELLOW}HTTP${NC} (streamable HTTP)"
echo -e " Authentication ${YELLOW}OAuth 2.0${NC}"
echo -e " OAuth scopes ${YELLOW}mcp.tools.read mcp.tools.write${NC}"
echo -e " OAuth client id ${YELLOW}mcp-client${NC} (public - leave the client secret blank)"
echo ""
echo -e " ${BLUE}The client redirects you to a sign-in page; log in with:${NC}"
echo -e " ${YELLOW}[email protected]${NC} / ${YELLOW}mcppassword${NC}"
echo ""
echo -e "${BLUE}Stop:${NC} docker-compose -f docker-compose-keycloak-mcp.yml down -v"
echo ""
}
# Manual API-key panel: mint a Stirling per-user key and print header-based client settings.
print_manual_panel_apikey() {
local jwt key
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')
# Reuse the user's existing key if any; else create one.
key=$(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 "$key" ]; then
key=$(curl -s -X POST http://localhost:8080/api/v1/user/update-api-key \
-H "Authorization: Bearer $jwt" \
| sed -n 's/.*"apiKey":"\([^"]*\)".*/\1/p')
fi
echo ""
echo -e "${BLUE}╔════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ MCP MANUAL TESTING - API KEY (no OAuth / no IdP) ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "${GREEN}Add an MCP server in your client with these settings:${NC}"
echo ""
echo -e " Server URL ${YELLOW}http://localhost:8080/mcp${NC}"
echo -e " Connection type ${YELLOW}HTTP${NC} (streamable HTTP)"
echo -e " Authentication ${YELLOW}None${NC} (no OAuth - just a header)"
if [ -n "$key" ]; then
echo -e " Custom header ${YELLOW}X-API-KEY: $key${NC}"
else
echo -e " ${RED}(could not mint a key automatically; is the stack up in apikey mode?)${NC}"
fi
echo ""
echo -e " ${BLUE}No browser redirect, no IdP. The key maps to ${YELLOW}[email protected]${BLUE} and${NC}"
echo -e " ${BLUE}every call is audited as that user. Use this when a client's OAuth can't reach localhost.${NC}"
echo ""
echo -e "${BLUE}Stop:${NC} docker-compose -f docker-compose-keycloak-mcp.yml down -v"
echo ""
}
echo -e "${BLUE}╔════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Stirling PDF + Keycloak MCP Test Environment ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════════════╝${NC}"
echo ""
COMPOSE_UP_ARGS=(-d --build)
RUN_VALIDATE=false
MANUAL=false
APIKEY_MODE=false
while [[ $# -gt 0 ]]; do
case "$1" in
--nobuild)
COMPOSE_UP_ARGS=(-d)
shift
;;
--validate)
RUN_VALIDATE=true
shift
;;
--manual)
MANUAL=true
shift
;;
--apikey)
# API-key manual testing mode (no OAuth/IdP), with a freshly minted key.
APIKEY_MODE=true
MANUAL=true
export MCP_AUTH_MODE=apikey
shift
;;
--license-key)
if [[ -z "${2:-}" ]]; then
echo -e "${RED}Missing value for --license-key${NC}"
exit 1
fi
export PREMIUM_KEY="$2"
shift 2
;;
--license-key=*)
export PREMIUM_KEY="${1#*=}"
shift
;;
-k)
if [[ -z "${2:-}" ]]; then
echo -e "${RED}Missing value for -k${NC}"
exit 1
fi
export PREMIUM_KEY="$2"
shift 2
;;
-h|--help)
echo "Usage: $0 [--nobuild] [--validate] [--manual] [--apikey] [--license-key <KEY>]"
echo ""
echo " --nobuild Skip building images (use existing images)"
echo " --validate Run validate-mcp-test.sh after the stack is up"
echo " --manual Manual testing mode (OAuth): bring the stack up and print"
echo " copy-paste client settings for the OAuth flow."
echo " --apikey Manual testing mode (API key): bring Stirling up in apikey"
echo " mode (no OAuth/IdP), mint a key, and print client settings."
echo " Ideal for clients whose OAuth can't reach localhost."
echo " --license-key <KEY> Premium license key (skips the interactive prompt)"
echo " Equivalent to setting PREMIUM_KEY in the environment."
exit 0
;;
*)
echo -e "${RED}Unknown option: $1${NC}"
exit 1
;;
esac
done
if ! docker info > /dev/null 2>&1; then
echo -e "${RED}✗ Docker is not running${NC}"
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# Keycloak issuer hostname (must resolve on host + containers)
KEYCLOAK_HOST="${KEYCLOAK_HOST:-kubernetes.docker.internal}"
export KEYCLOAK_HOST
# Preflight: the host must resolve the issuer hostname.
if [ "${SKIP_MCP_PREFLIGHT:-false}" != "true" ]; then
if ! curl -sf --connect-timeout 2 --max-time 3 "http://${KEYCLOAK_HOST}:9080/realms/stirling-mcp" >/dev/null 2>&1; then
echo -e "${YELLOW}⚠ Cannot reach http://${KEYCLOAK_HOST}:9080 from this machine yet.${NC}"
echo -e "${YELLOW} That is expected before the stack is up. If validation later fails to${NC}"
echo -e "${YELLOW} resolve the host, add a hosts entry pointing ${KEYCLOAK_HOST} to 127.0.0.1:${NC}"
echo ""
echo -e "${BLUE}Windows:${NC} C:\\Windows\\System32\\drivers\\etc\\hosts"
echo -e "${BLUE}macOS/Linux:${NC} /etc/hosts"
echo ""
echo -e "${GREEN}127.0.0.1 ${KEYCLOAK_HOST}${NC}"
echo ""
fi
fi
# Prompt for license key (optional).
if [ -z "$PREMIUM_KEY" ]; then
echo -e "${YELLOW}Enter license key (press Enter to use default test key):${NC}"
read -r LICENSE_INPUT
if [ -n "$LICENSE_INPUT" ]; then
export PREMIUM_KEY="$LICENSE_INPUT"
echo -e "${GREEN}✓ Using provided license key${NC}"
else
echo -e "${BLUE}Using default test license key${NC}"
fi
echo ""
fi
echo -e "${YELLOW}▶ Starting Keycloak (MCP) containers...${NC}"
docker-compose -f docker-compose-keycloak-mcp.yml up "${COMPOSE_UP_ARGS[@]}" keycloak-mcp-db keycloak-mcp
echo ""
echo -e "${YELLOW}▶ Waiting for Keycloak (MCP)...${NC}"
MAX_WAIT=180
WAITED=0
while [ $WAITED -lt $MAX_WAIT ]; do
if curl -sf http://localhost:9080/realms/stirling-mcp > /dev/null 2>&1; then
echo -e "${GREEN}✓ Keycloak is ready${NC}"
break
fi
echo -n "."
sleep 2
WAITED=$((WAITED + 2))
done
if [ $WAITED -ge $MAX_WAIT ]; then
echo -e "${RED}✗ Keycloak failed to start${NC}"
exit 1
fi
echo ""
echo -e "${YELLOW}▶ Starting Stirling PDF (MCP resource server)...${NC}"
docker-compose -f docker-compose-keycloak-mcp.yml up "${COMPOSE_UP_ARGS[@]}" stirling-pdf-mcp
echo ""
echo -e "${YELLOW}▶ Waiting for Stirling PDF...${NC}"
WAITED=0
while [ $WAITED -lt $MAX_WAIT ]; do
if curl -sf http://localhost:8080/api/v1/info/status 2>/dev/null | grep -q "UP"; then
echo -e "${GREEN}✓ Stirling PDF is ready${NC}"
break
fi
echo -n "."
sleep 2
WAITED=$((WAITED + 2))
done
if [ $WAITED -ge $MAX_WAIT ]; then
echo -e "${RED}✗ Stirling PDF failed to start${NC}"
exit 1
fi
echo ""
echo -e "${GREEN}╔════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ MCP Test Environment Ready! ✓ ║${NC}"
echo -e "${GREEN}╚════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "${BLUE}🔑 Auth mode:${NC} ${GREEN}${MCP_AUTH_MODE:-oauth}${NC}"
echo ""
echo -e "${BLUE}📍 Services:${NC}"
echo -e " Stirling PDF: ${GREEN}http://localhost:8080${NC}"
echo -e " MCP endpoint: ${GREEN}http://localhost:8080/mcp${NC}"
if [ "$APIKEY_MODE" != true ]; then
echo -e " PRM metadata: ${GREEN}http://localhost:8080/.well-known/oauth-protected-resource${NC}"
fi
echo -e " Keycloak Admin: ${GREEN}http://${KEYCLOAK_HOST}:9080/admin${NC} (admin / admin)"
echo ""
echo -e "${BLUE}👤 Test user (exists in Keycloak AND as a Stirling account):${NC}"
echo -e " Email: ${GREEN}[email protected]${NC}"
echo -e " Password: ${GREEN}mcppassword${NC}"
echo ""
if [ "$APIKEY_MODE" != true ]; then
echo -e "${BLUE}👻 Negative-test user (valid Keycloak login, NO Stirling account):${NC}"
echo -e " Email: ${GREEN}[email protected]${NC}"
echo -e " Password: ${GREEN}ghostpassword${NC} (expect HTTP 403 at /mcp)"
echo ""
echo -e "${BLUE}🔐 OAuth clients:${NC}"
echo -e " public (for MCP clients): ${GREEN}mcp-client${NC} (authcode + PKCE, no secret)"
echo -e " confidential (scripts): ${GREEN}mcp-test-client${NC} / ${GREEN}mcp-test-secret${NC}"
echo ""
fi
if [ "$APIKEY_MODE" = true ]; then
echo -e "${BLUE}🧪 Automated validation (apikey):${NC}"
echo -e " bash testing/compose/validate-mcp-apikey.sh"
else
echo -e "${BLUE}🧪 Automated validation (no token 401, valid token 200, ghost 403):${NC}"
echo -e " bash testing/compose/validate-mcp-test.sh"
fi
echo ""
echo -e "${BLUE}🔌 Connect an MCP client: re-run with ${GREEN}--manual${NC} (OAuth) or ${GREEN}--apikey${NC} (header).${NC}"
echo ""
echo -e "${BLUE}📊 View logs:${NC}"
echo -e " docker-compose -f docker-compose-keycloak-mcp.yml logs -f"
echo ""
echo -e "${BLUE}⏹ Stop:${NC}"
echo -e " docker-compose -f docker-compose-keycloak-mcp.yml down -v"
echo ""
if [ "$RUN_VALIDATE" = true ]; then
echo -e "${YELLOW}▶ Running validation...${NC}"
echo ""
if [ "$APIKEY_MODE" = true ]; then
bash "$SCRIPT_DIR/validate-mcp-apikey.sh"
else
bash "$SCRIPT_DIR/validate-mcp-test.sh"
fi
fi
if [ "$APIKEY_MODE" = true ]; then
print_manual_panel_apikey
elif [ "$MANUAL" = true ]; then
print_manual_panel
else
echo -e "${BLUE}💡 Tip:${NC} re-run with ${GREEN}--manual${NC} (OAuth) or ${GREEN}--apikey${NC} (API-key header) for client setup."
echo ""
fi
+134
View File
@@ -0,0 +1,134 @@
#!/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
+397
View File
@@ -0,0 +1,397 @@
#!/bin/bash
# Validate the Keycloak MCP OAuth2 resource-server path end-to-end against a real Keycloak.
# Runs every check (no set -e) and exits non-zero if anything failed.
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
KEYCLOAK_HOST="${KEYCLOAK_HOST:-kubernetes.docker.internal}"
REALM="stirling-mcp"
CLIENT_ID="mcp-test-client"
CLIENT_SECRET="mcp-test-secret"
MCP_URL="http://localhost:8080/mcp"
PRM_URL="http://localhost:8080/.well-known/oauth-protected-resource"
PASS=0
FAIL=0
pass() { echo -e "${GREEN}${NC} $1"; PASS=$((PASS + 1)); }
fail() { echo -e "${RED}$1${NC}"; FAIL=$((FAIL + 1)); }
echo -e "${BLUE}╔════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Validating MCP Test Environment ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════════════╝${NC}"
echo ""
# Pick a Keycloak base URL that resolves from this host (both share KC_HOSTNAME issuer).
KC_BASE="http://${KEYCLOAK_HOST}:9080"
if ! curl -sf --max-time 3 "$KC_BASE/realms/$REALM" >/dev/null 2>&1; then
KC_BASE="http://localhost:9080"
fi
TOKEN_URL="$KC_BASE/realms/$REALM/protocol/openid-connect/token"
echo -e "${BLUE}Using Keycloak base:${NC} $KC_BASE"
echo ""
# liveness
echo -e "${YELLOW}[0] Liveness${NC}"
if curl -sf "$KC_BASE/realms/$REALM" > /dev/null 2>&1; then
pass "Keycloak realm '$REALM' reachable"
else
fail "Keycloak realm '$REALM' not reachable - is the stack up?"
fi
if curl -sf "$KC_BASE/realms/$REALM/.well-known/openid-configuration" > /dev/null 2>&1; then
pass "Keycloak OIDC discovery reachable"
else
fail "Keycloak OIDC discovery not reachable"
fi
if curl -sf http://localhost:8080/api/v1/info/status 2>/dev/null | grep -q "UP"; then
pass "Stirling PDF is UP"
else
fail "Stirling PDF is not UP"
fi
echo ""
# protected-resource metadata (RFC 9728)
echo -e "${YELLOW}[1] Protected-resource metadata (RFC 9728)${NC}"
PRM=$(curl -s -o /tmp/mcp_prm.json -w "%{http_code}" "$PRM_URL")
PRM_BODY=$(cat /tmp/mcp_prm.json 2>/dev/null)
if [ "$PRM" = "200" ]; then
pass "GET $PRM_URL -> 200 (publicly discoverable)"
else
fail "GET $PRM_URL -> $PRM (expected 200)"
fi
if echo "$PRM_BODY" | grep -q '"resource"'; then
pass "metadata advertises a 'resource' id"
else
fail "metadata missing 'resource' id"
fi
if echo "$PRM_BODY" | grep -q "realms/$REALM"; then
pass "metadata points clients at the Keycloak authorization server"
else
fail "metadata missing the Keycloak authorization server (realms/$REALM)"
fi
if echo "$PRM_BODY" | grep -q "mcp.tools"; then
pass "metadata advertises mcp.tools scopes"
else
fail "metadata missing mcp.tools scopes"
fi
echo ""
# unauthenticated access is rejected
echo -e "${YELLOW}[2] Unauthenticated requests are rejected${NC}"
RPC_LIST='{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
NO_TOKEN=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" \
-H "Content-Type: application/json" -d "$RPC_LIST")
if [ "$NO_TOKEN" = "401" ]; then
pass "POST /mcp with no token -> 401"
else
fail "POST /mcp with no token -> $NO_TOKEN (expected 401)"
fi
BAD_TOKEN=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer not-a-real-jwt" -d "$RPC_LIST")
if [ "$BAD_TOKEN" = "401" ]; then
pass "POST /mcp with garbage token -> 401"
else
fail "POST /mcp with garbage token -> $BAD_TOKEN (expected 401)"
fi
echo ""
# fetch a real user token from Keycloak (password grant)
echo -e "${YELLOW}[3] Fetch a real access token from Keycloak (mcpuser)${NC}"
get_token() {
# $1 username, $2 password -> access_token (empty on failure)
curl -s -X POST "$TOKEN_URL" \
--data-urlencode "grant_type=password" \
--data-urlencode "client_id=$CLIENT_ID" \
--data-urlencode "client_secret=$CLIENT_SECRET" \
--data-urlencode "username=$1" \
--data-urlencode "password=$2" \
--data-urlencode "scope=openid email mcp.tools.read mcp.tools.write" \
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p'
}
USER_TOKEN=$(get_token "[email protected]" "mcppassword")
if [ -n "$USER_TOKEN" ]; then
pass "Obtained access token for [email protected]"
else
fail "Could not obtain access token for [email protected] (check Keycloak/client)"
fi
echo ""
# authenticated tools/list (account-binding succeeds)
echo -e "${YELLOW}[4] Authenticated MCP access (provisioned user)${NC}"
if [ -n "$USER_TOKEN" ]; then
LIST_CODE=$(curl -s -o /tmp/mcp_list.json -w "%{http_code}" -X POST "$MCP_URL" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $USER_TOKEN" -d "$RPC_LIST")
LIST_BODY=$(cat /tmp/mcp_list.json 2>/dev/null)
if [ "$LIST_CODE" = "200" ]; then
pass "POST /mcp tools/list with valid token -> 200"
else
fail "POST /mcp tools/list with valid token -> $LIST_CODE (expected 200)"
fi
for t in stirling_describe_operation stirling_convert stirling_pages stirling_misc stirling_security; do
if echo "$LIST_BODY" | grep -q "\"$t\""; then
pass "tools/list advertises $t"
else
fail "tools/list missing $t"
fi
done
else
fail "Skipping authenticated checks - no user token"
fi
echo ""
# tools/call stirling_describe_operation
echo -e "${YELLOW}[5] Deep schema via stirling_describe_operation${NC}"
if [ -n "$USER_TOKEN" ]; then
OP=$(echo "$LIST_BODY" | grep -oE '"enum":\[[^]]+\]' | head -1 | grep -oE '"[^"]+"' | sed -n '2p' | tr -d '"')
if [ -n "$OP" ]; then
echo -e " ${BLUE}using operation:${NC} $OP"
DESC_RPC="{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"stirling_describe_operation\",\"arguments\":{\"operation\":\"$OP\"}}}"
DESC_CODE=$(curl -s -o /tmp/mcp_desc.json -w "%{http_code}" -X POST "$MCP_URL" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $USER_TOKEN" -d "$DESC_RPC")
DESC_BODY=$(cat /tmp/mcp_desc.json 2>/dev/null)
if [ "$DESC_CODE" = "200" ] && echo "$DESC_BODY" | grep -q "parametersSchema"; then
pass "describe_operation('$OP') returned a parameters schema"
else
fail "describe_operation('$OP') -> $DESC_CODE (no parametersSchema in body)"
fi
else
fail "Could not extract an operation id from tools/list (no enabled ops?)"
fi
else
fail "Skipping describe check - no user token"
fi
echo ""
# account-binding: valid Keycloak user with no Stirling account -> 403
echo -e "${YELLOW}[6] Account-binding rejects users without a Stirling account${NC}"
GHOST_TOKEN=$(get_token "[email protected]" "ghostpassword")
if [ -n "$GHOST_TOKEN" ]; then
pass "Obtained a valid Keycloak token for [email protected]"
GHOST_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $GHOST_TOKEN" -d "$RPC_LIST")
if [ "$GHOST_CODE" = "403" ]; then
pass "Valid token, no Stirling account -> 403 (account-binding enforced)"
else
fail "ghost user -> $GHOST_CODE (expected 403)"
fi
else
fail "Could not obtain a token for [email protected] (cannot test account-binding)"
fi
echo ""
# hardening: bad tokens & endpoint isolation
echo -e "${YELLOW}[7] Hardening - bad tokens & endpoint isolation${NC}"
b64url() { printf '%s' "$1" | base64 | tr '+/' '-_' | tr -d '='; }
# unsigned alg:none token must be rejected
NONE_JWT="$(b64url '{"alg":"none","typ":"JWT"}').$(b64url '{"sub":"mcpuser","iss":"http://kubernetes.docker.internal:9080/realms/stirling-mcp","aud":"http://localhost:8080/mcp","email":"[email protected]","scope":"mcp.tools.read mcp.tools.write","exp":9999999999}')."
NONE_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" \
-H "Content-Type: application/json" -H "Authorization: Bearer $NONE_JWT" -d "$RPC_LIST")
if [ "$NONE_CODE" = "401" ] || [ "$NONE_CODE" = "400" ]; then
pass "Unsigned alg:none token -> $NONE_CODE (rejected; a real signature is required)"
else
fail "alg:none token -> $NONE_CODE (expected 400/401)"
fi
# valid token minted for a different audience must be rejected (RFC 8707)
WRONG_AUD=$(curl -s -X POST "$TOKEN_URL" \
--data-urlencode "grant_type=password" \
--data-urlencode "client_id=other-client" \
--data-urlencode "[email protected]" \
--data-urlencode "password=mcppassword" \
--data-urlencode "scope=openid email" \
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
if [ -n "$WRONG_AUD" ]; then
WA_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" \
-H "Content-Type: application/json" -H "Authorization: Bearer $WRONG_AUD" -d "$RPC_LIST")
if [ "$WA_CODE" = "401" ]; then
pass "Valid token, wrong audience -> 401 (RFC 8707 audience binding)"
else
fail "wrong-audience token -> $WA_CODE (expected 401)"
fi
else
fail "Could not mint a wrong-audience token from other-client"
fi
if [ -n "$USER_TOKEN" ]; then
# the /mcp guard covers every method: GET without a token -> 401
GET_NOAUTH=$(curl -s -o /dev/null -w "%{http_code}" -X GET "$MCP_URL")
if [ "$GET_NOAUTH" = "401" ]; then
pass "GET /mcp with no token -> 401 (chain guards all methods, not just POST)"
else
fail "GET /mcp no token -> $GET_NOAUTH (expected 401)"
fi
# unknown JSON-RPC method is refused with -32601
UNK=$(curl -s -X POST "$MCP_URL" -H "Content-Type: application/json" \
-H "Authorization: Bearer $USER_TOKEN" \
-d '{"jsonrpc":"2.0","id":9,"method":"admin/deleteEverything"}')
if echo "$UNK" | grep -q "32601"; then
pass "Unknown JSON-RPC method -> error -32601 (not dispatched)"
else
fail "Unknown method not rejected as expected: $UNK"
fi
# isolation: the MCP token must not unlock admin endpoints
ADM_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X GET \
"http://localhost:8080/api/v1/admin/settings" \
-H "Authorization: Bearer $USER_TOKEN")
if [ "$ADM_CODE" = "401" ] || [ "$ADM_CODE" = "403" ] || [ "$ADM_CODE" = "302" ]; then
pass "MCP token on /api/v1/admin/settings -> $ADM_CODE (no cross-endpoint access)"
else
fail "MCP token reached an admin endpoint -> $ADM_CODE (expected 401/403/302)"
fi
else
fail "Skipping method/isolation checks - no user token"
fi
echo ""
# regression checks
echo -e "${YELLOW}[8] Regression checks${NC}"
if [ -n "$USER_TOKEN" ]; then
# a PDF category tool must return an honest error (isError), not a fake success
CAT=$(curl -s -X POST "$MCP_URL" -H "Content-Type: application/json" \
-H "Authorization: Bearer $USER_TOKEN" \
-d '{"jsonrpc":"2.0","id":81,"method":"tools/call","params":{"name":"stirling_security","arguments":{"operation":"add-password"}}}')
if echo "$CAT" | grep -q '"isError":true'; then
pass "category tool returns isError, not a fake success"
else
fail "category tool did not return isError: $CAT"
fi
# malformed JSON -> JSON-RPC parse error (-32700) envelope
MAL=$(curl -s -X POST "$MCP_URL" -H "Content-Type: application/json" \
-H "Authorization: Bearer $USER_TOKEN" -d '{ not valid json ')
if echo "$MAL" | grep -q "32700"; then
pass "malformed JSON -> -32700 envelope"
else
fail "malformed JSON not enveloped as -32700: $MAL"
fi
# valid JSON but wrong shape -> invalid request (-32600)
WS=$(curl -s -X POST "$MCP_URL" -H "Content-Type: application/json" \
-H "Authorization: Bearer $USER_TOKEN" -d '{"hello":"world"}')
if echo "$WS" | grep -q "32600"; then
pass "wrong-shape JSON -> -32600"
else
fail "wrong-shape JSON wrong error code: $WS"
fi
# initialize echoes the client protocolVersion (negotiation)
INIT=$(curl -s -X POST "$MCP_URL" -H "Content-Type: application/json" \
-H "Authorization: Bearer $USER_TOKEN" \
-d '{"jsonrpc":"2.0","id":82,"method":"initialize","params":{"protocolVersion":"2025-03-26"}}')
if echo "$INIT" | grep -q '"protocolVersion":"2025-03-26"'; then
pass "initialize negotiates the client's protocolVersion"
else
fail "initialize did not echo client protocolVersion: $INIT"
fi
else
fail "Skipping regression checks - no user token"
fi
# chunked body (no Content-Length) over the cap -> 413, pre-auth
CHUNK=$( { printf '{"jsonrpc":"2.0","id":1,"method":"x","params":"'; head -c 300000 /dev/zero | tr '\0' A; printf '"}'; } \
| curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" \
-H "Content-Type: application/json" -H "Transfer-Encoding: chunked" --data-binary @- )
if [ "$CHUNK" = "413" ]; then
pass "chunked oversized body -> 413"
else
fail "chunked oversized body -> $CHUNK (expected 413)"
fi
# behind a proxy, the 401 resource_metadata URL must honour X-Forwarded-*
WWW=$(curl -s -D - -o /dev/null -X POST "$MCP_URL" -H "Content-Type: application/json" \
-H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: mcp.example.com" -d "$RPC_LIST" \
| tr -d '\r' | grep -i "^www-authenticate:")
if echo "$WWW" | grep -q "https://mcp.example.com/.well-known/oauth-protected-resource"; then
pass "401 resource_metadata honours X-Forwarded-*"
else
fail "resource_metadata ignored forwarded headers: $WWW"
fi
echo ""
# CORS for browser-based MCP clients
echo -e "${YELLOW}[9] CORS (browser MCP clients)${NC}"
ORIGIN="http://127.0.0.1:6274"
PF=$(curl -s -D - -o /dev/null -X OPTIONS "$MCP_URL" \
-H "Origin: $ORIGIN" -H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: authorization,content-type" | tr -d '\r')
PF_CODE=$(printf '%s' "$PF" | sed -n 's#^HTTP/[0-9.]* \([0-9][0-9][0-9]\).*#\1#p' | head -1)
if printf '%s' "$PF_CODE" | grep -qE '^2'; then
pass "OPTIONS /mcp preflight -> $PF_CODE (not 401 - browsers can call /mcp)"
else
fail "OPTIONS /mcp preflight -> $PF_CODE (expected 2xx)"
fi
if printf '%s' "$PF" | grep -qi "access-control-allow-origin"; then
pass "preflight advertises Access-Control-Allow-Origin"
else
fail "preflight missing Access-Control-Allow-Origin"
fi
if curl -s -D - -o /dev/null -H "Origin: $ORIGIN" "$PRM_URL" | tr -d '\r' | grep -qi "access-control-allow-origin"; then
pass "protected-resource metadata sends Access-Control-Allow-Origin (browser-readable)"
else
fail "metadata missing Access-Control-Allow-Origin (browser can't read discovery)"
fi
echo ""
# real MCP client (official @modelcontextprotocol/sdk)
echo -e "${YELLOW}[10] Real MCP SDK client (not just curl)${NC}"
CHECK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/mcp-client-check"
if ! command -v node >/dev/null 2>&1; then
echo -e " ${YELLOW}(node not installed - skipping real-client check)${NC}"
elif [ -z "$USER_TOKEN" ]; then
fail "skipping real-client check - no user token"
else
if [ ! -d "$CHECK_DIR/node_modules" ]; then
echo " installing MCP SDK (first run)..."
(cd "$CHECK_DIR" && npm install --silent --no-fund --no-audit >/dev/null 2>&1)
fi
if MODE=oauth MCP_URL="$MCP_URL" MCP_BEARER="$USER_TOKEN" node "$CHECK_DIR/check.mjs"; then
pass "official MCP SDK client connected + validated over streamable HTTP (oauth)"
else
fail "official MCP SDK client could not validate the server (oauth)"
fi
fi
echo ""
# real operation execution (rotate a real PDF, inline)
echo -e "${YELLOW}[11] Real operation execution (rotate-pdf, inline file)${NC}"
SAMPLE_PDF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../app/common/src/test/resources/example.pdf"
if [ -z "$USER_TOKEN" ]; then
fail "skipping execution check - no user token"
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 "Authorization: Bearer $USER_TOKEN" -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 ""
# summary
rm -f /tmp/mcp_prm.json /tmp/mcp_list.json /tmp/mcp_desc.json 2>/dev/null
echo -e "${BLUE}────────────────────────────────────────────────────${NC}"
if [ "$FAIL" -eq 0 ]; then
echo -e "${GREEN}All MCP environment checks passed! ($PASS passed)${NC}"
exit 0
else
echo -e "${RED}MCP validation failed: $FAIL failed, $PASS passed.${NC}"
exit 1
fi