mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Merge branch 'main' into SaaS
This commit is contained in:
@@ -3,3 +3,12 @@
|
||||
# intentionally in #6150 so engine/.env has a working default, with real
|
||||
# credentials overridden via engine/.env.local.
|
||||
engine/.env:generic-api-key:41
|
||||
|
||||
# MCP test fixtures / harness - no real secrets:
|
||||
# - test-only API key constant in an integration test
|
||||
# - JDBC URL + throwaway Keycloak creds in the local test compose
|
||||
# - placeholder / shell-variable Bearer headers in curl-based validation scripts
|
||||
app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpApiKeyIntegrationTest.java:generic-api-key:40
|
||||
testing/compose/docker-compose-keycloak-mcp.yml:generic-api-key:25
|
||||
testing/compose/validate-mcp-apikey.sh:curl-auth-header:73
|
||||
testing/compose/validate-mcp-test.sh:curl-auth-header:92
|
||||
|
||||
@@ -212,3 +212,55 @@ tasks:
|
||||
desc: "Stop the SAML keycloak test environment"
|
||||
cmds:
|
||||
- docker compose -f testing/compose/docker-compose-keycloak-saml.yml down -v
|
||||
|
||||
mcp:up:
|
||||
desc: "Start the MCP keycloak test environment (Stirling as OAuth resource server)"
|
||||
summary: |
|
||||
Brings up Keycloak (OAuth authorization server) + Stirling configured as an
|
||||
MCP resource server, then you can exercise /mcp with real Keycloak tokens.
|
||||
Set LICENSE_KEY=<KEY> to skip the interactive license prompt:
|
||||
task e2e:mcp:up LICENSE_KEY=abc123
|
||||
Pass extra flags via -- :
|
||||
task e2e:mcp:up -- --validate --nobuild
|
||||
ignore_error: true
|
||||
cmds:
|
||||
- bash testing/compose/start-mcp-test.sh {{if .LICENSE_KEY}}--license-key "{{.LICENSE_KEY}}"{{end}} {{.CLI_ARGS}}
|
||||
|
||||
mcp:manual:
|
||||
desc: "Start the MCP keycloak test env in manual mode (prints URLs + a live token for your client)"
|
||||
summary: |
|
||||
Brings the stack up and prints copy-paste URLs/commands plus a freshly minted
|
||||
access token so you can drive your own MCP client (Inspector, curl, ...).
|
||||
task e2e:mcp:manual LICENSE_KEY=<your-license-key>
|
||||
Add --nobuild if the images are already built:
|
||||
task e2e:mcp:manual LICENSE_KEY=<your-license-key> -- --nobuild
|
||||
ignore_error: true
|
||||
cmds:
|
||||
- bash testing/compose/start-mcp-test.sh --manual {{if .LICENSE_KEY}}--license-key "{{.LICENSE_KEY}}"{{end}} {{.CLI_ARGS}}
|
||||
|
||||
mcp:apikey:
|
||||
desc: "Start the MCP test env in API-KEY manual mode (no OAuth/IdP): mints a key + prints client settings"
|
||||
summary: |
|
||||
Brings Stirling up in apikey auth mode and prints copy-paste client settings with a freshly
|
||||
minted X-API-KEY - ideal for clients whose OAuth layer can't reach localhost.
|
||||
task e2e:mcp:apikey LICENSE_KEY=<your-license-key>
|
||||
Add --nobuild if images are already built:
|
||||
task e2e:mcp:apikey LICENSE_KEY=<your-license-key> -- --nobuild
|
||||
ignore_error: true
|
||||
cmds:
|
||||
- bash testing/compose/start-mcp-test.sh --apikey {{if .LICENSE_KEY}}--license-key "{{.LICENSE_KEY}}"{{end}} {{.CLI_ARGS}}
|
||||
|
||||
mcp:validate:
|
||||
desc: "Validate the running MCP keycloak test environment end-to-end (oauth mode + real MCP SDK client)"
|
||||
cmds:
|
||||
- bash testing/compose/validate-mcp-test.sh
|
||||
|
||||
mcp:validate-apikey:
|
||||
desc: "Validate the MCP server in API-KEY auth mode (mints a key + real MCP SDK client), then restore oauth"
|
||||
cmds:
|
||||
- bash testing/compose/validate-mcp-apikey.sh
|
||||
|
||||
mcp:down:
|
||||
desc: "Stop the MCP keycloak test environment"
|
||||
cmds:
|
||||
- docker compose -f testing/compose/docker-compose-keycloak-mcp.yml down -v
|
||||
|
||||
@@ -327,19 +327,19 @@ tasks:
|
||||
|
||||
test:
|
||||
desc: "Run tests"
|
||||
deps: [install]
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx vitest run --root editor
|
||||
|
||||
test:watch:
|
||||
desc: "Run tests in watch mode"
|
||||
deps: [install]
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx vitest --watch --root editor
|
||||
|
||||
test:coverage:
|
||||
desc: "Run tests with coverage (one-shot; CI-friendly)."
|
||||
deps: [install]
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
# `vitest run` makes this CI-safe (the bare `vitest` form enters watch
|
||||
# mode). Explicit reporter list because v8 + json-summary is what the
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Curated registry of agent capabilities the MCP server (Java side) is allowed to publish.
|
||||
|
||||
Internal sub-agents (currently only ``ExecutionPlanningAgent`` - it lives behind the orchestrator
|
||||
and has no end-user-facing API surface) are intentionally absent. The handoff spec calls for
|
||||
"user-facing" capabilities only; revisit this list when adding a new agent and ask whether MCP
|
||||
clients should be able to invoke it directly.
|
||||
|
||||
The Java side pulls ``/api/v1/agents/capabilities`` once at boot and again every few minutes; the
|
||||
manifest is the authoritative source for the ``stirling_ai`` MCP tool's operation enum.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from stirling.contracts import (
|
||||
AgentDraftRequest,
|
||||
AgentExecutionRequest,
|
||||
AgentRevisionRequest,
|
||||
Evidence,
|
||||
FolioManifest,
|
||||
PdfCommentRequest,
|
||||
PdfEditRequest,
|
||||
PdfQuestionRequest,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentCapability:
|
||||
"""One row in the curated manifest.
|
||||
|
||||
Attributes:
|
||||
id: stable capability identifier (used as the operation enum value in
|
||||
``stirling_ai``). Avoid renaming - clients persist these.
|
||||
description: one-line human-friendly summary shown inside MCP tool descriptions.
|
||||
input_model: Pydantic class whose JSON Schema becomes the capability's
|
||||
``input_schema``. Auto-derived; do not hand-write schemas.
|
||||
mode: ``"sync"`` if the capability returns content inline, ``"async"`` if it returns a
|
||||
plan that Java executes via the job pipeline.
|
||||
required_scope: coarse OAuth scope. ``mcp.tools.read`` for pure-read capabilities
|
||||
(Q&A, audits) and ``mcp.tools.write`` for anything that yields a plan / file.
|
||||
route: HTTP path Java POSTs to when invoking this capability. When a capability does
|
||||
not have a stable per-agent route yet, use the generic invoke fallback at
|
||||
``/api/v1/agents/invoke/{id}``.
|
||||
"""
|
||||
|
||||
id: str
|
||||
description: str
|
||||
input_model: type[BaseModel]
|
||||
mode: str
|
||||
required_scope: str
|
||||
route: str
|
||||
|
||||
|
||||
EXPOSED_CAPABILITIES: list[AgentCapability] = [
|
||||
AgentCapability(
|
||||
id="pdf-question-answer",
|
||||
description="Answer a natural-language question about a PDF document.",
|
||||
input_model=PdfQuestionRequest,
|
||||
mode="sync",
|
||||
required_scope="mcp.tools.read",
|
||||
route="/api/v1/pdf-question",
|
||||
),
|
||||
AgentCapability(
|
||||
id="pdf-edit-plan",
|
||||
description=(
|
||||
"Produce an edit plan (a structured sequence of PDF operations) from a"
|
||||
" natural-language edit request. The plan is executed by Java through the job"
|
||||
" pipeline; this capability does not modify files itself."
|
||||
),
|
||||
input_model=PdfEditRequest,
|
||||
mode="async",
|
||||
required_scope="mcp.tools.write",
|
||||
route="/api/v1/pdf-edit",
|
||||
),
|
||||
AgentCapability(
|
||||
id="agent-draft",
|
||||
description=(
|
||||
"Draft a structured agent specification from a free-text description of the task the user wants automated."
|
||||
),
|
||||
input_model=AgentDraftRequest,
|
||||
mode="sync",
|
||||
required_scope="mcp.tools.read",
|
||||
route="/api/v1/ai/agents/draft",
|
||||
),
|
||||
AgentCapability(
|
||||
id="agent-revise",
|
||||
description=("Revise an existing draft agent specification based on user feedback or constraint changes."),
|
||||
input_model=AgentRevisionRequest,
|
||||
mode="sync",
|
||||
required_scope="mcp.tools.read",
|
||||
route="/api/v1/ai/agents/revise",
|
||||
),
|
||||
AgentCapability(
|
||||
id="math-audit-examine",
|
||||
description=(
|
||||
"Examine a folio manifest of financial / numeric documents and surface the"
|
||||
" evidence that needs to be checked for arithmetic consistency."
|
||||
),
|
||||
input_model=FolioManifest,
|
||||
mode="sync",
|
||||
required_scope="mcp.tools.read",
|
||||
route="/api/v1/ai/math-auditor-agent/examine",
|
||||
),
|
||||
AgentCapability(
|
||||
id="math-audit-deliberate",
|
||||
description=(
|
||||
"Render a deliberated verdict on a single piece of evidence the examine step"
|
||||
" surfaced (does the arithmetic check out, with what caveats)."
|
||||
),
|
||||
input_model=Evidence,
|
||||
mode="sync",
|
||||
required_scope="mcp.tools.read",
|
||||
route="/api/v1/ai/math-auditor-agent/deliberate",
|
||||
),
|
||||
AgentCapability(
|
||||
id="pdf-comment-generate",
|
||||
description="Generate inline review comments for a PDF document.",
|
||||
input_model=PdfCommentRequest,
|
||||
mode="sync",
|
||||
required_scope="mcp.tools.read",
|
||||
route="/api/v1/pdf-comment/generate",
|
||||
),
|
||||
AgentCapability(
|
||||
id="agent-next-action",
|
||||
description=(
|
||||
"Decide the next execution step for an in-progress agent workflow. Returns a"
|
||||
" ToolCall, Completed, or CannotContinue action."
|
||||
),
|
||||
input_model=AgentExecutionRequest,
|
||||
mode="sync",
|
||||
required_scope="mcp.tools.read",
|
||||
route="/api/v1/agents/next-action",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def manifest_payload() -> dict[str, Any]:
|
||||
"""Serialize the curated registry to the wire shape consumed by Java.
|
||||
|
||||
Schema is derived from ``input_model.model_json_schema()`` so we never hand-write JSON
|
||||
Schema - the Pydantic model is the single source of truth.
|
||||
"""
|
||||
items: list[dict[str, Any]] = []
|
||||
for cap in EXPOSED_CAPABILITIES:
|
||||
items.append(
|
||||
{
|
||||
"id": cap.id,
|
||||
"description": cap.description,
|
||||
"input_schema": cap.input_model.model_json_schema(),
|
||||
"mode": cap.mode,
|
||||
"required_scope": cap.required_scope,
|
||||
"route": cap.route,
|
||||
}
|
||||
)
|
||||
return {"version": 1, "capabilities": items}
|
||||
@@ -18,8 +18,10 @@ from stirling.agents import (
|
||||
)
|
||||
from stirling.agents.ledger import MathAuditorAgent
|
||||
from stirling.agents.pdf_comment import PdfCommentAgent
|
||||
from stirling.api.engine_auth import EngineSharedSecretMiddleware
|
||||
from stirling.api.middleware import UserIdMiddleware
|
||||
from stirling.api.routes import (
|
||||
agent_capabilities_router,
|
||||
agent_draft_router,
|
||||
document_router,
|
||||
execution_router,
|
||||
@@ -115,6 +117,7 @@ async def lifespan(fast_api: FastAPI):
|
||||
|
||||
app = FastAPI(title="Stirling AI Engine", lifespan=lifespan, version="0.1.0")
|
||||
app.add_middleware(UserIdMiddleware)
|
||||
app.add_middleware(EngineSharedSecretMiddleware)
|
||||
app.include_router(orchestrator_router)
|
||||
app.include_router(pdf_edit_router)
|
||||
app.include_router(pdf_question_router)
|
||||
@@ -123,6 +126,7 @@ app.include_router(execution_router)
|
||||
app.include_router(document_router)
|
||||
app.include_router(ledger_router)
|
||||
app.include_router(pdf_comments_router)
|
||||
app.include_router(agent_capabilities_router)
|
||||
|
||||
|
||||
@app.get("/health", response_model=HealthResponse)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Shared-secret middleware that locks the engine to the trusted Java backend.
|
||||
|
||||
Config (resolved via :class:`stirling.config.AppSettings`/pydantic-settings):
|
||||
``STIRLING_ENGINE_SHARED_SECRET`` - non-public routes need ``X-Engine-Auth`` or 401.
|
||||
``STIRLING_ENGINE_REQUIRE_AUTH`` - fail closed with 503 when truthy and no secret is set.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from stirling.config import load_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_HEADER = "X-Engine-Auth"
|
||||
|
||||
# Public paths (liveness + docs); everything else needs the secret when configured.
|
||||
_PUBLIC_PREFIXES: tuple[str, ...] = (
|
||||
"/health",
|
||||
"/docs",
|
||||
"/redoc",
|
||||
"/openapi.json",
|
||||
)
|
||||
|
||||
|
||||
class EngineSharedSecretMiddleware(BaseHTTPMiddleware):
|
||||
"""Reject non-public requests lacking the shared secret.
|
||||
|
||||
Non-public path: secret set -> require matching X-Engine-Auth (else 401); else require flag
|
||||
truthy -> 503 (fail-closed); else allow through.
|
||||
|
||||
Secret/require values come from :class:`stirling.config.AppSettings` by default; tests can
|
||||
pass them explicitly to avoid touching the lru-cached settings.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app: ASGIApp,
|
||||
public_prefixes: Iterable[str] = _PUBLIC_PREFIXES,
|
||||
*,
|
||||
secret: str | None = None,
|
||||
require: bool | None = None,
|
||||
) -> None:
|
||||
super().__init__(app)
|
||||
self._public_prefixes = tuple(public_prefixes)
|
||||
if secret is None or require is None:
|
||||
settings = load_settings()
|
||||
if secret is None:
|
||||
secret = settings.engine_shared_secret
|
||||
if require is None:
|
||||
require = settings.engine_require_auth
|
||||
self._secret = secret or ""
|
||||
self._require = bool(require)
|
||||
if self._secret:
|
||||
logger.info(
|
||||
"Engine shared-secret enforcement ENABLED: non-public routes require a valid %s"
|
||||
" header (constant-time compared).",
|
||||
_HEADER,
|
||||
)
|
||||
elif self._require:
|
||||
logger.error(
|
||||
"STIRLING_ENGINE_REQUIRE_AUTH is enabled but STIRLING_ENGINE_SHARED_SECRET is not"
|
||||
" set - the engine will REFUSE every non-public request (HTTP 503, fail-closed)"
|
||||
" until a shared secret is configured.",
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"STIRLING_ENGINE_SHARED_SECRET not set - engine shared-secret enforcement is"
|
||||
" DISABLED. The AI and document routes then trust the caller-supplied X-User-Id"
|
||||
" header alone. Set this secret (and STIRLING_ENGINE_REQUIRE_AUTH=true) in any"
|
||||
" deployment that exposes the engine beyond localhost.",
|
||||
)
|
||||
|
||||
def _is_public(self, path: str) -> bool:
|
||||
return any(path == p or path.startswith(p + "/") for p in self._public_prefixes)
|
||||
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
if not self._is_public(request.url.path):
|
||||
if self._secret:
|
||||
offered = request.headers.get(_HEADER) or ""
|
||||
# Constant-time compare to avoid timing leaks.
|
||||
if not hmac.compare_digest(offered, self._secret):
|
||||
return JSONResponse({"detail": "Missing or invalid X-Engine-Auth header."}, status_code=401)
|
||||
elif self._require:
|
||||
# Fail closed: require flag set but no secret configured.
|
||||
return JSONResponse(
|
||||
{"detail": ("Engine authentication is required but no shared secret is configured.")},
|
||||
status_code=503,
|
||||
)
|
||||
return await call_next(request)
|
||||
@@ -1,3 +1,4 @@
|
||||
from .agent_capabilities import router as agent_capabilities_router
|
||||
from .agent_drafts import router as agent_draft_router
|
||||
from .documents import router as document_router
|
||||
from .execution import router as execution_router
|
||||
@@ -8,6 +9,7 @@ from .pdf_edit import router as pdf_edit_router
|
||||
from .pdf_questions import router as pdf_question_router
|
||||
|
||||
__all__ = [
|
||||
"agent_capabilities_router",
|
||||
"agent_draft_router",
|
||||
"document_router",
|
||||
"execution_router",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""GET ``/api/v1/agents/capabilities`` - the manifest the Java MCP server pulls at boot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from stirling.api.agent_capabilities import manifest_payload
|
||||
|
||||
router = APIRouter(prefix="/api/v1/agents", tags=["agents"])
|
||||
|
||||
|
||||
@router.get("/capabilities")
|
||||
def get_capabilities() -> dict[str, Any]:
|
||||
"""Return the curated agent capabilities manifest.
|
||||
|
||||
Gated by ``EngineSharedSecretMiddleware`` when the ``STIRLING_ENGINE_SHARED_SECRET`` env var
|
||||
is configured. In dev/local mode (no secret set), the endpoint is open - the engine binds to
|
||||
localhost only by default, so this is acceptable while iterating.
|
||||
"""
|
||||
return manifest_payload()
|
||||
@@ -102,6 +102,11 @@ class AppSettings(BaseSettings):
|
||||
posthog_api_key: str = Field(validation_alias="STIRLING_POSTHOG_API_KEY")
|
||||
posthog_host: str = Field(validation_alias="STIRLING_POSTHOG_HOST")
|
||||
|
||||
# Shared secret enforced by EngineSharedSecretMiddleware. Empty disables enforcement
|
||||
# unless engine_require_auth is set, in which case the engine fails closed (503).
|
||||
engine_shared_secret: str = Field(default="", validation_alias="STIRLING_ENGINE_SHARED_SECRET")
|
||||
engine_require_auth: bool = Field(default=False, validation_alias="STIRLING_ENGINE_REQUIRE_AUTH")
|
||||
|
||||
|
||||
def _configure_logging(level_name: str, log_file: str, http_debug: bool) -> None:
|
||||
"""Configure the ``stirling`` logger hierarchy."""
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Tests for the engine shared-secret middleware (EngineSharedSecretMiddleware)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from stirling.api.engine_auth import EngineSharedSecretMiddleware
|
||||
|
||||
SECRET = "s3cr3t-between-java-and-engine"
|
||||
|
||||
_TRUTHY = {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _client(*, secret: str | None = None, require: str | None = None) -> TestClient:
|
||||
# Values come from explicit kwargs rather than env so tests don't depend on the lru-cached
|
||||
# AppSettings; this mirrors how production code wires the middleware via pydantic-settings.
|
||||
require_flag = require is not None and require.strip().lower() in _TRUTHY
|
||||
app = FastAPI()
|
||||
app.add_middleware(
|
||||
EngineSharedSecretMiddleware,
|
||||
secret=secret or "",
|
||||
require=require_flag,
|
||||
)
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, bool]:
|
||||
return {"ok": True}
|
||||
|
||||
@app.post("/v1/agents/invoke")
|
||||
def invoke() -> dict[str, bool]:
|
||||
return {"ran": True}
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_dev_mode_open_when_unset():
|
||||
c = _client()
|
||||
assert c.post("/v1/agents/invoke").status_code == 200
|
||||
|
||||
|
||||
def test_health_is_public_even_with_secret():
|
||||
c = _client(secret=SECRET)
|
||||
assert c.get("/health").status_code == 200 # no header required
|
||||
|
||||
|
||||
def test_missing_header_rejected():
|
||||
c = _client(secret=SECRET)
|
||||
assert c.post("/v1/agents/invoke").status_code == 401
|
||||
|
||||
|
||||
def test_wrong_secret_rejected():
|
||||
c = _client(secret=SECRET)
|
||||
r = c.post("/v1/agents/invoke", headers={"X-Engine-Auth": "not-the-secret"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_valid_secret_allowed():
|
||||
c = _client(secret=SECRET)
|
||||
r = c.post("/v1/agents/invoke", headers={"X-Engine-Auth": SECRET})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"ran": True}
|
||||
|
||||
|
||||
def test_require_auth_fails_closed_without_secret():
|
||||
c = _client(require="true")
|
||||
# Require flag, no secret -> protected routes refused.
|
||||
assert c.post("/v1/agents/invoke").status_code == 503
|
||||
# Liveness still works for health checks.
|
||||
assert c.get("/health").status_code == 200
|
||||
|
||||
|
||||
def test_require_auth_with_secret_enforces_normally():
|
||||
c = _client(secret=SECRET, require="true")
|
||||
assert c.post("/v1/agents/invoke").status_code == 401
|
||||
assert c.post("/v1/agents/invoke", headers={"X-Engine-Auth": SECRET}).status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flag", ["true", "1", "YES", "On"])
|
||||
def test_require_flag_truthy_variants(flag: str):
|
||||
c = _client(require=flag)
|
||||
assert c.post("/v1/agents/invoke").status_code == 503
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flag", ["false", "0", "no", ""])
|
||||
def test_require_flag_falsey_variants_stay_open(flag: str):
|
||||
c = _client(require=flag)
|
||||
assert c.post("/v1/agents/invoke").status_code == 200
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Service worker for Watched Folder retry scheduling.
|
||||
*
|
||||
* Reads the earliest pending retry from IndexedDB and sets a setTimeout for it.
|
||||
* When the timer fires it posts PROCESS_DUE_RETRIES to all window clients so
|
||||
* the main thread can atomically claim and process the due entries.
|
||||
*
|
||||
* Limitations:
|
||||
* - Browsers may terminate idle service workers after ~30 s. The main thread
|
||||
* therefore also drains due retries on mount and on visibilitychange as a
|
||||
* fallback — no retries are lost, they may just fire slightly late.
|
||||
* - Multiple clients each post a SCHEDULE_RETRY message; the SW deduplicates
|
||||
* by resetting the timer each time, so only one notification is sent.
|
||||
*/
|
||||
|
||||
const DB_NAME = "stirling-pdf-retry-schedule";
|
||||
const STORE_NAME = "retries";
|
||||
|
||||
let retryTimer = null;
|
||||
|
||||
self.addEventListener("install", () => self.skipWaiting());
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(self.clients.claim().then(scheduleNextTimer));
|
||||
});
|
||||
|
||||
self.addEventListener("message", (event) => {
|
||||
if (event.data?.type === "SCHEDULE_RETRY") {
|
||||
scheduleNextTimer();
|
||||
}
|
||||
});
|
||||
|
||||
function openDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, 1);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(new Error("SW: failed to open retry DB"));
|
||||
// Create store if this SW activates before the main thread has opened the DB
|
||||
req.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
const store = db.createObjectStore(STORE_NAME, { keyPath: "id" });
|
||||
store.createIndex("dueAt", "dueAt", { unique: false });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function getEarliestDueAt() {
|
||||
try {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction([STORE_NAME], "readonly");
|
||||
const req = tx.objectStore(STORE_NAME).index("dueAt").openCursor();
|
||||
req.onsuccess = () => resolve(req.result ? req.result.value.dueAt : null);
|
||||
req.onerror = () => resolve(null);
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function notifyClients() {
|
||||
const clients = await self.clients.matchAll({
|
||||
type: "window",
|
||||
includeUncontrolled: true,
|
||||
});
|
||||
for (const client of clients) {
|
||||
client.postMessage({ type: "PROCESS_DUE_RETRIES" });
|
||||
}
|
||||
}
|
||||
|
||||
async function scheduleNextTimer() {
|
||||
if (retryTimer !== null) {
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
const earliest = await getEarliestDueAt();
|
||||
if (earliest === null) return;
|
||||
|
||||
const delay = Math.max(0, earliest - Date.now());
|
||||
retryTimer = setTimeout(async () => {
|
||||
retryTimer = null;
|
||||
await notifyClients();
|
||||
// Re-schedule for any remaining entries that were not yet due
|
||||
await scheduleNextTimer();
|
||||
}, delay);
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import AppConfigLoader from "@app/components/shared/AppConfigLoader";
|
||||
import { UpdateStartupPopup } from "@app/components/shared/UpdateStartupPopup";
|
||||
import { RedactionProvider } from "@app/contexts/RedactionContext";
|
||||
import { FormFillProvider } from "@app/tools/formFill/FormFillContext";
|
||||
import { FolderFileContextProvider } from "@app/contexts/FolderFileContext";
|
||||
import { FolderProvider } from "@app/contexts/FolderContext";
|
||||
|
||||
// Component to initialize scarf tracking (must be inside AppConfigProvider)
|
||||
@@ -148,7 +149,9 @@ export function AppProviders({
|
||||
<WorkbenchBarProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<AdminTourOrchestrationProvider>
|
||||
{children}
|
||||
<FolderFileContextProvider>
|
||||
{children}
|
||||
</FolderFileContextProvider>
|
||||
</AdminTourOrchestrationProvider>
|
||||
</TourOrchestrationProvider>
|
||||
</WorkbenchBarProvider>
|
||||
|
||||
@@ -62,6 +62,10 @@ export default function Workbench() {
|
||||
const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null;
|
||||
const { addFiles } = useFileHandler();
|
||||
const hasFiles = activeFiles.length > 0;
|
||||
// Custom workbench views (e.g. Watched Folders) manage their own content and may
|
||||
// have no workbench files, but still need the bar's view switcher so users can
|
||||
// navigate back out.
|
||||
const isCustomViewActive = !isBaseWorkbench(currentView);
|
||||
|
||||
// Enable bar transitions after first paint so the initial hidden state shows
|
||||
// without animating (landing page on load shouldn't animate the bar up).
|
||||
@@ -205,7 +209,7 @@ export default function Workbench() {
|
||||
?.hideTopControls && (
|
||||
<div
|
||||
className={styles.workbenchBarWrapper}
|
||||
data-hidden={String(!hasFiles)}
|
||||
data-hidden={String(!hasFiles && !isCustomViewActive)}
|
||||
data-no-transition={String(!barTransitionEnabled)}
|
||||
>
|
||||
<div className={styles.workbenchBarInner}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, {
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useEffect,
|
||||
forwardRef,
|
||||
@@ -30,6 +31,7 @@ import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
|
||||
import FolderSpecialIcon from "@mui/icons-material/FolderSpecial";
|
||||
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
@@ -37,11 +39,23 @@ import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import { FileItem } from "@app/components/shared/FileSidebarFileItem";
|
||||
import { useFolderMembership } from "@app/hooks/useFolderMembership";
|
||||
import { useAllWatchedFolders } from "@app/hooks/useAllWatchedFolders";
|
||||
import {
|
||||
setWatchedFolderDraggedFileIds,
|
||||
clearWatchedFolderDraggedFileIds,
|
||||
} from "@app/components/watchedFolders/watchedFolderDragState";
|
||||
import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import "@app/components/shared/FileSidebar.css";
|
||||
|
||||
const COLLAPSED_WIDTH = "3.5rem";
|
||||
const EXPANDED_WIDTH = "16.25rem"; // ~260px
|
||||
|
||||
// Inlined to avoid a circular import with WatchedFoldersRegistration.
|
||||
const WATCHED_FOLDER_VIEW_ID = "watchedFolder";
|
||||
const WATCHED_FOLDER_WORKBENCH_ID = "custom:watchedFolder";
|
||||
|
||||
export interface FileSidebarProps {
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
@@ -101,7 +115,60 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
const { state } = useFileState();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const { setCustomWorkbenchViewData, customWorkbenchViews } =
|
||||
useToolWorkflow();
|
||||
const { workbench: currentWorkbench, selectedTool } = useNavigationState();
|
||||
const isWatchedFoldersActive =
|
||||
currentWorkbench === WATCHED_FOLDER_WORKBENCH_ID;
|
||||
// The folder currently open in the Watched Folders view (null = folder list/home).
|
||||
const activeWatchedFolderId = (customWorkbenchViews.find(
|
||||
(v) => v.id === WATCHED_FOLDER_VIEW_ID,
|
||||
)?.data?.folderId ?? null) as string | null;
|
||||
// fileId → folderId[] across all watch folders. In the Watched Folders view the
|
||||
// sidebar tick reflects "already in the open folder" instead of workbench
|
||||
// membership (which is meaningless there - a click sends to the folder, not
|
||||
// the workbench). The same map drives the per-file membership dots.
|
||||
const folderMembership = useFolderMembership();
|
||||
const allFolders = useAllWatchedFolders();
|
||||
const folderById = useMemo(
|
||||
() => new Map(allFolders.map((f) => [f.id, f])),
|
||||
[allFolders],
|
||||
);
|
||||
|
||||
const openWatchedFolders = useCallback(() => {
|
||||
if (collapsed && onToggleCollapse) onToggleCollapse();
|
||||
setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, { folderId: null });
|
||||
navActions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID as any);
|
||||
}, [collapsed, onToggleCollapse, setCustomWorkbenchViewData, navActions]);
|
||||
|
||||
// Clicking a file's membership dot jumps straight into that folder.
|
||||
const openWatchedFolder = useCallback(
|
||||
(folderId: string) => {
|
||||
if (collapsed && onToggleCollapse) onToggleCollapse();
|
||||
setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, { folderId });
|
||||
navActions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID as any);
|
||||
},
|
||||
[collapsed, onToggleCollapse, setCustomWorkbenchViewData, navActions],
|
||||
);
|
||||
|
||||
// In Watched Folders view, sidebar files can be dragged onto a folder card / drop
|
||||
// zone (which read the watchedFolderFileId dataTransfer key).
|
||||
const handleWatchedFolderDragStart = useCallback(
|
||||
(e: React.DragEvent, fileId: FileId) => {
|
||||
e.dataTransfer.setData("watchedFolderFileId", String(fileId));
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
// Publish the id so drop targets can detect "already in folder" during
|
||||
// dragover (dataTransfer values are unreadable then). Clear on dragend
|
||||
// regardless of whether the drag ended in a drop or was cancelled.
|
||||
setWatchedFolderDraggedFileIds([String(fileId)]);
|
||||
const clear = () => {
|
||||
clearWatchedFolderDraggedFileIds();
|
||||
document.removeEventListener("dragend", clear);
|
||||
};
|
||||
document.addEventListener("dragend", clear);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const isMultiTool =
|
||||
currentWorkbench === "pageEditor" && selectedTool === "multiTool";
|
||||
const { requestNavigation } = useNavigationGuard();
|
||||
@@ -242,6 +309,19 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
const stub = allFileStubs.find((s) => s.id === fileId);
|
||||
if (!stub) return;
|
||||
|
||||
// In the Watched Folders view a click sends the file into the open folder
|
||||
// (mirrors how a click toggles a file into the active workbench elsewhere).
|
||||
// On the folder list (no folder open) it's a no-op so browsing isn't disrupted.
|
||||
if (isWatchedFoldersActive) {
|
||||
if (activeWatchedFolderId) {
|
||||
setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, {
|
||||
folderId: activeWatchedFolderId,
|
||||
pendingFileId: stub.id,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const workbenchFileId = state.files.ids.find(
|
||||
(id) => (id as string) === (stub.id as string),
|
||||
);
|
||||
@@ -291,6 +371,9 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
activeFileId,
|
||||
requestNavigation,
|
||||
isMultiTool,
|
||||
isWatchedFoldersActive,
|
||||
activeWatchedFolderId,
|
||||
setCustomWorkbenchViewData,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -676,6 +759,32 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Watched Folders entry */}
|
||||
{WATCHED_FOLDERS_ENABLED && (
|
||||
<div
|
||||
className="file-sidebar-action-row"
|
||||
data-testid="watchedFolders-button"
|
||||
data-active={isWatchedFoldersActive}
|
||||
onClick={openWatchedFolders}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === "Enter" && openWatchedFolders()}
|
||||
aria-label={t("watchedFolders.sidebarTitle", "Watched Folders")}
|
||||
style={
|
||||
isWatchedFoldersActive
|
||||
? { backgroundColor: "var(--active-bg)" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FolderSpecialIcon className="file-sidebar-action-icon" />
|
||||
{!collapsed && (
|
||||
<span className="file-sidebar-action-label sidebar-content-fade">
|
||||
{t("watchedFolders.sidebarTitle", "Watched Folders")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Files section - always visible when expanded */}
|
||||
{!collapsed && (
|
||||
<div className="file-sidebar-files-section sidebar-content-fade">
|
||||
@@ -716,6 +825,34 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
(id) => (id as string) === (stub.id as string),
|
||||
);
|
||||
const isInWorkbench = !!workbenchFileId;
|
||||
// On Watched Folders, the tick means "this file is already in
|
||||
// the open folder"; on the folder home (no folder open) a
|
||||
// click is a no-op, so show no tick at all.
|
||||
const isSelected = isWatchedFoldersActive
|
||||
? activeWatchedFolderId != null &&
|
||||
(folderMembership
|
||||
.get(stub.id as string)
|
||||
?.includes(activeWatchedFolderId) ??
|
||||
false)
|
||||
: isInWorkbench;
|
||||
// Membership dots only on the Watched Folders home (the folder
|
||||
// grid, no folder open). Inside a specific folder the tick
|
||||
// already shows "in this folder"; in other views they'd just
|
||||
// be noise.
|
||||
const showFolderDots =
|
||||
WATCHED_FOLDERS_ENABLED &&
|
||||
isWatchedFoldersActive &&
|
||||
activeWatchedFolderId === null;
|
||||
const memberFolders = showFolderDots
|
||||
? (folderMembership.get(stub.id as string) ?? [])
|
||||
.map((fid) => folderById.get(fid))
|
||||
.filter((f): f is NonNullable<typeof f> => !!f)
|
||||
.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
accentColor: f.accentColor,
|
||||
}))
|
||||
: [];
|
||||
// Both active and viewed-in-viewer are ID-based - never index-based.
|
||||
const isViewedInViewer = !!(
|
||||
viewedWorkbenchId &&
|
||||
@@ -739,12 +876,16 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
name={stub.name}
|
||||
size={stub.size}
|
||||
lastModified={stub.lastModified}
|
||||
isSelected={isInWorkbench}
|
||||
isSelected={isSelected}
|
||||
isActive={isActive}
|
||||
isViewedInViewer={isViewedInViewer}
|
||||
thumbnailUrl={thumbnailUrl}
|
||||
onClick={handleFileClick}
|
||||
onEyeClick={handleEyeClick}
|
||||
draggable={isWatchedFoldersActive}
|
||||
onDragStart={handleWatchedFolderDragStart}
|
||||
folders={memberFolders}
|
||||
onFolderClick={openWatchedFolder}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -118,11 +118,71 @@
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.file-sidebar-file-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-sidebar-file-meta {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ---- Folder membership tags ---- */
|
||||
.file-sidebar-folder-tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-sidebar-folder-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 7.5rem;
|
||||
padding: 1px 6px 1px 5px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: filter 0.1s ease;
|
||||
}
|
||||
|
||||
.file-sidebar-folder-tag:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.file-sidebar-folder-tag-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-sidebar-folder-tag-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-sidebar-folder-tag-more {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
cursor: default;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- Eye button (open in viewer) ---- */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
||||
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
|
||||
@@ -117,6 +118,13 @@ function getSidebarFileIcon(ext: string): React.ReactElement {
|
||||
return <FileDocIcon className={cls} variant={getFileDocVariant(ext)} />;
|
||||
}
|
||||
|
||||
/** A Watched Folder this file currently belongs to, used for the membership dots. */
|
||||
export interface FileItemFolderRef {
|
||||
id: string;
|
||||
name: string;
|
||||
accentColor: string;
|
||||
}
|
||||
|
||||
export interface FileItemProps {
|
||||
fileId: FileId;
|
||||
name: string;
|
||||
@@ -128,8 +136,17 @@ export interface FileItemProps {
|
||||
thumbnailUrl?: string;
|
||||
onClick: (fileId: FileId) => void;
|
||||
onEyeClick: (fileId: FileId, e: React.MouseEvent) => void;
|
||||
/** When true, the row can be dragged (e.g. onto a Watched Folder). */
|
||||
draggable?: boolean;
|
||||
onDragStart?: (e: React.DragEvent, fileId: FileId) => void;
|
||||
/** Watched Folders this file is in — rendered as small accent dots. */
|
||||
folders?: FileItemFolderRef[];
|
||||
/** Clicking a membership dot opens that folder. */
|
||||
onFolderClick?: (folderId: string) => void;
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_FOLDER_TAGS = 2;
|
||||
|
||||
export function FileItem({
|
||||
fileId,
|
||||
name,
|
||||
@@ -141,12 +158,19 @@ export function FileItem({
|
||||
thumbnailUrl,
|
||||
onClick,
|
||||
onEyeClick,
|
||||
draggable,
|
||||
onDragStart,
|
||||
folders = [],
|
||||
onFolderClick,
|
||||
}: FileItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const ext = getFileExtension(name);
|
||||
const dateLabel = lastModified ? formatFileDate(lastModified) : "";
|
||||
const typeLabel = ext ? ext.toUpperCase() : "File";
|
||||
|
||||
const visibleFolders = folders.slice(0, MAX_VISIBLE_FOLDER_TAGS);
|
||||
const overflowFolders = folders.slice(MAX_VISIBLE_FOLDER_TAGS);
|
||||
|
||||
// Only use raster thumbnails for PDFs and images — everything else uses scalable SVG icons
|
||||
const useRasterThumb = ext === "pdf" || IMAGE_EXTENSIONS.has(ext);
|
||||
const resolvedThumbnail = useLazyThumbnail(
|
||||
@@ -179,6 +203,10 @@ export function FileItem({
|
||||
ref={itemRef}
|
||||
className={`file-sidebar-file-item${isSelected ? " selected" : ""}${isActive ? " active" : ""}${isViewedInViewer ? " viewed" : ""}`}
|
||||
onClick={() => onClick(fileId)}
|
||||
draggable={draggable}
|
||||
onDragStart={
|
||||
draggable && onDragStart ? (e) => onDragStart(e, fileId) : undefined
|
||||
}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === "Enter" && onClick(fileId)}
|
||||
@@ -203,11 +231,61 @@ export function FileItem({
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<span className="file-sidebar-file-meta">
|
||||
{dateLabel}
|
||||
{dateLabel && typeLabel ? " · " : ""}
|
||||
{typeLabel}
|
||||
<span className="file-sidebar-file-meta-row">
|
||||
<span className="file-sidebar-file-meta">
|
||||
{dateLabel}
|
||||
{dateLabel && typeLabel ? " · " : ""}
|
||||
{typeLabel}
|
||||
</span>
|
||||
</span>
|
||||
{folders.length > 0 && (
|
||||
<span className="file-sidebar-folder-tags" data-no-select>
|
||||
{visibleFolders.map((folder) => (
|
||||
<Tooltip
|
||||
key={folder.id}
|
||||
label={folder.name}
|
||||
withArrow
|
||||
position="top"
|
||||
withinPortal
|
||||
>
|
||||
<span
|
||||
className="file-sidebar-folder-tag"
|
||||
style={{
|
||||
backgroundColor: `${folder.accentColor}1f`,
|
||||
borderColor: `${folder.accentColor}55`,
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-label={folder.name}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onFolderClick?.(folder.id);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="file-sidebar-folder-tag-dot"
|
||||
style={{ backgroundColor: folder.accentColor }}
|
||||
/>
|
||||
<span className="file-sidebar-folder-tag-label">
|
||||
{folder.name}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
{overflowFolders.length > 0 && (
|
||||
<Tooltip
|
||||
label={overflowFolders.map((f) => f.name).join(", ")}
|
||||
withArrow
|
||||
position="top"
|
||||
withinPortal
|
||||
>
|
||||
<span className="file-sidebar-folder-tag-more">
|
||||
+{overflowFolders.length}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="file-sidebar-eye-btn"
|
||||
|
||||
@@ -411,7 +411,7 @@ export default function WorkbenchBar({
|
||||
<div className="workbench-bar-divider" />
|
||||
</>
|
||||
)}
|
||||
{hasFiles &&
|
||||
{(hasFiles || isCustomView) &&
|
||||
viewOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
@@ -468,28 +468,30 @@ export default function WorkbenchBar({
|
||||
t("workbenchBar.print", "Print PDF"),
|
||||
)}
|
||||
|
||||
{/* Download */}
|
||||
{renderWithTooltip(
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="workbench-bar-action-icon"
|
||||
onClick={() => handleExportAll()}
|
||||
disabled={
|
||||
disableForFullscreen || totalItems === 0 || allButtonsDisabled
|
||||
}
|
||||
>
|
||||
<LocalIcon
|
||||
icon={icons.downloadIconName}
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
</ActionIcon>,
|
||||
downloadTooltip,
|
||||
)}
|
||||
{/* Download (file-level action — not relevant in custom views) */}
|
||||
{!isCustomView &&
|
||||
renderWithTooltip(
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="workbench-bar-action-icon"
|
||||
onClick={() => handleExportAll()}
|
||||
disabled={
|
||||
disableForFullscreen || totalItems === 0 || allButtonsDisabled
|
||||
}
|
||||
>
|
||||
<LocalIcon
|
||||
icon={icons.downloadIconName}
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
</ActionIcon>,
|
||||
downloadTooltip,
|
||||
)}
|
||||
|
||||
{/* Save As */}
|
||||
{icons.saveAsIconName &&
|
||||
{!isCustomView &&
|
||||
icons.saveAsIconName &&
|
||||
renderWithTooltip(
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
|
||||
@@ -40,6 +40,7 @@ const SETTINGS_SEARCH_TRANSLATION_PREFIXES: Partial<Record<string, string[]>> =
|
||||
adminDatabase: ["admin.settings.database"],
|
||||
adminAdvanced: ["admin.settings.advanced"],
|
||||
adminSecurity: ["admin.settings.security"],
|
||||
adminMcp: ["admin.settings.mcp"],
|
||||
adminConnections: [
|
||||
"admin.settings.connections",
|
||||
"admin.settings.mail",
|
||||
|
||||
@@ -29,6 +29,7 @@ export const VALID_NAV_KEYS = [
|
||||
"adminUsage",
|
||||
"adminEndpoints",
|
||||
"adminStorageSharing",
|
||||
"adminMcp",
|
||||
"help",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Button,
|
||||
@@ -34,6 +34,15 @@ interface AutomationCreationProps {
|
||||
onBack: () => void;
|
||||
onComplete: (automation: AutomationConfig) => void;
|
||||
toolRegistry: Partial<ToolRegistry>;
|
||||
/** Hide the name/description/icon fields and the action buttons. Used when embedding
|
||||
* the form (e.g. inside the Watched Folder modal) where the host owns those controls. */
|
||||
hideMetadata?: boolean;
|
||||
/** Force the automation name (used with hideMetadata so the host's name drives it). */
|
||||
nameOverride?: string;
|
||||
/** Called when an external save trigger fires but the form isn't in a saveable state. */
|
||||
onSaveFailed?: () => void;
|
||||
/** When provided, the host can trigger save imperatively (the internal Save button is hidden). */
|
||||
saveTriggerRef?: React.MutableRefObject<(() => void) | null>;
|
||||
}
|
||||
|
||||
export default function AutomationCreation({
|
||||
@@ -42,6 +51,10 @@ export default function AutomationCreation({
|
||||
onBack,
|
||||
onComplete,
|
||||
toolRegistry,
|
||||
hideMetadata = false,
|
||||
nameOverride,
|
||||
onSaveFailed,
|
||||
saveTriggerRef,
|
||||
}: AutomationCreationProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -154,6 +167,29 @@ export default function AutomationCreation({
|
||||
}
|
||||
};
|
||||
|
||||
// Keep the (hidden) automation name in sync with a host-provided override.
|
||||
useEffect(() => {
|
||||
if (nameOverride !== undefined) {
|
||||
setAutomationName(nameOverride);
|
||||
}
|
||||
}, [nameOverride, setAutomationName]);
|
||||
|
||||
// Expose an imperative save trigger to the host (e.g. the Watched Folder modal's
|
||||
// "Create Folder" button). Surfaces a failure callback when not saveable.
|
||||
useEffect(() => {
|
||||
if (!saveTriggerRef) return;
|
||||
saveTriggerRef.current = () => {
|
||||
if (!canSaveAutomation()) {
|
||||
onSaveFailed?.();
|
||||
return;
|
||||
}
|
||||
void saveAutomation();
|
||||
};
|
||||
return () => {
|
||||
saveTriggerRef.current = null;
|
||||
};
|
||||
});
|
||||
|
||||
const currentConfigTool =
|
||||
configuraingToolIndex >= 0 ? selectedTools[configuraingToolIndex] : null;
|
||||
|
||||
@@ -195,41 +231,45 @@ export default function AutomationCreation({
|
||||
<Divider mb="md" />
|
||||
|
||||
<Stack gap="md">
|
||||
{/* Automation Name and Icon */}
|
||||
<Group gap="xs" align="flex-end">
|
||||
<Stack gap="xs" style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
{!hideMetadata && (
|
||||
<>
|
||||
{/* Automation Name and Icon */}
|
||||
<Group gap="xs" align="flex-end">
|
||||
<Stack gap="xs" style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
placeholder={t(
|
||||
"automate.creation.name.placeholder",
|
||||
"My Automation",
|
||||
)}
|
||||
value={automationName}
|
||||
withAsterisk
|
||||
label={t("automate.creation.name.label", "Automation Name")}
|
||||
onChange={(e) => setAutomationName(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<IconSelector
|
||||
value={automationIcon || "SettingsIcon"}
|
||||
onChange={setAutomationIcon}
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Automation Description */}
|
||||
<Textarea
|
||||
placeholder={t(
|
||||
"automate.creation.name.placeholder",
|
||||
"My Automation",
|
||||
"automate.creation.description.placeholder",
|
||||
"Describe what this automation does...",
|
||||
)}
|
||||
value={automationName}
|
||||
withAsterisk
|
||||
label={t("automate.creation.name.label", "Automation Name")}
|
||||
onChange={(e) => setAutomationName(e.currentTarget.value)}
|
||||
value={automationDescription}
|
||||
label={t("automate.creation.description.label", "Description")}
|
||||
onChange={(e) => setAutomationDescription(e.currentTarget.value)}
|
||||
size="sm"
|
||||
rows={3}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<IconSelector
|
||||
value={automationIcon || "SettingsIcon"}
|
||||
onChange={setAutomationIcon}
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Automation Description */}
|
||||
<Textarea
|
||||
placeholder={t(
|
||||
"automate.creation.description.placeholder",
|
||||
"Describe what this automation does...",
|
||||
)}
|
||||
value={automationDescription}
|
||||
label={t("automate.creation.description.label", "Description")}
|
||||
onChange={(e) => setAutomationDescription(e.currentTarget.value)}
|
||||
size="sm"
|
||||
rows={3}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Selected Tools List */}
|
||||
{selectedTools.length > 0 && (
|
||||
@@ -245,48 +285,52 @@ export default function AutomationCreation({
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
{!saveTriggerRef && (
|
||||
<>
|
||||
<Divider />
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
leftSection={<CheckIcon />}
|
||||
onClick={saveAutomation}
|
||||
disabled={!canSaveAutomation()}
|
||||
fullWidth
|
||||
>
|
||||
{t("automate.creation.save", "Save Automation")}
|
||||
</Button>
|
||||
{/* Action Buttons */}
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
leftSection={<CheckIcon />}
|
||||
onClick={saveAutomation}
|
||||
disabled={!canSaveAutomation()}
|
||||
fullWidth
|
||||
>
|
||||
{t("automate.creation.save", "Save Automation")}
|
||||
</Button>
|
||||
|
||||
<Group gap="sm" grow>
|
||||
<Button
|
||||
leftSection={<DownloadIcon />}
|
||||
onClick={() => {
|
||||
downloadAutomationConfig(buildExportableAutomation());
|
||||
}}
|
||||
disabled={!canSaveAutomation()}
|
||||
variant="light"
|
||||
>
|
||||
{t("automate.creation.export", "Export")}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<DownloadIcon />}
|
||||
onClick={() => {
|
||||
downloadFolderScanningConfig(
|
||||
buildExportableAutomation(),
|
||||
toolRegistry,
|
||||
);
|
||||
}}
|
||||
disabled={!canSaveAutomation()}
|
||||
variant="light"
|
||||
>
|
||||
{t(
|
||||
"automate.creation.exportForFolderScanning",
|
||||
"Export for Folder Scanning",
|
||||
)}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
<Group gap="sm" grow>
|
||||
<Button
|
||||
leftSection={<DownloadIcon />}
|
||||
onClick={() => {
|
||||
downloadAutomationConfig(buildExportableAutomation());
|
||||
}}
|
||||
disabled={!canSaveAutomation()}
|
||||
variant="light"
|
||||
>
|
||||
{t("automate.creation.export", "Export")}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<DownloadIcon />}
|
||||
onClick={() => {
|
||||
downloadFolderScanningConfig(
|
||||
buildExportableAutomation(),
|
||||
toolRegistry,
|
||||
);
|
||||
}}
|
||||
disabled={!canSaveAutomation()}
|
||||
variant="light"
|
||||
>
|
||||
{t(
|
||||
"automate.creation.exportForFolderScanning",
|
||||
"Export for Folder Scanning",
|
||||
)}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Tool Configuration Modal */}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Tracks which file ids are currently being dragged toward a Watched Folder.
|
||||
*
|
||||
* The HTML5 DnD spec hides `dataTransfer` values during `dragover`/`dragenter`
|
||||
* (they're only readable on `drop`), so a drop target can't tell *which* file is
|
||||
* hovering over it mid-drag. Drag sources publish the ids here on `dragstart`;
|
||||
* drop targets read them during `dragover` to give live feedback (e.g. "already
|
||||
* in this folder"). Cleared on `dragend`.
|
||||
*/
|
||||
|
||||
let draggedFileIds: string[] = [];
|
||||
|
||||
export function setWatchedFolderDraggedFileIds(ids: string[]): void {
|
||||
draggedFileIds = ids;
|
||||
}
|
||||
|
||||
export function getWatchedFolderDraggedFileIds(): string[] {
|
||||
return draggedFileIds;
|
||||
}
|
||||
|
||||
export function clearWatchedFolderDraggedFileIds(): void {
|
||||
draggedFileIds = [];
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Build-time feature gates. Flip a flag back to `true` to re-enable a feature
|
||||
* that's been temporarily pulled from the UI.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Watched Folders (a.k.a. Watched Folders). Disabled for now — gates both the
|
||||
* custom workbench registration (seeding, URL sync, view) and the sidebar
|
||||
* entry point, so the feature is unreachable from the UI. Flip to `true` to
|
||||
* restore access.
|
||||
*/
|
||||
// Annotated as `boolean` (not the literal `false`) so call sites aren't treated
|
||||
// as constant/unreachable conditions by the type checker and linter.
|
||||
export const WATCHED_FOLDERS_ENABLED: boolean = false;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Core stub for the Watched Folders file context.
|
||||
*
|
||||
* Watched Folders is a proprietary feature — its real provider lives in
|
||||
* `proprietary/contexts/FolderFileContext.tsx`. The core build has no Watch
|
||||
* Folders consumers, so this is a pass-through that keeps `AppProviders`
|
||||
* (shared) compiling in the open-source build without pulling the feature in.
|
||||
*/
|
||||
export function FolderFileContextProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1083,7 +1083,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
() =>
|
||||
import("@app/components/tools/scannerImageSplit/ScannerImageSplitSettings"),
|
||||
),
|
||||
synonyms: getSynonyms(t, "ScannerImageSplit"),
|
||||
synonyms: getSynonyms(t, "scannerImageSplit"),
|
||||
},
|
||||
overlayPdfs: {
|
||||
icon: (
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Read-only hook that returns all smart folders, kept in sync with storage changes.
|
||||
* Use this when you only need to read the folder list without CRUD operations.
|
||||
* For full CRUD, use useWatchedFolders instead.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { WatchedFolder } from "@app/types/watchedFolders";
|
||||
import {
|
||||
watchedFolderStorage,
|
||||
WATCHED_FOLDER_STORAGE_CHANGE_EVENT,
|
||||
} from "@app/services/watchedFolderStorage";
|
||||
|
||||
export function useAllWatchedFolders(): WatchedFolder[] {
|
||||
const [folders, setFolders] = useState<WatchedFolder[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
setFolders(await watchedFolderStorage.getAllFolders());
|
||||
} catch (err) {
|
||||
console.error("Failed to load smart folders:", err);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
window.addEventListener(WATCHED_FOLDER_STORAGE_CHANGE_EVENT, load);
|
||||
return () =>
|
||||
window.removeEventListener(WATCHED_FOLDER_STORAGE_CHANGE_EVENT, load);
|
||||
}, []);
|
||||
|
||||
return folders;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
export type CardModalPhase =
|
||||
| "closed"
|
||||
| "entering"
|
||||
| "header-open"
|
||||
| "open"
|
||||
| "closing-body"
|
||||
| "closing-header";
|
||||
|
||||
const TIMINGS = {
|
||||
headerStretch: 220,
|
||||
bodyFireDelay: 130,
|
||||
bodyDrop: 90,
|
||||
textAccordion: 25,
|
||||
closeBody: 150,
|
||||
closeHeader: 150,
|
||||
closeStretch: 140,
|
||||
} as const;
|
||||
|
||||
export { TIMINGS as CARD_MODAL_TIMINGS };
|
||||
|
||||
interface UseCardModalAnimationReturn {
|
||||
phase: CardModalPhase;
|
||||
cardRect: DOMRect | null;
|
||||
textExpanded: boolean;
|
||||
openModal: (rect: DOMRect) => void;
|
||||
closeModal: () => void;
|
||||
}
|
||||
|
||||
export function useCardModalAnimation(): UseCardModalAnimationReturn {
|
||||
const [phase, setPhase] = useState<CardModalPhase>("closed");
|
||||
const [cardRect, setCardRect] = useState<DOMRect | null>(null);
|
||||
const [textExpanded, setTextExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === "entering") {
|
||||
const raf = requestAnimationFrame(() => setPhase("header-open"));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}
|
||||
if (phase === "header-open") {
|
||||
const t1 = setTimeout(() => setTextExpanded(true), TIMINGS.textAccordion);
|
||||
const t2 = setTimeout(() => setPhase("open"), TIMINGS.bodyFireDelay);
|
||||
return () => {
|
||||
clearTimeout(t1);
|
||||
clearTimeout(t2);
|
||||
};
|
||||
}
|
||||
if (phase === "closing-body") {
|
||||
const t = setTimeout(() => setPhase("closing-header"), TIMINGS.closeBody);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
if (phase === "closing-header") {
|
||||
const t = setTimeout(() => {
|
||||
setPhase("closed");
|
||||
setTextExpanded(false);
|
||||
}, TIMINGS.closeHeader);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [phase]);
|
||||
|
||||
const openModal = useCallback((rect: DOMRect) => {
|
||||
setCardRect(rect);
|
||||
setPhase("entering");
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setPhase("closing-body");
|
||||
}, []);
|
||||
|
||||
return { phase, cardRect, textExpanded, openModal, closeModal };
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Returns a map of fileId → folderId[] for all files currently in any watched folder.
|
||||
* Both input files (keyed by their FileId in stirling-pdf-files) and their output
|
||||
* counterparts (displayFileId) are included so folder tags show on both versions.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { watchedFolderFileStorage } from "@app/services/watchedFolderFileStorage";
|
||||
import { useAllWatchedFolders } from "@app/hooks/useAllWatchedFolders";
|
||||
|
||||
export function useFolderMembership(): Map<string, string[]> {
|
||||
const folders = useAllWatchedFolders();
|
||||
const [membership, setMembership] = useState<Map<string, string[]>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (folders.length === 0) {
|
||||
setMembership(new Map());
|
||||
return;
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
const map = new Map<string, string[]>();
|
||||
const add = (fileId: string, folderId: string) => {
|
||||
const existing = map.get(fileId);
|
||||
if (existing) {
|
||||
if (!existing.includes(folderId)) existing.push(folderId);
|
||||
} else map.set(fileId, [folderId]);
|
||||
};
|
||||
for (const folder of folders) {
|
||||
try {
|
||||
const record = await watchedFolderFileStorage.getFolderData(
|
||||
folder.id,
|
||||
);
|
||||
if (record) {
|
||||
Object.entries(record.files).forEach(([fileId, meta]) => {
|
||||
add(fileId, folder.id);
|
||||
const outputIds =
|
||||
meta?.displayFileIds ??
|
||||
(meta?.displayFileId ? [meta.displayFileId] : []);
|
||||
outputIds.forEach((oid) => add(oid, folder.id));
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore individual folder failures
|
||||
}
|
||||
}
|
||||
setMembership(map);
|
||||
};
|
||||
|
||||
load();
|
||||
return watchedFolderFileStorage.onFolderChange(load);
|
||||
}, [folders]);
|
||||
|
||||
return membership;
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Service for managing folder-file associations in IndexedDB.
|
||||
* File blobs are stored in the main stirling-pdf-files database (fileStorage).
|
||||
* This service only maintains folder record metadata: which file IDs belong to
|
||||
* which folders and their processing status.
|
||||
*/
|
||||
|
||||
import { FolderFileMetadata, FolderRecord } from "@app/types/watchedFolders";
|
||||
|
||||
const FOLDER_CHANGE_EVENT = "folder-storage-changed";
|
||||
|
||||
class WatchedFolderFileStorage {
|
||||
private dbName = "stirling-pdf-folder-files";
|
||||
private dbVersion = 3;
|
||||
private recordsStore = "folderRecords";
|
||||
private db: IDBDatabase | null = null;
|
||||
|
||||
async init(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.dbName, this.dbVersion);
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error("Failed to open folder files database"));
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains(this.recordsStore)) {
|
||||
db.createObjectStore(this.recordsStore, { keyPath: "folderId" });
|
||||
}
|
||||
if (db.objectStoreNames.contains("folderOutputFiles")) {
|
||||
db.deleteObjectStore("folderOutputFiles");
|
||||
}
|
||||
if (db.objectStoreNames.contains("folderInputFiles")) {
|
||||
db.deleteObjectStore("folderInputFiles");
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureDB(): Promise<IDBDatabase> {
|
||||
if (!this.db) {
|
||||
await this.init();
|
||||
}
|
||||
if (!this.db) {
|
||||
throw new Error("Folder files database not initialized");
|
||||
}
|
||||
return this.db;
|
||||
}
|
||||
|
||||
private dispatchChange(folderId: string): void {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(FOLDER_CHANGE_EVENT, { detail: { folderId } }),
|
||||
);
|
||||
}
|
||||
|
||||
onFolderChange(listener: (folderId: string) => void): () => void {
|
||||
const handler = (e: Event) => {
|
||||
listener((e as CustomEvent).detail.folderId);
|
||||
};
|
||||
window.addEventListener(FOLDER_CHANGE_EVENT, handler);
|
||||
return () => window.removeEventListener(FOLDER_CHANGE_EVENT, handler);
|
||||
}
|
||||
|
||||
async getFolderData(folderId: string): Promise<FolderRecord | null> {
|
||||
const db = await this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.recordsStore], "readonly");
|
||||
const store = transaction.objectStore(this.recordsStore);
|
||||
const request = store.get(folderId);
|
||||
request.onsuccess = () => resolve(request.result || null);
|
||||
request.onerror = () => reject(new Error("Failed to get folder data"));
|
||||
});
|
||||
}
|
||||
|
||||
async addFileToFolder(
|
||||
folderId: string,
|
||||
fileId: string,
|
||||
metadata?: Partial<FolderFileMetadata>,
|
||||
): Promise<void> {
|
||||
const db = await this.ensureDB();
|
||||
const now = new Date();
|
||||
return new Promise((resolve, reject) => {
|
||||
// Single readwrite transaction for both read and write — prevents lost-update
|
||||
// races when multiple files are added to the same folder concurrently.
|
||||
const transaction = db.transaction([this.recordsStore], "readwrite");
|
||||
const store = transaction.objectStore(this.recordsStore);
|
||||
const getRequest = store.get(folderId);
|
||||
getRequest.onsuccess = () => {
|
||||
const record: FolderRecord = getRequest.result || {
|
||||
folderId,
|
||||
files: {},
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
record.files[fileId] = { addedAt: now, status: "pending", ...metadata };
|
||||
record.lastUpdated = Date.now();
|
||||
const putRequest = store.put(record);
|
||||
putRequest.onsuccess = () => {
|
||||
this.dispatchChange(folderId);
|
||||
resolve();
|
||||
};
|
||||
putRequest.onerror = () =>
|
||||
reject(new Error("Failed to add file to folder"));
|
||||
};
|
||||
getRequest.onerror = () =>
|
||||
reject(new Error("Failed to read folder for add"));
|
||||
});
|
||||
}
|
||||
|
||||
async updateFileMetadata(
|
||||
folderId: string,
|
||||
fileId: string,
|
||||
updates: Partial<FolderFileMetadata>,
|
||||
): Promise<void> {
|
||||
const db = await this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
// Single readwrite transaction — prevents lost-update races during concurrent
|
||||
// pipeline runs where multiple files update their status simultaneously.
|
||||
const transaction = db.transaction([this.recordsStore], "readwrite");
|
||||
const store = transaction.objectStore(this.recordsStore);
|
||||
const getRequest = store.get(folderId);
|
||||
getRequest.onsuccess = () => {
|
||||
const existing: FolderRecord | undefined = getRequest.result;
|
||||
if (!existing) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
existing.files[fileId] = { ...existing.files[fileId], ...updates };
|
||||
existing.lastUpdated = Date.now();
|
||||
const putRequest = store.put(existing);
|
||||
putRequest.onsuccess = () => {
|
||||
this.dispatchChange(folderId);
|
||||
resolve();
|
||||
};
|
||||
putRequest.onerror = () =>
|
||||
reject(new Error("Failed to update file metadata"));
|
||||
};
|
||||
getRequest.onerror = () =>
|
||||
reject(new Error("Failed to read folder for update"));
|
||||
});
|
||||
}
|
||||
|
||||
async removeFileFromFolder(folderId: string, fileId: string): Promise<void> {
|
||||
const db = await this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.recordsStore], "readwrite");
|
||||
const store = transaction.objectStore(this.recordsStore);
|
||||
const getRequest = store.get(folderId);
|
||||
getRequest.onsuccess = () => {
|
||||
const existing: FolderRecord | undefined = getRequest.result;
|
||||
if (!existing) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
delete existing.files[fileId];
|
||||
existing.lastUpdated = Date.now();
|
||||
const putRequest = store.put(existing);
|
||||
putRequest.onsuccess = () => {
|
||||
this.dispatchChange(folderId);
|
||||
resolve();
|
||||
};
|
||||
putRequest.onerror = () =>
|
||||
reject(new Error("Failed to remove file from folder"));
|
||||
};
|
||||
getRequest.onerror = () =>
|
||||
reject(new Error("Failed to read folder for remove"));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrite the entire folder record.
|
||||
* Pass `{ silent: true }` for mirror writes — these reflect a read,
|
||||
* not a user action, so dispatching change events would cause subscribers
|
||||
* (which themselves call getFolderData) to re-fetch in an infinite loop.
|
||||
*/
|
||||
async setFolderData(
|
||||
folderId: string,
|
||||
record: FolderRecord,
|
||||
opts?: { silent?: boolean },
|
||||
): Promise<void> {
|
||||
const db = await this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.recordsStore], "readwrite");
|
||||
const store = transaction.objectStore(this.recordsStore);
|
||||
const request = store.put(record);
|
||||
request.onsuccess = () => {
|
||||
if (!opts?.silent) this.dispatchChange(folderId);
|
||||
resolve();
|
||||
};
|
||||
request.onerror = () => reject(new Error("Failed to set folder data"));
|
||||
});
|
||||
}
|
||||
|
||||
async clearFolder(folderId: string): Promise<void> {
|
||||
const db = await this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.recordsStore], "readwrite");
|
||||
const store = transaction.objectStore(this.recordsStore);
|
||||
const request = store.delete(folderId);
|
||||
request.onsuccess = () => {
|
||||
this.dispatchChange(folderId);
|
||||
resolve();
|
||||
};
|
||||
request.onerror = () => reject(new Error("Failed to clear folder"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const watchedFolderFileStorage = new WatchedFolderFileStorage();
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Service for managing Watched Folder configurations in IndexedDB
|
||||
*/
|
||||
|
||||
import { WatchedFolder } from "@app/types/watchedFolders";
|
||||
|
||||
const STORAGE_CHANGE_EVENT = "watched-folder-storage-changed";
|
||||
|
||||
class WatchedFolderStorage {
|
||||
private dbName = "stirling-pdf-watched-folders";
|
||||
private dbVersion = 1;
|
||||
private storeName = "watchedFolders";
|
||||
private db: IDBDatabase | null = null;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
async init(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.dbName, this.dbVersion);
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error("Failed to open smart folder storage database"));
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
this.db.onclose = () => {
|
||||
this.db = null;
|
||||
this.initPromise = null;
|
||||
};
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains(this.storeName)) {
|
||||
const store = db.createObjectStore(this.storeName, { keyPath: "id" });
|
||||
store.createIndex("name", "name", { unique: false });
|
||||
store.createIndex("createdAt", "createdAt", { unique: false });
|
||||
store.createIndex("order", "order", { unique: false });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureDB(): Promise<IDBDatabase> {
|
||||
if (!this.db) {
|
||||
this.initPromise ??= this.init();
|
||||
await this.initPromise;
|
||||
}
|
||||
if (!this.db) {
|
||||
throw new Error("Smart folder database not initialized");
|
||||
}
|
||||
return this.db;
|
||||
}
|
||||
|
||||
private dispatchChange(): void {
|
||||
window.dispatchEvent(new Event(STORAGE_CHANGE_EVENT));
|
||||
}
|
||||
|
||||
async getAllFolders(): Promise<WatchedFolder[]> {
|
||||
const db = await this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => {
|
||||
const folders: WatchedFolder[] = request.result || [];
|
||||
folders.sort((a, b) => {
|
||||
const orderA = a.order ?? Number.MAX_SAFE_INTEGER;
|
||||
const orderB = b.order ?? Number.MAX_SAFE_INTEGER;
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return (
|
||||
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
);
|
||||
});
|
||||
resolve(folders);
|
||||
};
|
||||
request.onerror = () => reject(new Error("Failed to get smart folders"));
|
||||
});
|
||||
}
|
||||
|
||||
async getFolder(id: string): Promise<WatchedFolder | null> {
|
||||
const db = await this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => resolve(request.result || null);
|
||||
request.onerror = () => reject(new Error("Failed to get smart folder"));
|
||||
});
|
||||
}
|
||||
|
||||
async createFolder(
|
||||
data: Omit<WatchedFolder, "id" | "createdAt" | "updatedAt">,
|
||||
): Promise<WatchedFolder> {
|
||||
const db = await this.ensureDB();
|
||||
const timestamp = new Date().toISOString();
|
||||
const folder: WatchedFolder = {
|
||||
id: crypto.randomUUID(),
|
||||
...data,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.add(folder);
|
||||
request.onsuccess = () => {
|
||||
this.dispatchChange();
|
||||
resolve(folder);
|
||||
};
|
||||
request.onerror = () =>
|
||||
reject(new Error("Failed to create smart folder"));
|
||||
});
|
||||
}
|
||||
|
||||
async createFolderWithId(folder: WatchedFolder): Promise<WatchedFolder> {
|
||||
const db = await this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.put(folder);
|
||||
request.onsuccess = () => {
|
||||
this.dispatchChange();
|
||||
resolve(folder);
|
||||
};
|
||||
request.onerror = () =>
|
||||
reject(new Error("Failed to create smart folder with id"));
|
||||
});
|
||||
}
|
||||
|
||||
async updateFolder(folder: WatchedFolder): Promise<WatchedFolder> {
|
||||
const db = await this.ensureDB();
|
||||
const updated: WatchedFolder = {
|
||||
...folder,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.put(updated);
|
||||
request.onsuccess = () => {
|
||||
this.dispatchChange();
|
||||
resolve(updated);
|
||||
};
|
||||
request.onerror = () =>
|
||||
reject(new Error("Failed to update smart folder"));
|
||||
});
|
||||
}
|
||||
|
||||
async deleteFolder(id: string): Promise<void> {
|
||||
const db = await this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.delete(id);
|
||||
request.onsuccess = () => {
|
||||
this.dispatchChange();
|
||||
resolve();
|
||||
};
|
||||
request.onerror = () =>
|
||||
reject(new Error("Failed to delete smart folder"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const watchedFolderStorage = new WatchedFolderStorage();
|
||||
export { STORAGE_CHANGE_EVENT as WATCHED_FOLDER_STORAGE_CHANGE_EVENT };
|
||||
@@ -202,6 +202,8 @@
|
||||
--icon-sign-color: #ffffff;
|
||||
--icon-automate-bg: #a576e3;
|
||||
--icon-automate-color: #ffffff;
|
||||
--icon-watchedFolders-bg: #f59e0b;
|
||||
--icon-watchedFolders-color: #ffffff;
|
||||
--icon-files-bg: #d3e7f7;
|
||||
--icon-files-color: #0a8bff;
|
||||
--icon-activity-bg: #d3e7f7;
|
||||
@@ -535,6 +537,8 @@
|
||||
--icon-sign-color: #eaeaea;
|
||||
--icon-automate-bg: #4b525a;
|
||||
--icon-automate-color: #eaeaea;
|
||||
--icon-watchedFolders-bg: #4b525a;
|
||||
--icon-watchedFolders-color: #eaeaea;
|
||||
--icon-files-bg: #4b525a;
|
||||
--icon-files-color: #eaeaea;
|
||||
--icon-activity-bg: #4b525a;
|
||||
|
||||
@@ -0,0 +1,577 @@
|
||||
/**
|
||||
* Comprehensive Playwright tests for the Watched Folders feature.
|
||||
*
|
||||
* Tests cover: navigation, folder CRUD, preset seeding, drag-and-drop,
|
||||
* modal interactions, sidebar integration, IndexedDB state, and error states.
|
||||
*
|
||||
* Run: npx playwright test watched-folders --project=chromium --reporter=list
|
||||
*/
|
||||
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "@app/tests/helpers/test-base";
|
||||
import { loginAndSetup } from "@app/tests/helpers/login";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Watched Folders ships behind the WATCHED_FOLDERS_ENABLED flag, which is off in
|
||||
// every build today, so its entry points never render. This live suite is
|
||||
// therefore skipped unless the app under test is built with the feature enabled
|
||||
// and the runner opts in via WATCHED_FOLDERS_E2E=1. When re-enabling, also revisit
|
||||
// the sidebar entry point (the old QuickAccessBar button no longer exists).
|
||||
test.beforeEach(() => {
|
||||
const enabled = ["1", "true"].includes(process.env.WATCHED_FOLDERS_E2E ?? "");
|
||||
test.skip(
|
||||
!enabled,
|
||||
"Watched Folders is feature-flagged off; set WATCHED_FOLDERS_E2E=1 to run",
|
||||
);
|
||||
});
|
||||
|
||||
async function setupApp(page: Page): Promise<void> {
|
||||
// Use the shared login helper (real UI login with the bootstrapped
|
||||
// `admin / adminadmin` credentials). The previous bespoke /api/v1/auth/login
|
||||
// call used the pre-bootstrap `stirling` password and always 401'd.
|
||||
await loginAndSetup(page);
|
||||
await page.waitForSelector('[data-testid="watchedFolders-button"]', {
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async function navigateToWatchedFolders(page: Page): Promise<void> {
|
||||
await page.locator('[data-testid="watchedFolders-button"]').click();
|
||||
await page.waitForSelector('button:has-text("New folder")', {
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async function getIDBFolderCount(page: Page): Promise<number> {
|
||||
return page.evaluate(async () => {
|
||||
return new Promise<number>((resolve) => {
|
||||
const req = indexedDB.open("stirling-pdf-watched-folders");
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
const storeName = db.objectStoreNames[0];
|
||||
if (!storeName) {
|
||||
resolve(0);
|
||||
return;
|
||||
}
|
||||
const tx = db.transaction(storeName, "readonly");
|
||||
const count = tx.objectStore(storeName).count();
|
||||
count.onsuccess = () => resolve(count.result);
|
||||
count.onerror = () => resolve(0);
|
||||
};
|
||||
req.onerror = () => resolve(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getIDBFolders(
|
||||
page: Page,
|
||||
): Promise<{ id: string; name: string }[]> {
|
||||
return page.evaluate(async () => {
|
||||
return new Promise<{ id: string; name: string }[]>((resolve) => {
|
||||
const req = indexedDB.open("stirling-pdf-watched-folders");
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
const storeName = db.objectStoreNames[0];
|
||||
if (!storeName) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
const tx = db.transaction(storeName, "readonly");
|
||||
const all = tx.objectStore(storeName).getAll();
|
||||
all.onsuccess = () =>
|
||||
resolve(
|
||||
(all.result || []).map((f: any) => ({ id: f.id, name: f.name })),
|
||||
);
|
||||
all.onerror = () => resolve([]);
|
||||
};
|
||||
req.onerror = () => resolve([]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function clearAllIDBFolders(page: Page): Promise<void> {
|
||||
await page.evaluate(async () => {
|
||||
const dbNames = [
|
||||
"stirling-pdf-watched-folders",
|
||||
"stirling-pdf-folder-files",
|
||||
"stirling-pdf-folder-run-state",
|
||||
"stirling-pdf-retry-schedule",
|
||||
"stirling-pdf-folder-seen-files",
|
||||
"stirling-pdf-folder-directory-handles",
|
||||
];
|
||||
for (const name of dbNames) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const req = indexedDB.deleteDatabase(name);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => resolve();
|
||||
req.onblocked = () => resolve();
|
||||
});
|
||||
}
|
||||
localStorage.removeItem("watched_folders_seeded");
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Watched Folders — Navigation", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupApp(page);
|
||||
});
|
||||
|
||||
test("sidebar button navigates to Watched Folders home", async ({ page }) => {
|
||||
await page.locator('[data-testid="watchedFolders-button"]').click();
|
||||
// Should see the home page with "New folder" button
|
||||
await expect(
|
||||
page.getByRole("button", { name: "New folder" }).first(),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("clicking Watched Folders button twice returns to home", async ({
|
||||
page,
|
||||
}) => {
|
||||
await navigateToWatchedFolders(page);
|
||||
// Click a folder card to navigate into it, then click the button again
|
||||
const firstCard = page.locator('[data-testid="watchedFolders-button"]');
|
||||
await firstCard.click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "New folder" }).first(),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Preset Seeding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Watched Folders — Presets", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupApp(page);
|
||||
await clearAllIDBFolders(page);
|
||||
});
|
||||
|
||||
test("seeds 4 default folders on first visit", async ({ page }) => {
|
||||
// Navigate to watched folders — this triggers WatchedFoldersRegistration which calls seedDefaultFolders
|
||||
await navigateToWatchedFolders(page);
|
||||
// Wait a bit for seeding to complete
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const count = await getIDBFolderCount(page);
|
||||
expect(count).toBe(4);
|
||||
|
||||
const folders = await getIDBFolders(page);
|
||||
const names = folders.map((f) => f.name).sort();
|
||||
expect(names).toEqual([
|
||||
"Email Prep",
|
||||
"Pre-publish",
|
||||
"Rotate & Optimise",
|
||||
"Secure Ingestion",
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not re-seed on second visit", async ({ page }) => {
|
||||
await navigateToWatchedFolders(page);
|
||||
await page.waitForTimeout(2000);
|
||||
const count1 = await getIDBFolderCount(page);
|
||||
|
||||
// Navigate away and back
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForSelector('[data-testid="watchedFolders-button"]', {
|
||||
timeout: 15000,
|
||||
});
|
||||
await navigateToWatchedFolders(page);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const count2 = await getIDBFolderCount(page);
|
||||
expect(count2).toBe(count1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Folder CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Watched Folders — Create / Edit / Delete", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupApp(page);
|
||||
});
|
||||
|
||||
test("create a new folder via modal", async ({ page }) => {
|
||||
await navigateToWatchedFolders(page);
|
||||
|
||||
const initialCount = await getIDBFolderCount(page);
|
||||
|
||||
// Open modal
|
||||
await page.getByRole("button", { name: "New folder" }).first().click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Fill name
|
||||
const nameInput = page.getByPlaceholder("My watched folder");
|
||||
if (await nameInput.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await nameInput.fill("Test Folder");
|
||||
} else {
|
||||
// Fallback — find the first text input in the modal
|
||||
await page.locator('input[type="text"]').first().fill("Test Folder");
|
||||
}
|
||||
|
||||
// We need to configure at least the minimum tools before save will work
|
||||
// Try to find and fill tool slots
|
||||
const toolInputs = page.getByPlaceholder("Select a tool...");
|
||||
if (
|
||||
await toolInputs
|
||||
.first()
|
||||
.isVisible({ timeout: 2000 })
|
||||
.catch(() => false)
|
||||
) {
|
||||
await toolInputs.first().click();
|
||||
await page.waitForTimeout(200);
|
||||
await toolInputs.first().fill("compress");
|
||||
await page.waitForTimeout(400);
|
||||
// Click the compress option
|
||||
const compressOption = page
|
||||
.getByRole("button", { name: /compress/i })
|
||||
.first();
|
||||
if (
|
||||
await compressOption.isVisible({ timeout: 2000 }).catch(() => false)
|
||||
) {
|
||||
await compressOption.click();
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to save
|
||||
const createBtn = page.getByText("Create Folder");
|
||||
if (await createBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await createBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
const newCount = await getIDBFolderCount(page);
|
||||
// Should have at least one more folder than before
|
||||
expect(newCount).toBeGreaterThanOrEqual(initialCount);
|
||||
});
|
||||
|
||||
test("delete a folder cleans up all related IDB stores", async ({ page }) => {
|
||||
await navigateToWatchedFolders(page);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const folders = await getIDBFolders(page);
|
||||
if (folders.length === 0) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
const targetFolder = folders[0];
|
||||
|
||||
// Delete via IDB directly and check cleanup
|
||||
await page.evaluate(
|
||||
async ({ folderId }) => {
|
||||
// Seed some test data in related stores
|
||||
const seedStore = (
|
||||
dbName: string,
|
||||
storeName: string,
|
||||
key: string,
|
||||
value: any,
|
||||
) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const req = indexedDB.open(dbName);
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(storeName)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const tx = db.transaction(storeName, "readwrite");
|
||||
tx.objectStore(storeName).put(value, key);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => resolve();
|
||||
};
|
||||
req.onerror = () => resolve();
|
||||
});
|
||||
|
||||
await seedStore(
|
||||
"stirling-pdf-folder-seen-files",
|
||||
"seenFiles",
|
||||
`${folderId}|test.pdf|1234|5678`,
|
||||
Date.now(),
|
||||
);
|
||||
},
|
||||
{ folderId: targetFolder.id },
|
||||
);
|
||||
|
||||
// Now trigger folder deletion via the hook mechanism
|
||||
// We simulate what the delete button does by calling the storage directly
|
||||
await page.evaluate(
|
||||
async ({ folderId }) => {
|
||||
// Delete from watched folder storage
|
||||
await new Promise<void>((resolve) => {
|
||||
const req = indexedDB.open("stirling-pdf-watched-folders");
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
const storeName = db.objectStoreNames[0];
|
||||
if (!storeName) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const tx = db.transaction(storeName, "readwrite");
|
||||
tx.objectStore(storeName).delete(folderId);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => resolve();
|
||||
};
|
||||
req.onerror = () => resolve();
|
||||
});
|
||||
},
|
||||
{ folderId: targetFolder.id },
|
||||
);
|
||||
|
||||
// Verify the folder is gone
|
||||
const remaining = await getIDBFolders(page);
|
||||
expect(remaining.find((f) => f.id === targetFolder.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Management Modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Watched Folders — Management Modal", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupApp(page);
|
||||
await navigateToWatchedFolders(page);
|
||||
});
|
||||
|
||||
test("modal opens and shows name input", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "New folder" }).first().click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should show the name input
|
||||
const nameInput = page.getByPlaceholder("My watched folder");
|
||||
await expect(nameInput).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("modal closes on Escape key", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "New folder" }).first().click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const nameInput = page.getByPlaceholder("My watched folder");
|
||||
await expect(nameInput).toBeVisible({ timeout: 5000 });
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Modal should be gone
|
||||
await expect(nameInput).toBeHidden({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("name input enforces 50 character limit", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "New folder" }).first().click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const nameInput = page.getByPlaceholder("My watched folder");
|
||||
await expect(nameInput).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Type a very long string
|
||||
const longName = "A".repeat(60);
|
||||
await nameInput.fill(longName);
|
||||
const value = await nameInput.inputValue();
|
||||
expect(value.length).toBeLessThanOrEqual(50);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Home Page UI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Watched Folders — Home Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupApp(page);
|
||||
await navigateToWatchedFolders(page);
|
||||
await page.waitForTimeout(1500); // wait for seeding
|
||||
});
|
||||
|
||||
test("displays folder cards for seeded presets", async ({ page }) => {
|
||||
// Should see at least some folder cards
|
||||
const folderNames = [
|
||||
"Secure Ingestion",
|
||||
"Pre-publish",
|
||||
"Email Prep",
|
||||
"Rotate & Optimise",
|
||||
];
|
||||
for (const name of folderNames) {
|
||||
const card = page.getByText(name).first();
|
||||
const visible = await card
|
||||
.isVisible({ timeout: 3000 })
|
||||
.catch(() => false);
|
||||
if (visible) {
|
||||
expect(visible).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('shows "How it works" section on first visit', async ({ page }) => {
|
||||
// Clear the session storage flag
|
||||
await page.evaluate(() =>
|
||||
sessionStorage.removeItem("watchedFolderHowItWorksDismissed"),
|
||||
);
|
||||
// Re-navigate
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForSelector('[data-testid="watchedFolders-button"]', {
|
||||
timeout: 15000,
|
||||
});
|
||||
await navigateToWatchedFolders(page);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Look for "How" text
|
||||
const howItWorks = page.getByText(/How.*[Ww]atch.*[Ff]olders.*work/i);
|
||||
const visible = await howItWorks
|
||||
.isVisible({ timeout: 3000 })
|
||||
.catch(() => false);
|
||||
// This is expected to be visible on first visit (if not dismissed)
|
||||
// Don't hard-fail if not found — it may have been dismissed in session
|
||||
if (visible) {
|
||||
expect(visible).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('"New folder" button is present and clickable', async ({ page }) => {
|
||||
const btn = page.getByRole("button", { name: "New folder" }).first();
|
||||
await expect(btn).toBeVisible();
|
||||
await expect(btn).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Sidebar Section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Watched Folders — Sidebar", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupApp(page);
|
||||
await navigateToWatchedFolders(page);
|
||||
await page.waitForTimeout(1500);
|
||||
});
|
||||
|
||||
test("sidebar shows folder entries", async ({ page }) => {
|
||||
// The sidebar should have a section with folder names
|
||||
const sidebar = page
|
||||
.locator('[class*="sidebar"], [data-testid*="sidebar"]')
|
||||
.first();
|
||||
if (await sidebar.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
// Check for at least one folder name in the sidebar area
|
||||
const sidebarText = await sidebar.textContent();
|
||||
// Should contain at least one preset name
|
||||
const hasFolder = ["Secure", "Pre-publish", "Email", "Rotate"].some(
|
||||
(name) => sidebarText?.includes(name),
|
||||
);
|
||||
expect(hasFolder).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: IndexedDB Storage Integrity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Watched Folders — Storage Integrity", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupApp(page);
|
||||
});
|
||||
|
||||
test("folder IDs are valid UUIDs (no prefix)", async ({ page }) => {
|
||||
await navigateToWatchedFolders(page);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const folders = await getIDBFolders(page);
|
||||
for (const folder of folders) {
|
||||
// Should be a valid UUID format
|
||||
expect(folder.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
||||
);
|
||||
// Should NOT have the old prefix
|
||||
expect(folder.id).not.toMatch(/^folder-/);
|
||||
}
|
||||
});
|
||||
|
||||
test("seeded flag is set in localStorage after seeding", async ({ page }) => {
|
||||
await navigateToWatchedFolders(page);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const flag = await page.evaluate(() =>
|
||||
localStorage.getItem("watched_folders_seeded"),
|
||||
);
|
||||
expect(flag).toBe("true");
|
||||
});
|
||||
|
||||
test("clearing localStorage flag and reloading re-seeds folders", async ({
|
||||
page,
|
||||
}) => {
|
||||
await clearAllIDBFolders(page);
|
||||
|
||||
// Navigate to trigger seeding
|
||||
await navigateToWatchedFolders(page);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const count = await getIDBFolderCount(page);
|
||||
expect(count).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Responsive / Accessibility basics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Watched Folders — Accessibility", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupApp(page);
|
||||
await navigateToWatchedFolders(page);
|
||||
await page.waitForTimeout(1500);
|
||||
});
|
||||
|
||||
test("New folder button is focusable via Tab", async ({ page }) => {
|
||||
// Tab through the page until we reach the New folder button
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await page.keyboard.press("Tab");
|
||||
const focused = await page.evaluate(
|
||||
() => document.activeElement?.textContent,
|
||||
);
|
||||
if (focused?.includes("New folder")) {
|
||||
expect(true).toBe(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// If we didn't find it in 30 tabs, that's concerning but not necessarily a failure
|
||||
// (depends on page structure)
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: File Count Display
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Watched Folders — File Count", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupApp(page);
|
||||
await navigateToWatchedFolders(page);
|
||||
await page.waitForTimeout(1500);
|
||||
});
|
||||
|
||||
test("file count text is visible in both light and dark themes", async ({
|
||||
page,
|
||||
}) => {
|
||||
// The file count should NOT have hardcoded white color (we fixed this)
|
||||
// Verify by checking computed styles
|
||||
const fileCountTexts = page.locator("text=file").first();
|
||||
if (await fileCountTexts.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
const color = await fileCountTexts.evaluate(
|
||||
(el) => window.getComputedStyle(el).color,
|
||||
);
|
||||
// Should not be pure white (#fff = rgb(255, 255, 255))
|
||||
// In light theme, it should be a dark color
|
||||
expect(color).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import ts from "typescript";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { parse } from "smol-toml";
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, "../../../..");
|
||||
const SRC_ROOT = path.join(__dirname, "../..");
|
||||
const EN_GB_FILE = path.join(
|
||||
__dirname,
|
||||
"../../../public/locales/en-GB/translation.toml",
|
||||
);
|
||||
|
||||
const IGNORED_DIRS = new Set(["tests", "__mocks__"]);
|
||||
const IGNORED_FILE_PATTERNS = [
|
||||
/\.d\.ts$/,
|
||||
/\.test\./,
|
||||
/\.spec\./,
|
||||
/\.stories\./,
|
||||
];
|
||||
|
||||
/**
|
||||
* Keys that look unused to the heuristic but are genuinely used: keep them.
|
||||
* These are families assembled at runtime, so no static fragment ever reaches
|
||||
* source code for the literal/template matching to catch. Add a regex here
|
||||
* (with a comment naming the runtime usage) rather than teaching the test
|
||||
* about specific component internals. For a single key, anchor it: /^a\.b$/.
|
||||
*/
|
||||
const IGNORED_KEY_PATTERNS: RegExp[] = [
|
||||
// SignSettings / SavedSignaturesSection look up every key as
|
||||
// t(`${translationScope}.${key}`); the scope ("sign" | "addText" |
|
||||
// "addImage") and the relative key only ever exist as separate literals.
|
||||
/^(sign|addText|addImage)\./,
|
||||
// SettingsSearchBar builds its search index by loading whole subtrees via
|
||||
// t(prefix, { returnObjects: true }); the leaf keys never appear in source.
|
||||
/^admin\.settings\./,
|
||||
/^settings\./,
|
||||
/^account\./,
|
||||
];
|
||||
|
||||
const flattenKeys = (
|
||||
node: unknown,
|
||||
prefix = "",
|
||||
acc = new Set<string>(),
|
||||
): Set<string> => {
|
||||
if (!node || typeof node !== "object" || Array.isArray(node)) {
|
||||
if (prefix) {
|
||||
acc.add(prefix);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
for (const [childKey, value] of Object.entries(
|
||||
node as Record<string, unknown>,
|
||||
)) {
|
||||
const next = prefix ? `${prefix}.${childKey}` : childKey;
|
||||
flattenKeys(value, next, acc);
|
||||
}
|
||||
|
||||
return acc;
|
||||
};
|
||||
|
||||
const listSourceFiles = (): string[] => {
|
||||
const files = ts.sys.readDirectory(
|
||||
SRC_ROOT,
|
||||
[".ts", ".tsx", ".js", ".jsx"],
|
||||
undefined,
|
||||
["**/*"],
|
||||
);
|
||||
|
||||
return files
|
||||
.filter(
|
||||
(file) =>
|
||||
!file.split(path.sep).some((segment) => IGNORED_DIRS.has(segment)),
|
||||
)
|
||||
.filter((file) => !IGNORED_FILE_PATTERNS.some((re) => re.test(file)));
|
||||
};
|
||||
|
||||
const getScriptKind = (file: string): ts.ScriptKind => {
|
||||
if (file.endsWith(".tsx")) return ts.ScriptKind.TSX;
|
||||
if (file.endsWith(".ts")) return ts.ScriptKind.TS;
|
||||
if (file.endsWith(".jsx")) return ts.ScriptKind.JSX;
|
||||
return ts.ScriptKind.JS;
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk each file's AST and collect every template literal whose static parts
|
||||
* could plausibly form a dotted translation key. Each shape replaces ${...}
|
||||
* interpolations with `*`, e.g. `tools.${id}.title` becomes `tools.*.title`.
|
||||
*
|
||||
* We deliberately collect *all* template literals (not just those at t()
|
||||
* call sites), because keys are often built up in helpers, constants or
|
||||
* config objects and only passed to t() somewhere far away. A shape only
|
||||
* counts if it carries at least one identifier-like static fragment though,
|
||||
* so generic templates like `${name}.${ext}` (shape `*.*`) are discarded.
|
||||
*
|
||||
* Using the AST (rather than a backtick-pair regex) is important: source
|
||||
* files contain large multi-line templates with embedded CSS/HTML and
|
||||
* nested interpolations that confuse regex-based pairing.
|
||||
*/
|
||||
const extractTemplateShapesFromFile = (
|
||||
file: string,
|
||||
acc: Set<string>,
|
||||
): void => {
|
||||
const code = fs.readFileSync(file, "utf8");
|
||||
if (!code.includes("${")) return;
|
||||
|
||||
const sourceFile = ts.createSourceFile(
|
||||
file,
|
||||
code,
|
||||
ts.ScriptTarget.Latest,
|
||||
false,
|
||||
getScriptKind(file),
|
||||
);
|
||||
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isTemplateExpression(node)) {
|
||||
let shape = node.head.text;
|
||||
for (const span of node.templateSpans) {
|
||||
shape += "*";
|
||||
shape += span.literal.text;
|
||||
}
|
||||
if (
|
||||
shape.includes(".") &&
|
||||
/[A-Za-z0-9_-]/.test(shape.replace(/\*/g, ""))
|
||||
) {
|
||||
acc.add(shape);
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
ts.forEachChild(sourceFile, visit);
|
||||
};
|
||||
|
||||
const shapeToMatcher = (shape: string): RegExp => {
|
||||
// Each * stands in for one runtime-supplied path segment. We use `[^.]+`
|
||||
// (not `.+`) so a one-variable interpolation doesn't accidentally span
|
||||
// multiple key levels. If a real interpolation does carry a multi-segment
|
||||
// string, the IGNORED_KEY_PATTERNS list is the escape hatch.
|
||||
const escaped = shape
|
||||
.split("*")
|
||||
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
||||
.join("[^.]+");
|
||||
return new RegExp(`^${escaped}$`);
|
||||
};
|
||||
|
||||
const isIgnored = (key: string): boolean => {
|
||||
return IGNORED_KEY_PATTERNS.some((re) => re.test(key));
|
||||
};
|
||||
|
||||
describe("Unused translation coverage", () => {
|
||||
test(
|
||||
"fails if any en-GB translation key has no source references",
|
||||
{ timeout: 30_000 },
|
||||
() => {
|
||||
expect(fs.existsSync(EN_GB_FILE)).toBe(true);
|
||||
|
||||
const enGb = parse(fs.readFileSync(EN_GB_FILE, "utf8"));
|
||||
const availableKeys = Array.from(flattenKeys(enGb));
|
||||
expect(availableKeys.length).toBeGreaterThan(100); // sanity check
|
||||
|
||||
const sourceFiles = listSourceFiles();
|
||||
expect(sourceFiles.length).toBeGreaterThan(0);
|
||||
|
||||
const source = sourceFiles
|
||||
.map((file) => fs.readFileSync(file, "utf8"))
|
||||
.join("\n");
|
||||
|
||||
const shapes = new Set<string>();
|
||||
for (const file of sourceFiles) {
|
||||
extractTemplateShapesFromFile(file, shapes);
|
||||
}
|
||||
const shapeMatchers = Array.from(shapes).map(shapeToMatcher);
|
||||
|
||||
const unused = availableKeys.filter((key) => {
|
||||
if (isIgnored(key)) return false;
|
||||
// Direct: the full key text appears anywhere in source (catches
|
||||
// static t() calls, i18nKey props, constants, and any other place
|
||||
// the literal string sits in code or comments).
|
||||
if (source.includes(key)) return false;
|
||||
// Dynamic: the key matches a template-literal shape from source.
|
||||
return !shapeMatchers.some((re) => re.test(key));
|
||||
});
|
||||
|
||||
const localeRelative = path
|
||||
.relative(REPO_ROOT, EN_GB_FILE)
|
||||
.replace(/\\/g, "/");
|
||||
|
||||
// GitHub Annotations format so unused keys show up tagged on the
|
||||
// translation file in CI.
|
||||
for (const key of unused) {
|
||||
process.stderr.write(
|
||||
`::error file=${localeRelative}::Unused en-GB translation: ${key}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
expect(
|
||||
unused,
|
||||
`Found ${unused.length} unused en-GB translation key(s). ` +
|
||||
`Remove them from ${localeRelative}, or (if the usage is too ` +
|
||||
`dynamic for the heuristic to spot) add to IGNORED_KEY_PATTERNS ` +
|
||||
`in this test.`,
|
||||
).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Types for Watched Folders (a.k.a. Watched Folders) functionality.
|
||||
*
|
||||
* Server-backed persistence and server-side processing fields are intentionally
|
||||
* not in this type — they land in a follow-up PR.
|
||||
*/
|
||||
|
||||
export interface WatchedFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
automationId: string; // FK → AutomationConfig.id
|
||||
icon: string; // icon name string
|
||||
accentColor: string; // hex e.g. '#3b82f6'
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
order?: number;
|
||||
isDefault?: boolean;
|
||||
isPaused?: boolean;
|
||||
maxRetries?: number; // 0 = disabled; default 3
|
||||
retryDelayMinutes?: number; // default 5
|
||||
outputMode?: "new_file" | "new_version"; // default: 'new_file'
|
||||
outputName?: string; // output filename prefix/suffix
|
||||
outputNamePosition?: "prefix" | "suffix" | "auto-number"; // default: 'prefix'
|
||||
hasOutputDirectory?: boolean; // true when a local FS output folder is configured
|
||||
/** Where input files come from. Default: 'idb' (dropped/sidebar files stay in browser). */
|
||||
inputSource?: "idb" | "local-folder";
|
||||
}
|
||||
|
||||
export interface FolderFileMetadata {
|
||||
addedAt: Date;
|
||||
status: "pending" | "processing" | "processed" | "error";
|
||||
processedAt?: Date;
|
||||
/** All output file ids produced by this run — references stirling-pdf-files */
|
||||
displayFileIds?: string[];
|
||||
/** First output file id — kept for backwards compat with existing records */
|
||||
displayFileId?: string;
|
||||
/** True when the folder created this file from a disk drop and therefore owns it.
|
||||
* False / absent when the file came from the shared sidebar store — do NOT delete on folder removal. */
|
||||
ownedByFolder?: boolean;
|
||||
errorMessage?: string;
|
||||
failedAttempts?: number;
|
||||
nextRetryAt?: number; // ms timestamp — set when an automatic retry is scheduled
|
||||
lastFailedAt?: Date;
|
||||
name?: string; // original filename
|
||||
}
|
||||
|
||||
export interface FolderRecord {
|
||||
folderId: string;
|
||||
files: Record<string, FolderFileMetadata>;
|
||||
lastUpdated: number;
|
||||
}
|
||||
|
||||
export interface WatchedFolderRunEntry {
|
||||
inputFileId: string;
|
||||
/** First output file id */
|
||||
displayFileId: string;
|
||||
/** All output file ids produced by this run — kept in sync with FolderFileMetadata.displayFileIds */
|
||||
displayFileIds?: string[];
|
||||
/** When this run completed — used for TTL-based "done" status */
|
||||
processedAt?: Date;
|
||||
status: "processing" | "processed";
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Feature detection for the File System Access API.
|
||||
*
|
||||
* Browser support matrix (as of 2026):
|
||||
* Chrome/Edge: full support — showDirectoryPicker, createWritable, queryPermission
|
||||
* Firefox: showDirectoryPicker + read-only iteration only; no createWritable, no queryPermission
|
||||
* Safari 15.2+: showDirectoryPicker + read only; no createWritable
|
||||
*/
|
||||
|
||||
/** True when the browser can pick a directory and read files from it. */
|
||||
export const canReadLocalFolder: boolean =
|
||||
typeof window !== "undefined" &&
|
||||
typeof (window as any).showDirectoryPicker === "function";
|
||||
|
||||
/** True when the browser supports writing files (createWritable). Requires Chrome/Edge. */
|
||||
export const canWriteLocalFolder: boolean =
|
||||
canReadLocalFolder &&
|
||||
typeof FileSystemFileHandle !== "undefined" &&
|
||||
typeof (FileSystemFileHandle.prototype as any).createWritable === "function";
|
||||
|
||||
/** True when the browser supports permission querying across sessions (queryPermission). */
|
||||
export const canPersistFsPermission: boolean =
|
||||
canReadLocalFolder &&
|
||||
typeof FileSystemHandle !== "undefined" &&
|
||||
typeof (FileSystemHandle.prototype as any).queryPermission === "function";
|
||||
|
||||
export const FS_READ_UNSUPPORTED_MSG =
|
||||
"Your browser does not support the File System Access API. Use Chrome or Edge.";
|
||||
|
||||
export const FS_WRITE_UNSUPPORTED_MSG =
|
||||
"Your browser cannot write to local folders. Use Chrome or Edge for this feature.";
|
||||
@@ -14,6 +14,8 @@ import ShareLinkPage from "@app/routes/ShareLinkPage";
|
||||
import ParticipantView from "@app/components/workflow/ParticipantView";
|
||||
import MobileScannerPage from "@app/pages/MobileScannerPage";
|
||||
import Onboarding from "@app/components/onboarding/Onboarding";
|
||||
import WatchedFoldersRegistration from "@app/components/watchedFolders/WatchedFoldersRegistration";
|
||||
import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags";
|
||||
|
||||
// Import global styles
|
||||
import "@app/styles/tailwind.css";
|
||||
@@ -80,6 +82,7 @@ export default function App() {
|
||||
<Route path="/*" element={<Landing />} />
|
||||
</Routes>
|
||||
<Onboarding />
|
||||
{WATCHED_FOLDERS_ENABLED && <WatchedFoldersRegistration />}
|
||||
</AppLayout>
|
||||
</AppProviders>
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import AdminLegalSection from "@app/components/shared/config/configSections/Admi
|
||||
import AdminPlanSection from "@app/components/shared/config/configSections/AdminPlanSection";
|
||||
import AdminFeaturesSection from "@app/components/shared/config/configSections/AdminFeaturesSection";
|
||||
import AdminEndpointsSection from "@app/components/shared/config/configSections/AdminEndpointsSection";
|
||||
import AdminMcpSection from "@app/components/shared/config/configSections/AdminMcpSection";
|
||||
import AdminAuditSection from "@app/components/shared/config/configSections/AdminAuditSection";
|
||||
import AdminUsageSection from "@app/components/shared/config/configSections/AdminUsageSection";
|
||||
import AdminStorageSharingSection from "@app/components/shared/config/configSections/AdminStorageSharingSection";
|
||||
@@ -136,6 +137,14 @@ export const useConfigNavSections = (
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
|
||||
},
|
||||
{
|
||||
key: "adminMcp",
|
||||
label: t("settings.configuration.mcp", "MCP Server"),
|
||||
icon: "smart-toy-rounded",
|
||||
component: <AdminMcpSection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
|
||||
},
|
||||
{
|
||||
key: "adminDatabase",
|
||||
label: t("settings.configuration.database", "Database"),
|
||||
|
||||
+551
@@ -0,0 +1,551 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TextInput,
|
||||
Textarea,
|
||||
Switch,
|
||||
Select,
|
||||
Stack,
|
||||
Paper,
|
||||
Text,
|
||||
Loader,
|
||||
Group,
|
||||
Alert,
|
||||
Code,
|
||||
List,
|
||||
} from "@mantine/core";
|
||||
import { alert } from "@app/components/toast";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
|
||||
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
|
||||
import { useAdminSettings } from "@app/hooks/useAdminSettings";
|
||||
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
|
||||
import PendingBadge from "@app/components/shared/config/PendingBadge";
|
||||
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { useLoginRequired } from "@app/hooks/useLoginRequired";
|
||||
|
||||
interface McpAuthData {
|
||||
mode?: string;
|
||||
issuerUri?: string;
|
||||
jwksUri?: string;
|
||||
resourceId?: string;
|
||||
usernameClaim?: string;
|
||||
requireExistingAccount?: boolean;
|
||||
}
|
||||
|
||||
interface McpSettingsData {
|
||||
enabled?: boolean;
|
||||
scopesEnabled?: boolean;
|
||||
allowedOperations?: string[];
|
||||
blockedOperations?: string[];
|
||||
auth?: McpAuthData;
|
||||
}
|
||||
|
||||
/** Parse a comma/space/newline separated list into a string[]. */
|
||||
function parseOpList(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\s,]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
interface ApiResponseWithPending<T> {
|
||||
_pending?: Partial<T>;
|
||||
}
|
||||
|
||||
type McpApiResponse = McpSettingsData & ApiResponseWithPending<McpSettingsData>;
|
||||
|
||||
export default function AdminMcpSection() {
|
||||
const { t } = useTranslation();
|
||||
const { loginEnabled } = useLoginRequired();
|
||||
const {
|
||||
restartModalOpened,
|
||||
showRestartModal,
|
||||
closeRestartModal,
|
||||
restartServer,
|
||||
} = useRestartServer();
|
||||
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<McpSettingsData>({
|
||||
sectionName: "mcp",
|
||||
fetchTransformer: async (): Promise<
|
||||
McpSettingsData & { _pending?: Partial<McpSettingsData> }
|
||||
> => {
|
||||
const response = await apiClient.get<McpApiResponse>(
|
||||
"/api/v1/admin/settings/section/mcp",
|
||||
);
|
||||
return response.data || {};
|
||||
},
|
||||
// Save nested auth.* keys as dot-notation through the root endpoint so siblings are preserved.
|
||||
saveTransformer: (s: McpSettingsData) => ({
|
||||
sectionData: {},
|
||||
deltaSettings: {
|
||||
"mcp.enabled": s.enabled ?? false,
|
||||
"mcp.scopesEnabled": s.scopesEnabled ?? true,
|
||||
"mcp.allowedOperations": s.allowedOperations ?? [],
|
||||
"mcp.blockedOperations": s.blockedOperations ?? [],
|
||||
"mcp.auth.mode": s.auth?.mode ?? "oauth",
|
||||
"mcp.auth.issuerUri": s.auth?.issuerUri ?? "",
|
||||
"mcp.auth.jwksUri": s.auth?.jwksUri ?? "",
|
||||
"mcp.auth.resourceId": s.auth?.resourceId ?? "",
|
||||
"mcp.auth.usernameClaim": s.auth?.usernameClaim ?? "sub",
|
||||
"mcp.auth.requireExistingAccount":
|
||||
s.auth?.requireExistingAccount ?? true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
|
||||
settings,
|
||||
loading,
|
||||
);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await saveSettings();
|
||||
markSaved();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("admin.error", "Error"),
|
||||
body: t("admin.settings.saveError", "Failed to save settings"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
setSettings(resetToSnapshot());
|
||||
}, [resetToSnapshot, setSettings]);
|
||||
|
||||
const setAuth = (patch: Partial<McpAuthData>) =>
|
||||
setSettings({ ...settings, auth: { ...(settings.auth || {}), ...patch } });
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h={200}>
|
||||
<Loader size="lg" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl =
|
||||
typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: "https://your-host";
|
||||
const mcpUrl = `${baseUrl}/mcp`;
|
||||
const metadataUrl = `${baseUrl}/.well-known/oauth-protected-resource`;
|
||||
const authMode = settings.auth?.mode || "oauth";
|
||||
|
||||
return (
|
||||
<div className="settings-section-container">
|
||||
<Stack gap="lg" className="settings-section-content">
|
||||
<div>
|
||||
<Text fw={600} size="lg">
|
||||
{t("admin.settings.mcp.title", "MCP Server")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"admin.settings.mcp.description",
|
||||
"Expose Stirling's PDF tools and AI agents to MCP clients over an OAuth-protected endpoint.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t("admin.settings.mcp.enabled.label", "Enable MCP Server")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"admin.settings.mcp.enabled.description",
|
||||
"When off (default), no /mcp endpoint, metadata, or MCP beans are loaded.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.enabled || false}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, enabled: e.target.checked })
|
||||
}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending("enabled")} />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Select
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t("admin.settings.mcp.mode.label", "Authentication mode")}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("auth.mode")} />
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.mcp.mode.description",
|
||||
"OAuth needs an external IdP. API key uses a Stirling per-user API key (X-API-KEY) - simplest for self-host.",
|
||||
)}
|
||||
data={[
|
||||
{ value: "oauth", label: "OAuth 2.1 (external IdP)" },
|
||||
{ value: "apikey", label: "API key (Stirling per-user key)" },
|
||||
]}
|
||||
value={authMode}
|
||||
onChange={(v) => setAuth({ mode: v || "oauth" })}
|
||||
allowDeselect={false}
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
|
||||
{authMode === "apikey" && (
|
||||
<Alert variant="light" color="gray">
|
||||
<Text size="xs">
|
||||
{t(
|
||||
"admin.settings.mcp.apikeyNote",
|
||||
"Clients send a Stirling API key in the X-API-KEY header (or Authorization: Bearer <key>). The key maps to its owning Stirling user - only provisioned accounts get in, and actions are audited as that user. Manage keys under Account → API Keys.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{authMode === "oauth" && (
|
||||
<>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t(
|
||||
"admin.settings.mcp.issuerUri.label",
|
||||
"OAuth Issuer URL",
|
||||
)}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("auth.issuerUri")} />
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.mcp.issuerUri.description",
|
||||
"Your OAuth2 authorization server (must publish /.well-known/openid-configuration). Required when enabled.",
|
||||
)}
|
||||
value={settings.auth?.issuerUri || ""}
|
||||
onChange={(e) => setAuth({ issuerUri: e.target.value })}
|
||||
placeholder="https://auth.example.com"
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t(
|
||||
"admin.settings.mcp.resourceId.label",
|
||||
"Resource ID",
|
||||
)}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("auth.resourceId")} />
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.mcp.resourceId.description",
|
||||
"This server's public /mcp URL. Tokens must list it in their audience (RFC 8707) or they are rejected.",
|
||||
)}
|
||||
value={settings.auth?.resourceId || ""}
|
||||
onChange={(e) => setAuth({ resourceId: e.target.value })}
|
||||
placeholder={mcpUrl}
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t(
|
||||
"admin.settings.mcp.jwksUri.label",
|
||||
"JWKS URL (optional)",
|
||||
)}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("auth.jwksUri")} />
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.mcp.jwksUri.description",
|
||||
"Leave blank to discover it from the issuer. Set only if your IdP serves keys at a non-standard URL.",
|
||||
)}
|
||||
value={settings.auth?.jwksUri || ""}
|
||||
onChange={(e) => setAuth({ jwksUri: e.target.value })}
|
||||
placeholder={t(
|
||||
"admin.settings.mcp.jwksUri.placeholder",
|
||||
"Auto-discovered from issuer",
|
||||
)}
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.mcp.scopes.label",
|
||||
"Enforce OAuth scopes",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"admin.settings.mcp.scopes.description",
|
||||
"Require mcp.tools.read for read ops and mcp.tools.write for write/AI ops.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.scopesEnabled ?? true}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
scopesEnabled: e.target.checked,
|
||||
})
|
||||
}
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending("scopesEnabled")} />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.mcp.requireAccount.label",
|
||||
"Require an existing Stirling account",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"admin.settings.mcp.requireAccount.description",
|
||||
"Only let tokens through if their subject maps to a provisioned, enabled Stirling user.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.auth?.requireExistingAccount ?? true}
|
||||
onChange={(e) =>
|
||||
setAuth({ requireExistingAccount: e.target.checked })
|
||||
}
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
<PendingBadge
|
||||
show={isFieldPending("auth.requireExistingAccount")}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t(
|
||||
"admin.settings.mcp.usernameClaim.label",
|
||||
"Username claim",
|
||||
)}
|
||||
</span>
|
||||
<PendingBadge
|
||||
show={isFieldPending("auth.usernameClaim")}
|
||||
/>
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.mcp.usernameClaim.description",
|
||||
"JWT claim matched against a Stirling username (e.g. sub, email, preferred_username).",
|
||||
)}
|
||||
value={settings.auth?.usernameClaim || ""}
|
||||
onChange={(e) => setAuth({ usernameClaim: e.target.value })}
|
||||
placeholder="sub"
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t("admin.settings.mcp.allowedOps.label", "Allowed tools")}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("allowedOperations")} />
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.mcp.allowedOps.description",
|
||||
"If set, ONLY these operation ids are exposed (an allow-list; comma or space separated). Leave blank to expose all enabled tools except any blocked below.",
|
||||
)}
|
||||
value={(settings.allowedOperations || []).join(" ")}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
allowedOperations: parseOpList(e.target.value),
|
||||
})
|
||||
}
|
||||
placeholder="merge-pdfs split-pdf rotate-pdf"
|
||||
autosize
|
||||
minRows={1}
|
||||
maxRows={3}
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t("admin.settings.mcp.blockedOps.label", "Blocked tools")}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("blockedOperations")} />
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.mcp.blockedOps.description",
|
||||
"Operation ids to hide from MCP (comma or space separated), e.g. add-password remove-password. Leave blank to expose all enabled tools.",
|
||||
)}
|
||||
value={(settings.blockedOperations || []).join(" ")}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
blockedOperations: parseOpList(e.target.value),
|
||||
})
|
||||
}
|
||||
placeholder="add-password sanitize-pdf"
|
||||
autosize
|
||||
minRows={1}
|
||||
maxRows={3}
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Compact in-page setup guide */}
|
||||
<Alert
|
||||
variant="light"
|
||||
color="blue"
|
||||
title={t("admin.settings.mcp.guide.title", "Connect an MCP client")}
|
||||
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
|
||||
>
|
||||
<Stack gap={6}>
|
||||
<List size="xs" type="ordered" spacing={4}>
|
||||
{authMode === "oauth" ? (
|
||||
<>
|
||||
<List.Item>
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step1",
|
||||
"Enter your OAuth issuer + resource ID above, Save, and restart.",
|
||||
)}
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step2",
|
||||
"In your MCP client add a",
|
||||
)}{" "}
|
||||
<b>streamable-HTTP</b>{" "}
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step2b",
|
||||
"server pointing at:",
|
||||
)}{" "}
|
||||
<Code>{mcpUrl}</Code>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step3",
|
||||
"The client auto-discovers OAuth from:",
|
||||
)}{" "}
|
||||
<Code>{metadataUrl}</Code>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step4",
|
||||
"Approve the sign-in; the client retries with a token. Tools appear grouped: convert, pages, misc, security, ai.",
|
||||
)}
|
||||
</List.Item>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<List.Item>
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step1ApiKey",
|
||||
"Create an API key under Account → API Keys (each user uses their own).",
|
||||
)}
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step2",
|
||||
"In your MCP client add a",
|
||||
)}{" "}
|
||||
<b>streamable-HTTP</b>{" "}
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step2b",
|
||||
"server pointing at:",
|
||||
)}{" "}
|
||||
<Code>{mcpUrl}</Code>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step3ApiKey",
|
||||
"Send the key in an",
|
||||
)}{" "}
|
||||
<Code>X-API-KEY</Code>{" "}
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step3ApiKeyb",
|
||||
"header (or Authorization: Bearer <key>). No OAuth or metadata discovery is used.",
|
||||
)}
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step4ApiKey",
|
||||
"Tools appear grouped: convert, pages, misc, security, ai - and every call is audited as the key's owner.",
|
||||
)}
|
||||
</List.Item>
|
||||
</>
|
||||
)}
|
||||
</List>
|
||||
<Text size="xs" c="dimmed">
|
||||
{authMode === "oauth"
|
||||
? t(
|
||||
"admin.settings.mcp.guide.tip",
|
||||
"Tip: register the resource ID as an allowed audience in your IdP. Tested with MCP Inspector and Claude Desktop.",
|
||||
)
|
||||
: t(
|
||||
"admin.settings.mcp.guide.tipApiKey",
|
||||
"Tip: API-key mode needs no external IdP - simplest for self-host. The key maps to its owning Stirling user.",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
</Stack>
|
||||
|
||||
<SettingsStickyFooter
|
||||
isDirty={isDirty}
|
||||
saving={saving}
|
||||
loginEnabled={loginEnabled}
|
||||
onSave={handleSave}
|
||||
onDiscard={handleDiscard}
|
||||
/>
|
||||
|
||||
<RestartConfirmationModal
|
||||
opened={restartModalOpened}
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, ActionIcon, ScrollArea } from "@mantine/core";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import {
|
||||
CardModalPhase,
|
||||
CARD_MODAL_TIMINGS,
|
||||
} from "@app/hooks/useCardModalAnimation";
|
||||
|
||||
interface CardExpansionModalProps {
|
||||
phase: CardModalPhase;
|
||||
cardRect: DOMRect | null;
|
||||
textExpanded: boolean;
|
||||
onClose: () => void;
|
||||
icon: React.ReactNode;
|
||||
count: number;
|
||||
labelSingular: string;
|
||||
labelPlural: string;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
toolbar?: React.ReactNode;
|
||||
widthRem?: number;
|
||||
heightRem?: number;
|
||||
/** Replace ScrollArea with a flex-fill container so children can expand to full body height */
|
||||
fillHeight?: boolean;
|
||||
}
|
||||
|
||||
const MODAL_W_REM = 56;
|
||||
const MODAL_H_REM = 38;
|
||||
const HEADER_H_REM = 3.5;
|
||||
const MODAL_TOP_FRACTION = 0.12;
|
||||
const EASING = "cubic-bezier(0.22,1,0.36,1)";
|
||||
|
||||
export function CardExpansionModal({
|
||||
phase,
|
||||
cardRect,
|
||||
textExpanded,
|
||||
onClose,
|
||||
icon,
|
||||
count,
|
||||
labelSingular,
|
||||
labelPlural,
|
||||
children,
|
||||
footer,
|
||||
toolbar,
|
||||
widthRem,
|
||||
heightRem,
|
||||
fillHeight,
|
||||
}: CardExpansionModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [viewportW, setViewportW] = useState(window.innerWidth);
|
||||
const [viewportH, setViewportH] = useState(window.innerHeight);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setViewportW(window.innerWidth);
|
||||
setViewportH(window.innerHeight);
|
||||
};
|
||||
window.addEventListener("resize", handler);
|
||||
return () => window.removeEventListener("resize", handler);
|
||||
}, []);
|
||||
|
||||
if (phase === "closed" || !cardRect) return null;
|
||||
|
||||
const rootFontSize = parseFloat(
|
||||
getComputedStyle(document.documentElement).fontSize,
|
||||
);
|
||||
const modalW = Math.min(
|
||||
(widthRem ?? MODAL_W_REM) * rootFontSize,
|
||||
viewportW * 0.9,
|
||||
);
|
||||
const modalH = Math.min(
|
||||
(heightRem ?? MODAL_H_REM) * rootFontSize,
|
||||
viewportH * 0.85,
|
||||
);
|
||||
const headerH = HEADER_H_REM * rootFontSize;
|
||||
const finalLeft = (viewportW - modalW) / 2;
|
||||
const finalTop = Math.min(
|
||||
viewportH * MODAL_TOP_FRACTION,
|
||||
viewportH - modalH - 16,
|
||||
);
|
||||
|
||||
const isAtCard = phase === "entering" || phase === "closing-header";
|
||||
const isAtHeader = phase === "header-open";
|
||||
|
||||
const cardH = isAtCard ? cardRect.height : isAtHeader ? headerH : modalH;
|
||||
|
||||
const getTransition = () => {
|
||||
const s = CARD_MODAL_TIMINGS;
|
||||
if (phase === "header-open")
|
||||
return `top ${s.headerStretch}ms ${EASING}, left ${s.headerStretch}ms ${EASING}, width ${s.headerStretch}ms ${EASING}, height ${s.headerStretch}ms ${EASING}`;
|
||||
if (phase === "open") return `height ${s.bodyDrop}ms ${EASING}`;
|
||||
if (phase === "closing-body") return `height ${s.closeBody}ms ease-in`;
|
||||
if (phase === "closing-header")
|
||||
return `top ${s.closeStretch}ms ${EASING}, left ${s.closeStretch}ms ${EASING}, width ${s.closeStretch}ms ${EASING}, height ${s.closeStretch}ms ${EASING}, opacity ${s.closeStretch}ms ease`;
|
||||
return "none";
|
||||
};
|
||||
|
||||
const backdropOpacity =
|
||||
phase === "entering" || phase === "closing-header" ? 0 : 1;
|
||||
const cardOpacity = phase === "closing-header" ? 0 : 1;
|
||||
|
||||
const showBody = phase === "open" || phase === "closing-body";
|
||||
|
||||
return createPortal(
|
||||
<div style={{ position: "fixed", inset: 0, zIndex: 300 }}>
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
backgroundColor: "rgba(0,0,0,0.55)",
|
||||
opacity: backdropOpacity,
|
||||
transition: "opacity 220ms ease",
|
||||
willChange: "opacity",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: isAtCard ? cardRect.top : finalTop,
|
||||
left: isAtCard ? cardRect.left : finalLeft,
|
||||
width: isAtCard ? cardRect.width : modalW,
|
||||
height: cardH,
|
||||
opacity: cardOpacity,
|
||||
transition: getTransition(),
|
||||
willChange: "top, left, width, height, opacity",
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
overflow: "hidden",
|
||||
backgroundColor: "var(--bg-toolbar)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
boxShadow: "0 1.5rem 3rem rgba(0,0,0,0.3)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
height: headerH,
|
||||
flexShrink: 0,
|
||||
borderBottom: "0.0625rem solid var(--border-subtle)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "1rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<Text
|
||||
component="span"
|
||||
fw={800}
|
||||
style={{ fontSize: "1.375rem", lineHeight: 1, margin: 0 }}
|
||||
>
|
||||
{count}
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: textExpanded ? "16rem" : "0",
|
||||
opacity: textExpanded ? 1 : 0,
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
transition: `max-width 100ms ${EASING}, opacity 80ms ease`,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
component="span"
|
||||
c="dimmed"
|
||||
style={{
|
||||
fontSize: "1rem",
|
||||
paddingLeft: "0.5rem",
|
||||
lineHeight: 1,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{count === 1 ? labelSingular : labelPlural}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
color="gray"
|
||||
onClick={onClose}
|
||||
aria-label={t("close", "Close")}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "0.25rem",
|
||||
right: "0.375rem",
|
||||
}}
|
||||
>
|
||||
<CloseIcon style={{ fontSize: "1.25rem" }} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showBody && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: 0,
|
||||
backgroundColor: "var(--bg-toolbar)",
|
||||
}}
|
||||
>
|
||||
{toolbar && (
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
borderBottom: "0.0625rem solid var(--border-subtle)",
|
||||
}}
|
||||
>
|
||||
{toolbar}
|
||||
</div>
|
||||
)}
|
||||
{fillHeight ? (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
padding: "0.75rem 1rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "0.75rem 1rem",
|
||||
backgroundColor: "var(--bg-toolbar)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
{footer && (
|
||||
<div
|
||||
style={{
|
||||
padding: "0.75rem 1rem",
|
||||
borderTop: "0.0625rem solid var(--border-subtle)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { Modal, Text, Button, Stack, Group } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { WatchedFolder } from "@app/types/watchedFolders";
|
||||
|
||||
interface DeleteFolderConfirmModalProps {
|
||||
opened: boolean;
|
||||
folder: WatchedFolder | null;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function DeleteFolderConfirmModal({
|
||||
opened,
|
||||
folder,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: DeleteFolderConfirmModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!folder) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onCancel}
|
||||
title={t("watchedFolders.deleteConfirmTitle", "Delete folder?")}
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="md">
|
||||
{folder.isDefault && (
|
||||
<Text size="sm" c="orange">
|
||||
{t(
|
||||
"watchedFolders.defaultFolderWarning",
|
||||
"This is a default folder and will be recreated on next reload.",
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"watchedFolders.deleteConfirmBody",
|
||||
"This will remove the folder and its run history. Files already downloaded are not affected.",
|
||||
)}
|
||||
</Text>
|
||||
<Group gap="sm" justify="flex-end">
|
||||
<Button variant="outline" size="sm" onClick={onCancel}>
|
||||
{t("cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button color="red" size="sm" onClick={onConfirm}>
|
||||
{t("delete", "Delete")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Modal, Center, Text, Box, Loader } from "@mantine/core";
|
||||
import { FileId } from "@app/types/fileContext";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { LocalEmbedPDF } from "@app/components/viewer/LocalEmbedPDF";
|
||||
import { PdfViewerToolbar } from "@app/components/viewer/PdfViewerToolbar";
|
||||
import { ViewerProvider } from "@app/contexts/ViewerContext";
|
||||
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
|
||||
|
||||
interface FilePreviewModalProps {
|
||||
fileId?: FileId | null;
|
||||
/** Pass a File directly (e.g. outputs not stored in IDB). */
|
||||
file?: File | null;
|
||||
fileName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function FilePreviewModal({
|
||||
fileId,
|
||||
file: fileProp,
|
||||
fileName,
|
||||
onClose,
|
||||
}: FilePreviewModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (fileProp) {
|
||||
setFile(fileProp);
|
||||
setError(false);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!fileId) {
|
||||
setFile(null);
|
||||
setError(false);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setError(false);
|
||||
setLoading(true);
|
||||
fileStorage
|
||||
.getStirlingFile(fileId)
|
||||
.then((f) => {
|
||||
if (f) setFile(f);
|
||||
else setError(true);
|
||||
})
|
||||
.catch(() => setError(true))
|
||||
.finally(() => setLoading(false));
|
||||
}, [fileId, fileProp]);
|
||||
|
||||
const opened = !!(fileId || fileProp);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={fileName}
|
||||
size="90%"
|
||||
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
|
||||
styles={{
|
||||
body: {
|
||||
height: "82vh",
|
||||
padding: 0,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<Center h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : error ? (
|
||||
<Center h="100%">
|
||||
<Text c="dimmed">
|
||||
{t(
|
||||
"watchedFolders.workbench.previewLoadFailed",
|
||||
"Could not load file preview.",
|
||||
)}
|
||||
</Text>
|
||||
</Center>
|
||||
) : !file ? (
|
||||
<Center h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : (
|
||||
<ViewerProvider>
|
||||
<PdfViewerToolbar />
|
||||
<Box style={{ flex: 1, minHeight: 0 }}>
|
||||
<LocalEmbedPDF file={file} fileName={fileName} />
|
||||
</Box>
|
||||
</ViewerProvider>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Icon picker for Watched Folder create/edit.
|
||||
* Re-exports IconSelector from the automate tools so smart folder components
|
||||
* don't need to reach into the automate tools directory.
|
||||
*/
|
||||
|
||||
export { default as IconPicker } from "@app/components/tools/automate/IconSelector";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user