mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Add MCP server with OAuth/API-key auth (#6570)
Adds an optional MCP server (proprietary module) that exposes Stirling's PDF operations and AI capabilities to MCP clients. Off by default, zero footprint when disabled. ### What - New `/mcp` endpoint: streamable-HTTP + JSON-RPC 2.0; 8 tools (describe_operation, pages/convert/misc/security category tools, AI, upload, download). - Runs real operations over an internal loopback; results returned inline as base64 (small) or by fileId (large). ### Auth (two modes) - OAuth2 resource server: RFC 9728 protected-resource metadata, RFC 8707 audience binding, JWKS, `mcp.tools.read/write` scopes; binds each token to a provisioned Stirling account. - API-key mode: reuses Stirling per-user `X-API-KEY` (no IdP needed). ### Security - Per-user file ownership in FileStorage: async/queued writes scoped to the submitting user; legacy/owner-less files stay readable. - Admin allow/block list controls which operations are exposed. - Python engine gated behind a shared secret (`X-Engine-Auth`). - MCP filter chain is isolated and cannot weaken the main app's security. - Hardened: no upstream error-body leakage, log injection sanitized, fileId path/sidecar enumeration blocked. ### Config / footprint - Disabled by default (`mcp.enabled=false`); all beans `@ConditionalOnProperty`. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
+100
-23
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+44
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -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));
|
||||
|
||||
+107
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
+41
-5
@@ -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);
|
||||
}
|
||||
}
|
||||
+217
@@ -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);
|
||||
}
|
||||
+266
@@ -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);
|
||||
}
|
||||
}
|
||||
+38
@@ -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;
|
||||
}
|
||||
}
|
||||
+22
@@ -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
|
||||
}
|
||||
}
|
||||
+178
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+204
@@ -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;
|
||||
}
|
||||
}
|
||||
+36
@@ -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);
|
||||
}
|
||||
}
|
||||
+14
@@ -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();
|
||||
}
|
||||
}
|
||||
+18
@@ -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);
|
||||
}
|
||||
}
|
||||
+85
@@ -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;
|
||||
}
|
||||
}
|
||||
+44
@@ -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();
|
||||
}
|
||||
}
|
||||
+76
@@ -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));
|
||||
}
|
||||
}
|
||||
+128
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+262
@@ -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;
|
||||
}
|
||||
}
|
||||
+115
@@ -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));
|
||||
}
|
||||
}
|
||||
+154
@@ -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());
|
||||
}
|
||||
}
|
||||
+84
@@ -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);
|
||||
}
|
||||
}
|
||||
+255
@@ -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;
|
||||
}
|
||||
}
|
||||
+80
@@ -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;
|
||||
}
|
||||
}
|
||||
+43
@@ -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);
|
||||
}
|
||||
}
|
||||
+149
@@ -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);
|
||||
}
|
||||
}
|
||||
+41
@@ -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;
|
||||
}
|
||||
}
|
||||
+113
@@ -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 + "'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -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;
|
||||
}
|
||||
}
|
||||
+40
@@ -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;
|
||||
}
|
||||
}
|
||||
+41
@@ -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;
|
||||
}
|
||||
}
|
||||
+96
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -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 =
|
||||
|
||||
+22
-3
@@ -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());
|
||||
|
||||
+23
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+99
@@ -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");
|
||||
}
|
||||
}
|
||||
+238
@@ -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());
|
||||
}
|
||||
}
|
||||
+185
@@ -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);
|
||||
}
|
||||
}
|
||||
+59
@@ -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");
|
||||
}
|
||||
}
|
||||
+138
@@ -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();
|
||||
}
|
||||
}
|
||||
+148
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -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));
|
||||
}
|
||||
}
|
||||
+61
@@ -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());
|
||||
}
|
||||
}
|
||||
+337
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+121
@@ -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"));
|
||||
}
|
||||
}
|
||||
+55
@@ -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"));
|
||||
}
|
||||
}
|
||||
+146
@@ -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");
|
||||
}
|
||||
}
|
||||
+43
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user