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
@@ -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));
}
}