diff --git a/.gitleaksignore b/.gitleaksignore index a5f4b02f9..f363dd114 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -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 diff --git a/.taskfiles/e2e.yml b/.taskfiles/e2e.yml index 825b7cb69..618041807 100644 --- a/.taskfiles/e2e.yml +++ b/.taskfiles/e2e.yml @@ -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= 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= + Add --nobuild if the images are already built: + task e2e:mcp:manual 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= + Add --nobuild if images are already built: + task e2e:mcp:apikey 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 diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 1cb1c0152..4ce9418a3 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -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 diff --git a/app/common/src/main/java/stirling/software/common/cluster/FileStore.java b/app/common/src/main/java/stirling/software/common/cluster/FileStore.java index f576e1e97..33e48eb40 100644 --- a/app/common/src/main/java/stirling/software/common/cluster/FileStore.java +++ b/app/common/src/main/java/stirling/software/common/cluster/FileStore.java @@ -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. * *

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; } diff --git a/app/common/src/main/java/stirling/software/common/cluster/inprocess/LocalDiskFileStore.java b/app/common/src/main/java/stirling/software/common/cluster/inprocess/LocalDiskFileStore.java index 6d8d0d91f..67ba48b4a 100644 --- a/app/common/src/main/java/stirling/software/common/cluster/inprocess/LocalDiskFileStore.java +++ b/app/common/src/main/java/stirling/software/common/cluster/inprocess/LocalDiskFileStore.java @@ -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(); + } } diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index bb2eb8167..00e26ec64 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -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 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 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 }) 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 diff --git a/app/common/src/main/java/stirling/software/common/service/FileStorage.java b/app/common/src/main/java/stirling/software/common/service/FileStorage.java index e77e6d6fc..c5c1ddb5d 100644 --- a/app/common/src/main/java/stirling/software/common/service/FileStorage.java +++ b/app/common/src/main/java/stirling/software/common/service/FileStorage.java @@ -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; 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 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"); + } + } } diff --git a/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java b/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java index 629d28ba6..23a23e868 100644 --- a/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java +++ b/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java @@ -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) { diff --git a/app/common/src/main/java/stirling/software/common/util/JobContext.java b/app/common/src/main/java/stirling/software/common/util/JobContext.java index a41394914..f7016b8f7 100644 --- a/app/common/src/main/java/stirling/software/common/util/JobContext.java +++ b/app/common/src/main/java/stirling/software/common/util/JobContext.java @@ -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 CURRENT_JOB_ID = new ThreadLocal<>(); + private static final ThreadLocal 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(); } } diff --git a/app/common/src/test/java/stirling/software/common/cluster/inprocess/LocalDiskFileStoreTest.java b/app/common/src/test/java/stirling/software/common/cluster/inprocess/LocalDiskFileStoreTest.java index 296d012c8..df8516851 100644 --- a/app/common/src/test/java/stirling/software/common/cluster/inprocess/LocalDiskFileStoreTest.java +++ b/app/common/src/test/java/stirling/software/common/cluster/inprocess/LocalDiskFileStoreTest.java @@ -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())); + } } diff --git a/app/common/src/test/java/stirling/software/common/service/FileStorageDelegationTest.java b/app/common/src/test/java/stirling/software/common/service/FileStorageDelegationTest.java index a929a15af..b013a2692 100644 --- a/app/common/src/test/java/stirling/software/common/service/FileStorageDelegationTest.java +++ b/app/common/src/test/java/stirling/software/common/service/FileStorageDelegationTest.java @@ -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)); diff --git a/app/common/src/test/java/stirling/software/common/service/FileStorageOwnershipTest.java b/app/common/src/test/java/stirling/software/common/service/FileStorageOwnershipTest.java new file mode 100644 index 000000000..861efa850 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/service/FileStorageOwnershipTest.java @@ -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 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 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 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 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 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 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)); + } +} diff --git a/app/common/src/test/java/stirling/software/common/service/FileStorageTest.java b/app/common/src/test/java/stirling/software/common/service/FileStorageTest.java index ace0dfa56..32a3cb65d 100644 --- a/app/common/src/test/java/stirling/software/common/service/FileStorageTest.java +++ b/app/common/src/test/java/stirling/software/common/service/FileStorageTest.java @@ -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); diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index d3684b4da..d4ecb35f7 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -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. diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle index 6f744c6c6..ceb44153d 100644 --- a/app/proprietary/build.gradle +++ b/app/proprietary/build.gradle @@ -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' diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3FileStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3FileStore.java index 1cff20e6a..68cd72ded 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3FileStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3FileStore.java @@ -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 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"); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/McpCallContext.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/McpCallContext.java new file mode 100644 index 000000000..7bbf46db3 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/McpCallContext.java @@ -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 grantedScopes, boolean scopesEnabled) { + + public boolean hasScope(String required) { + if (!scopesEnabled) { + return true; + } + return required == null || required.isBlank() || grantedScopes.contains(required); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/McpServerController.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/McpServerController.java new file mode 100644 index 000000000..d3103b8d4 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/McpServerController.java @@ -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 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 toolsByName; + + public McpServerController( + ObjectMapper mapper, ApplicationProperties applicationProperties, List 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 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 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); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/McpTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/McpTool.java new file mode 100644 index 000000000..271ed9858 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/McpTool.java @@ -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); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/McpToolCatalog.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/McpToolCatalog.java new file mode 100644 index 000000000..2821c9181 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/McpToolCatalog.java @@ -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 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 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 allowed = mcp.getAllowedOperations(); + List 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 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 patterns = extractPatterns(info); + if (patterns.isEmpty()) { + return; + } + Set 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> 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> 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 enabledOps(OperationCategory category) { + if (category == OperationCategory.AI) { + List ai = new ArrayList<>(); + for (OperationMeta m : aiOps.values()) { + if (isOperationAllowed(m.id())) { + ai.add(m); + } + } + return ai; + } + List 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 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 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 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 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 extractPatterns(RequestMappingInfo info) { + try { + Method getDirectPaths = info.getClass().getMethod("getDirectPaths"); + Object result = getDirectPaths.invoke(info); + if (result instanceof Set set) { + Set 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 snapshotPdfOps() { + return new LinkedHashMap<>(pdfOps); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/OperationCategory.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/OperationCategory.java new file mode 100644 index 000000000..7be79b796 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/OperationCategory.java @@ -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; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/OperationMeta.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/OperationMeta.java new file mode 100644 index 000000000..a9d8b2a9a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/OperationMeta.java @@ -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 + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/SimpleSchemaGenerator.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/SimpleSchemaGenerator.java new file mode 100644 index 000000000..0cef8d32b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/SimpleSchemaGenerator.java @@ -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> 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 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 collectFields(Class type) { + List 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> 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> 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); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/engine/EngineCapabilityClient.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/engine/EngineCapabilityClient.java new file mode 100644 index 000000000..ed023bd25 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/engine/EngineCapabilityClient.java @@ -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 response = + httpClient.send(reqBuilder.build(), HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new IOException( + "Engine capabilities endpoint returned HTTP " + response.statusCode()); + } + Map parsed = parseManifest(response.body()); + catalog.replaceAiCapabilities(parsed); + } + + private Map 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 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; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/jsonrpc/JsonRpcError.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/jsonrpc/JsonRpcError.java new file mode 100644 index 000000000..e383605a5 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/jsonrpc/JsonRpcError.java @@ -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); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/jsonrpc/JsonRpcRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/jsonrpc/JsonRpcRequest.java new file mode 100644 index 000000000..01466746e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/jsonrpc/JsonRpcRequest.java @@ -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(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/jsonrpc/JsonRpcResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/jsonrpc/JsonRpcResponse.java new file mode 100644 index 000000000..e01898bde --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/jsonrpc/JsonRpcResponse.java @@ -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); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpApiKeyAuthFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpApiKeyAuthFilter.java new file mode 100644 index 000000000..e45dadb0c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpApiKeyAuthFilter.java @@ -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 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 = 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; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAudienceValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAudienceValidator.java new file mode 100644 index 000000000..7430776de --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAudienceValidator.java @@ -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 { + + 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 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(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAuthenticationEntryPoint.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAuthenticationEntryPoint.java new file mode 100644 index 000000000..5139ea162 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAuthenticationEntryPoint.java @@ -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)); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpRequestSizeFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpRequestSizeFilter.java new file mode 100644 index 000000000..b6c410a30 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpRequestSizeFilter.java @@ -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)); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpSecurityConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpSecurityConfig.java new file mode 100644 index 000000000..ab6341ed3 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpSecurityConfig.java @@ -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; + + private static final String BASE_PATH = "/mcp"; + + public McpSecurityConfig( + ApplicationProperties applicationProperties, + @Lazy UserService userService, + ObjectProvider 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 ). 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 ).\"}"); + })) + .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 defaultValidators = + JwtValidators.createDefaultWithIssuer(auth.getIssuerUri()); + OAuth2TokenValidator combined = + new DelegatingOAuth2TokenValidator<>( + defaultValidators, new McpAudienceValidator(auth.getResourceId())); + decoder.setJwtValidator(combined); + return decoder; + } + + private Converter mcpJwtAuthenticationConverter() { + JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter(); + scopes.setAuthorityPrefix("SCOPE_"); + scopes.setAuthoritiesClaimName("scope"); + JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); + converter.setJwtGrantedAuthoritiesConverter( + jwt -> { + Collection out = new ArrayList<>(scopes.convert(jwt)); + List aud = jwt.getAudience(); + if (aud != null) { + for (String a : aud) { + out.add(new SimpleGrantedAuthority("AUDIENCE_" + a)); + } + } + return out; + }); + return converter; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpUserBindingFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpUserBindingFilter.java new file mode 100644 index 000000000..59dfbbfac --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpUserBindingFilter.java @@ -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 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)); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/AbstractCategoryTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/AbstractCategoryTool.java new file mode 100644 index 000000000..8c75ee145 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/AbstractCategoryTool.java @@ -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 catalogProvider; + protected final ObjectProvider executorProvider; + + protected AbstractCategoryTool( + ObjectMapper mapper, + ObjectProvider catalog, + ObjectProvider executor) { + this.mapper = mapper; + this.catalogProvider = catalog; + this.executorProvider = executor; + } + + protected abstract OperationCategory category(); + + protected List 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 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 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()); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/DescribeOperationTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/DescribeOperationTool.java new file mode 100644 index 000000000..c93578136 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/DescribeOperationTool.java @@ -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 catalogProvider; + + public DescribeOperationTool(ObjectMapper mapper, ObjectProvider 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: } where 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); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/McpOperationExecutor.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/McpOperationExecutor.java new file mode 100644 index 000000000..28cb56d96 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/McpOperationExecutor.java @@ -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 body = new LinkedMultiValueMap<>(); + body.add("fileInput", bytesResource(inputBytes, inputName)); + addParameters(body, arguments == null ? null : arguments.get("parameters")); + + ResponseEntity 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 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 body, JsonNode params) { + if (params == null || !params.isObject()) { + return; + } + Map map = + mapper.convertValue(params, new TypeReference>() {}); + for (Map.Entry 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; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/McpResponses.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/McpResponses.java new file mode 100644 index 000000000..57ee2cdbd --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/McpResponses.java @@ -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; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/McpToolSupport.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/McpToolSupport.java new file mode 100644 index 000000000..dae928261 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/McpToolSupport.java @@ -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); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingAiTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingAiTool.java new file mode 100644 index 000000000..fc3696b65 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingAiTool.java @@ -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 catalogProvider; + private final ObjectProvider engineClientProvider; + + public StirlingAiTool( + ObjectMapper mapper, + ObjectProvider catalog, + ObjectProvider 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 aiOps() { + McpToolCatalog catalog = catalogProvider.getIfAvailable(); + if (catalog == null) { + return List.of(); + } + return catalog.enabledOps(OperationCategory.AI); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingConvertTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingConvertTool.java new file mode 100644 index 000000000..9e89f6a5c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingConvertTool.java @@ -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 catalog, + ObjectProvider 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; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingDownloadTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingDownloadTool.java new file mode 100644 index 000000000..22fad2384 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingDownloadTool.java @@ -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: }."; + } + + @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 + "'."); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingMiscTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingMiscTool.java new file mode 100644 index 000000000..3d76f47a7 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingMiscTool.java @@ -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 catalog, + ObjectProvider 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; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingPagesTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingPagesTool.java new file mode 100644 index 000000000..a736a7812 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingPagesTool.java @@ -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 catalog, + ObjectProvider 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; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingSecurityTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingSecurityTool.java new file mode 100644 index 000000000..300b6c1d0 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingSecurityTool.java @@ -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 catalog, + ObjectProvider 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; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingUploadTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingUploadTool.java new file mode 100644 index 000000000..ebee4e412 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingUploadTool.java @@ -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: , fileName?: }."; + } + + @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."); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java index 115727eb9..06fc80c75 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java @@ -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 = diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineClient.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineClient.java index dedc558af..b0d726cec 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineClient.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineClient.java @@ -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 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> response; @@ -184,6 +201,7 @@ public class AiEngineClient { .timeout(Duration.ofSeconds(config.getTimeoutSeconds())) .DELETE(); addUserHeader(builder, userId); + addEngineAuthHeader(builder); HttpResponse 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 response = sendRequest(builder.build()); log.debug("AI engine responded with status {}", response.statusCode()); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/cluster/s3/S3FileStoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/cluster/s3/S3FileStoreTest.java index b407041bd..9bb4d08b7 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/cluster/s3/S3FileStoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/cluster/s3/S3FileStoreTest.java @@ -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(); + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/McpConditionalTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/McpConditionalTest.java new file mode 100644 index 000000000..ca6eee5ee --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/McpConditionalTest.java @@ -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"); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/McpServerControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/McpServerControllerTest.java new file mode 100644 index 000000000..041829579 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/McpServerControllerTest.java @@ -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 emptyCatalog = emptyProvider(); + ObjectProvider emptyEngine = emptyProvider(); + ObjectProvider emptyExecutor = emptyProvider(); + List 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 ObjectProvider 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 defaultSupplier) { + return defaultSupplier == null ? null : defaultSupplier.get(); + } + + @Override + public void ifAvailable(Consumer dependencyConsumer) {} + + @Override + public Iterator 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 names = + Set.of( + "stirling_describe_operation", + "stirling_convert", + "stirling_pages", + "stirling_misc", + "stirling_security", + "stirling_ai"); + Set 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()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/catalog/McpToolCatalogTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/catalog/McpToolCatalogTest.java new file mode 100644 index 000000000..71341cf6c --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/catalog/McpToolCatalogTest.java @@ -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 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) f.get(catalog)).put(id, meta); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/catalog/SimpleSchemaGeneratorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/catalog/SimpleSchemaGeneratorTest.java new file mode 100644 index 000000000..601c4c11c --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/catalog/SimpleSchemaGeneratorTest.java @@ -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 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"); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/engine/EngineCapabilityParseTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/engine/EngineCapabilityParseTest.java new file mode 100644 index 000000000..9be2d9908 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/engine/EngineCapabilityParseTest.java @@ -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 parsed = (Map) 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 parsed = (Map) 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(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpApiKeyIntegrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpApiKeyIntegrationTest.java new file mode 100644 index 000000000..b8c1f953e --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpApiKeyIntegrationTest.java @@ -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 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 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 response = + postMcp(b -> b, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}"); + assertThat(response.statusCode()).isEqualTo(401); + } + + @Test + void wrongKey_isRejectedWith401() throws Exception { + HttpResponse 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 response = http.send(req, HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).isNotEqualTo(200); + } + + private HttpResponse postMcp( + java.util.function.UnaryOperator 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; + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpAudienceValidatorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpAudienceValidatorTest.java new file mode 100644 index 000000000..06f23e1ff --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpAudienceValidatorTest.java @@ -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 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)); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpAuthenticationEntryPointTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpAuthenticationEntryPointTest.java new file mode 100644 index 000000000..d1d89779e --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpAuthenticationEntryPointTest.java @@ -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 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 header = ArgumentCaptor.forClass(String.class); + verify(resp).setHeader(eq("WWW-Authenticate"), header.capture()); + assertTrue(header.getValue().contains("http://localhost:8080" + META), header.getValue()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpOAuthIntegrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpOAuthIntegrationTest.java new file mode 100644 index 000000000..53f0b2660 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpOAuthIntegrationTest.java @@ -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 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 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 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 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 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 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 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 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 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 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 audience, String scope, Instant expiry) { + return mintToken("test-user", issuer, audience, scope, expiry); + } + + private static String mintToken( + String subject, String issuer, List 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; + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/tools/CategoryToolDispatchTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/tools/CategoryToolDispatchTest.java new file mode 100644 index 000000000..de843ae42 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/tools/CategoryToolDispatchTest.java @@ -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 catalogProvider = mock(ObjectProvider.class); + when(catalogProvider.getIfAvailable()).thenReturn(catalog); + @SuppressWarnings("unchecked") + ObjectProvider 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")); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/tools/FileToolScopeTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/tools/FileToolScopeTest.java new file mode 100644 index 000000000..966420548 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/tools/FileToolScopeTest.java @@ -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")); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/tools/McpOperationExecutorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/tools/McpOperationExecutorTest.java new file mode 100644 index 000000000..f2d938714 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/tools/McpOperationExecutorTest.java @@ -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 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 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"); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiEngineClientTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiEngineClientTest.java index f4c9af592..259ae55a0 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiEngineClientTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiEngineClientTest.java @@ -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 ok = mock(HttpResponse.class); + when(ok.statusCode()).thenReturn(200); + when(ok.body()).thenReturn("{}"); + org.mockito.ArgumentCaptor 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 ok = mock(HttpResponse.class); + when(ok.statusCode()).thenReturn(200); + when(ok.body()).thenReturn("{}"); + org.mockito.ArgumentCaptor 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)); + } } diff --git a/engine/src/stirling/api/agent_capabilities.py b/engine/src/stirling/api/agent_capabilities.py new file mode 100644 index 000000000..2d552ea55 --- /dev/null +++ b/engine/src/stirling/api/agent_capabilities.py @@ -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} diff --git a/engine/src/stirling/api/app.py b/engine/src/stirling/api/app.py index 1cf4b76c3..699cede06 100644 --- a/engine/src/stirling/api/app.py +++ b/engine/src/stirling/api/app.py @@ -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) diff --git a/engine/src/stirling/api/engine_auth.py b/engine/src/stirling/api/engine_auth.py new file mode 100644 index 000000000..c30b9f742 --- /dev/null +++ b/engine/src/stirling/api/engine_auth.py @@ -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) diff --git a/engine/src/stirling/api/routes/__init__.py b/engine/src/stirling/api/routes/__init__.py index 37572c5e7..1c1e4e1d2 100644 --- a/engine/src/stirling/api/routes/__init__.py +++ b/engine/src/stirling/api/routes/__init__.py @@ -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", diff --git a/engine/src/stirling/api/routes/agent_capabilities.py b/engine/src/stirling/api/routes/agent_capabilities.py new file mode 100644 index 000000000..75f1d0f82 --- /dev/null +++ b/engine/src/stirling/api/routes/agent_capabilities.py @@ -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() diff --git a/engine/src/stirling/config/settings.py b/engine/src/stirling/config/settings.py index bd0499e6b..4cd2bbc9e 100644 --- a/engine/src/stirling/config/settings.py +++ b/engine/src/stirling/config/settings.py @@ -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.""" diff --git a/engine/tests/test_engine_auth.py b/engine/tests/test_engine_auth.py new file mode 100644 index 000000000..d831b1a67 --- /dev/null +++ b/engine/tests/test_engine_auth.py @@ -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 diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index ddc117e01..39db57af9 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -1,5 +1,4 @@ accessInvite = "Invite" -addToDoc = "Add to Document" alphabet = "Alphabet" apply = "Apply" applyAndContinue = "Save & Leave" @@ -7,9 +6,7 @@ areYouSure = "Are you sure you want to leave?" back = "Back" black = "Black" blue = "Blue" -bored = "Bored Waiting?" cancel = "Cancel" -changedCredsMessage = "Credentials changed!" chooseFile = "Choose File" close = "Close" comingSoon = "Coming soon" @@ -24,22 +21,13 @@ confirmCloseSaveFailed = "Saved with errors. {{count}} file{{plural}} could not confirmCloseSaveFailedTitle = "Save Failed" confirmCloseUnsaved = "This file has unsaved changes." confirmCloseUnsavedList = "You have {{count}} file{{plural}} with unsaved changes.\n\n{{fileList}}" -confirmPasswordErrorMessage = "New Password and Confirm New Password must match." custom = "Custom..." customPosition = "Custom Position" customTextTooltip = "Optional custom format for page numbers. Use {n} as placeholder for the number. Example: \"Page {n}\" will show \"Page 1\", \"Page 2\", etc." delete = "Delete" -deleteCurrentUserMessage = "Cannot delete currently logged in user." -deleteUsernameExistsMessage = "The username does not exist and cannot be deleted." details = "Details" -disabledCurrentUserMessage = "The current user cannot be disabled" discardChanges = "Discard & Leave" -discardRedactions = "Discard & Leave" -donate = "Donate" -downgradeCurrentUserLongMessage = "Cannot downgrade current user's role. Hence, current user will not be shown." -downgradeCurrentUserMessage = "Cannot downgrade current user's role" download = "Download" -downloadComplete = "Download Complete" downloadPdf = "Download PDF" downloadUnavailable = "Download unavailable for this item" edit = "Edit" @@ -57,18 +45,13 @@ font = "Font" fontSizeTooltip = "Size of the page number text in points. Larger numbers create bigger text." fontTypeTooltip = "Font family for the page numbers. Choose based on your document style." genericSubmit = "Submit" -goHomepage = "Go to Homepage" -goToPage = "Go" green = "Green" help = "Help" imgPrompt = "Select Image(s)" incorrectPasswordMessage = "Current password is incorrect." info = "Info" insufficientCredits = "Insufficient credits. Required: {{requiredCredits}}, Available: {{currentBalance}}, Shortfall: {{shortfall}}" -invalidPasswordMessage = "The password must not be empty and must not have spaces at the beginning or end." invalidUndoData = "Cannot undo: invalid operation data" -invalidUsernameMessage = "Invalid username, username can only contain letters, numbers and the following special characters @._+- or must be a valid email address." -joinDiscord = "Join our Discord server" keepWorking = "Keep Working" loading = "Loading..." loadingCredits = "Checking credits..." @@ -76,15 +59,12 @@ loadingProStatus = "Checking subscription status..." logOut = "Log out" marginTooltip = "Distance between the page number and the edge of the page." moreOptions = "More Options" -multiPdfDropPrompt = "Select (or drag & drop) all PDFs you require" multiPdfPrompt = "Select PDFs (2+)" never = "Never" no = "No" -noFavourites = "No favourites added" noFileSelected = "No file loaded. Please upload one." noFilesToUndo = "Cannot undo: no files were processed in the last operation" noOperationToUndo = "No operation to undo" -notAuthenticatedMessage = "User not authenticated." nothingToUndo = "Nothing to undo" noticeTopUpOrPlan = "Not enough credits, please top up or upgrade to a plan" noValidFiles = "No valid files to process" @@ -99,26 +79,18 @@ pages = "Pages" pageSelectionPrompt = "Custom Page Selection (Enter a comma-separated list of page numbers 1,5,6 or Functions like 2n+1)" password = "Password" pdfPrompt = "Select PDF(s)" -pendingRedactions = "You have unapplied redactions that will be lost." -pendingRedactionsTitle = "Unapplied Redactions" pin = "Pin File (keep active after tool run)" -poweredBy = "Powered by" pro = "Pro" processingComplete = "Your file is ready." processingCompleteMultiple = "{{count}} files are ready." -processTimeWarning = "Warning: This process can take up to a minute depending on file-size" property = "Property" quickPosition = "Quick Position" red = "Red" reset = "Reset" review = "Review" save = "Save" -saveToBrowser = "Save to Browser" saveUnavailable = "Save unavailable for this item" -seeDockerHub = "See Docker Hub" -selectFillter = "-- Select --" size = "Size" -sponsor = "Sponsor" startingNumberTooltip = "The first number to display. Subsequent pages will increment from this number." submit = "Submit" success = "Success" @@ -137,18 +109,9 @@ unpin = "Unpin File (replace after tool run)" unsavedChanges = "You have unsaved changes to your PDF." unsavedChangesTitle = "Unsaved Changes" unsupported = "Unsupported" -uploadLimit = "Maximum file size:" -uploadLimitExceededPlural = "are too large. Maximum allowed size is" -uploadLimitExceededSingular = "is too large. Maximum allowed size is" -userAlreadyExistsOAuthMessage = "The user already exists as an OAuth2 user." -userAlreadyExistsWebMessage = "The user already exists as an web user." username = "Username" -usernameExistsMessage = "New Username already exists." -userNotFoundMessage = "User not found." -visitGithub = "Visit Github Repository" welcome = "Welcome" white = "White" -WorkInProgess = "Work in progress, May not work or be buggy, Please report any problems!" yes = "Yes" [account] @@ -205,6 +168,9 @@ signedInAs = "Signed in as: {{email}}" [add-page-numbers] tags = "paginate,label,organize,index" +[addAttachments] +tags = "embed,attach,include,attachments,attach files,embed files,include files,add files,file attachment,associated files,supplementary files" + [addAttachments.error] failed = "An error occurred while adding attachments to the PDF." @@ -214,7 +180,6 @@ attachments = "Select Attachments" info = "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel." placeholder = "Choose files..." selectedFiles = "Selected Files" -selectFiles = "Select Files to Attach" submit = "Add Attachments" [AddAttachmentsRequest.error] @@ -238,6 +203,9 @@ header = "Add images to PDFs" tags = "img,jpg,picture,photo" title = "Add Image" +[addImage.canvas] +colorPickerTitle = "Choose stroke colour" + [addImage.error] failed = "An error occurred while adding image to the PDF." @@ -264,8 +232,26 @@ resume = "Resume placement" title = "Add Image Results" [addImage.saved] +defaultCanvasLabel = "Drawing signature" defaultImageLabel = "Uploaded image" defaultLabel = "Image" +defaultTextLabel = "Typed signature" +delete = "Remove" +emptyTitle = "No saved signatures yet" +heading = "Saved signatures" +label = "Label" +limitTitle = "Limit reached" +next = "Next" +personalHeading = "Personal Signatures" +prev = "Previous" +savePersonal = "Save Personal" +saveShared = "Save Shared" +sharedHeading = "Shared Signatures" + +[addImage.saved.type] +canvas = "Drawing" +image = "Upload" +text = "Text" [addImage.step] createDesc = "Upload the image you want to add" @@ -275,14 +261,25 @@ placeDesc = "Click on the PDF to add your image" [addImage.steps] configure = "Configure Image" +[addImage.text] +colorLabel = "Text colour" +fontLabel = "Font" +fontSizeLabel = "Font size" +name = "Text" +placeholder = "Enter text" + +[addImage.type] +canvas = "Canvas" +image = "Image" +saved = "Saved" +text = "Text" + [addPageNumbers] -configuration = "Configuration" +tags = "number,pagination,count,add page numbers,page numbering,page numbers,footer,header,number pages,sequential,pagination tool" customize = "Customize Appearance" customNumberDesc = "e.g., \"Page {n}\" or leave blank for just numbers" -customTextDesc = "Custom Text" fontName = "Font Name" fontSize = "Font Size" -header = "Add Page Numbers" numberPagesDesc = "e.g., 1,3,5-8 or leave blank for all pages" pagesAndStarting = "Pages & Starting Number" positionAndPages = "Position & Pages" @@ -300,17 +297,14 @@ failed = "Add page numbers operation failed" title = "Page Number Results" [addPageNumbers.selectText] -1 = "Select PDF file:" 2 = "Margin Size" -3 = "Position Selection" 4 = "Starting Number" 5 = "Pages to Number" 6 = "Custom Text Format" [addPassword] -completed = "Password protection applied" +tags = "encrypt,password,lock,secure,protect,security,encryption,safeguard,confidential,private,restrict access" desc = "Encrypt your PDF document with a password." -filenamePrefix = "encrypted" submit = "Encrypt" title = "Add Password" @@ -324,7 +318,6 @@ label = "Encryption Key Length" failed = "An error occurred while encrypting the PDF." [addPassword.passwords] -completed = "Passwords configured" stepTitle = "Passwords & Encryption" [addPassword.passwords.owner] @@ -363,7 +356,6 @@ alphabet = "Alphabet" clickToExpand = "Click to expand" customColor = "Custom Text Colour" customDateDesc = "Custom format" -customMargin = "Custom Margin" customPosition = "Drag the stamp to the desired location in the preview window." dateDesc = "Current date" datetimeDesc = "Date and time combined" @@ -374,7 +366,6 @@ filenameDesc = "Filename without extension" filenameFullDesc = "Filename with extension" fileVars = "File Information" fontSize = "Font/Image Size" -header = "Stamp PDF" imageSize = "Image Size" margin = "Margin" metadataDesc = "From PDF document properties" @@ -383,8 +374,6 @@ multiLine = "multi-line" noStampSelected = "No stamp selected. Return to Step 1." opacity = "Opacity" otherVars = "Other" -overrideX = "Override X Coordinate" -overrideY = "Override Y Coordinate" pageNumberDesc = "Current page number" pageVars = "Page Information" position = "Position" @@ -393,7 +382,6 @@ preview = "Preview:" quickPosition = "Select a position on the page to place the stamp." rotation = "Rotation" selectTemplate = "Select a template..." -stampImage = "Stamp Image" stampSetup = "Stamp Setup" stampText = "Stamp Text" stampTextDescription = "Use dynamic variables below. Use @@ for literal @. Use \\n for new lines." @@ -468,19 +456,49 @@ fontSizePlaceholder = "Type or select font size (8-200)" name = "Text content" placeholder = "Enter the text you want to add" +[addText.canvas] +colorPickerTitle = "Choose stroke colour" + +[addText.saved] +defaultCanvasLabel = "Drawing signature" +defaultImageLabel = "Uploaded signature" +defaultLabel = "Signature" +defaultTextLabel = "Typed signature" +delete = "Remove" +emptyTitle = "No saved signatures yet" +heading = "Saved signatures" +label = "Label" +limitTitle = "Limit reached" +next = "Next" +personalHeading = "Personal Signatures" +prev = "Previous" +savePersonal = "Save Personal" +saveShared = "Save Shared" +sharedHeading = "Shared Signatures" + +[addText.saved.type] +canvas = "Drawing" +image = "Upload" +text = "Text" + +[addText.type] +canvas = "Canvas" +image = "Image" +saved = "Saved" +text = "Text" + [adjust-contrast] tags = "color-correction,tune,modify,enhance,colour-correction" [adjustContrast] +tags = "contrast,brightness,saturation,adjust colors,color correction,enhance,lighten,darken,improve quality,color balance,hue,vibrance" adjustColors = "Adjust Colors" basic = "Basic Adjustments" blue = "Blue" brightness = "Brightness:" confirm = "Confirm" contrast = "Contrast:" -download = "Download" green = "Green" -header = "Adjust Colors/Contrast" noPreview = "Select a PDF to preview" red = "Red" saturation = "Saturation:" @@ -493,7 +511,6 @@ failed = "Failed to adjust colors/contrast" title = "Adjusted PDF" [adjustPageScale] -header = "Adjust Page Scale" submit = "Adjust Page Scale" tags = "resize,modify,dimension,adapt" title = "Adjust Page Scale" @@ -537,7 +554,6 @@ title = "Scale Factor" close = "Close" error = "Error" expand = "Expand" -success = "Success" [admin.settings] discard = "Discard" @@ -1110,6 +1126,66 @@ label = "SMTP Port" description = "Username for SMTP authentication" label = "SMTP Username" +[admin.settings.mcp] +apikeyNote = "Clients send a Stirling API key in the X-API-KEY header (or Authorization: Bearer ). The key maps to its owning Stirling user - only provisioned accounts get in, and actions are audited as that user. Manage keys under Account > API Keys." +description = "Expose Stirling's PDF tools and AI agents to MCP clients over an OAuth-protected endpoint." +title = "MCP Server" + +[admin.settings.mcp.allowedOps] +description = "If set, ONLY these operation ids are exposed (an allow-list; comma or space separated). Leave blank to expose all enabled tools except any blocked below." +label = "Allowed tools" + +[admin.settings.mcp.blockedOps] +description = "Operation ids to hide from MCP (comma or space separated), e.g. add-password remove-password. Leave blank to expose all enabled tools." +label = "Blocked tools" + +[admin.settings.mcp.enabled] +description = "When off (default), no /mcp endpoint, metadata, or MCP beans are loaded." +label = "Enable MCP Server" + +[admin.settings.mcp.guide] +step1 = "Enter your OAuth issuer + resource ID above, Save, and restart." +step1ApiKey = "Create an API key under Account > API Keys (each user uses their own)." +step2 = "In your MCP client add a" +step2b = "server pointing at:" +step3 = "The client auto-discovers OAuth from:" +step3ApiKey = "Send the key in an" +step3ApiKeyb = "header (or Authorization: Bearer ). No OAuth or metadata discovery is used." +step4 = "Approve the sign-in; the client retries with a token. Tools appear grouped: convert, pages, misc, security, ai." +step4ApiKey = "Tools appear grouped: convert, pages, misc, security, ai - and every call is audited as the key's owner." +tip = "Tip: register the resource ID as an allowed audience in your IdP. Tested with MCP Inspector and Claude Desktop." +tipApiKey = "Tip: API-key mode needs no external IdP - simplest for self-host. The key maps to its owning Stirling user." +title = "Connect an MCP client" + +[admin.settings.mcp.issuerUri] +description = "Your OAuth2 authorization server (must publish /.well-known/openid-configuration). Required when enabled." +label = "OAuth Issuer URL" + +[admin.settings.mcp.jwksUri] +description = "Leave blank to discover it from the issuer. Set only if your IdP serves keys at a non-standard URL." +label = "JWKS URL (optional)" +placeholder = "Auto-discovered from issuer" + +[admin.settings.mcp.mode] +description = "OAuth needs an external IdP. API key uses a Stirling per-user API key (X-API-KEY) - simplest for self-host." +label = "Authentication mode" + +[admin.settings.mcp.requireAccount] +description = "Only let tokens through if their subject maps to a provisioned, enabled Stirling user." +label = "Require an existing Stirling account" + +[admin.settings.mcp.resourceId] +description = "This server's public /mcp URL. Tokens must list it in their audience (RFC 8707) or they are rejected." +label = "Resource ID" + +[admin.settings.mcp.scopes] +description = "Require mcp.tools.read for read ops and mcp.tools.write for write/AI ops." +label = "Enforce OAuth scopes" + +[admin.settings.mcp.usernameClaim] +description = "JWT claim matched against a Stirling username (e.g. sub, email, preferred_username)." +label = "Username claim" + [admin.settings.plan.noData] message = "Plans data is not available at the moment." title = "No data available" @@ -1480,10 +1556,6 @@ hint = "You have unsaved changes" message = "You have unsaved changes. Do you want to discard them?" title = "Unsaved Changes" -[admin.status] -active = "Active" -inactive = "Inactive" - [adminOnboarding] adminTools = "Finally, we have advanced administration tools like Auditing to track system activity and Usage Analytics to monitor how your users interact with the platform." configButton = "Open Settings to access all system configuration and administrative controls." @@ -1496,35 +1568,8 @@ welcome = "Welcome to the Admin Tour! Let's explore the powerfu wrapUp = "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. You can replay it anytime - just open Settings and find it here in the Tours section under Help." [adminUserSettings] -actions = "Actions" -activeUsers = "Active Users:" -addUser = "Add New User" admin = "Admin" -apiUser = "Limited API User" -authenticated = "Authenticated" -changeUserRole = "Change User's Role" -confirmChangeUserStatus = "Should the user be disabled/enabled?" -confirmDeleteUser = "Should the user be deleted?" -deleteUser = "Delete User" -demoUser = "Demo User (No custom settings)" -disabledUser = "disabled user" -disabledUsers = "Disabled Users:" -editOwnProfil = "Edit own profile" -enabledUser = "enabled user" -extraApiUser = "Additional Limited API User" -forceChange = "Force user to change password on login" -header = "Admin User Control Settings" -internalApiUser = "Internal API User" -lastRequest = "Last Request" -role = "Role" -roles = "Roles" -submit = "Save User" title = "User Control Settings" -totalUsers = "Total Users:" -usage = "View Usage" -user = "User" -usernameInfo = "Username can only contain letters, numbers and the following special characters @._+- or must be a valid email address." -webOnlyUser = "Web Only User" [agents] auto_redaction_description = "Redact PII automatically" @@ -1539,7 +1584,6 @@ doc_summary_description = "Summarise long documents" doc_summary_name = "Summariser" form_filler_description = "Fill PDF forms intelligently" form_filler_name = "Form Filler" -fullscreen_title = "Stirling Agents" pdf_to_markdown_description = "Convert PDFs to clean Markdown" pdf_to_markdown_name = "PDF to Markdown" section_title = "Agents" @@ -1554,18 +1598,13 @@ stirling_tooltip = "Stirling agent" view_all = "View all agents" [analytics] -disable = "Disable analytics" -enable = "Enable analytics" learnMore = "Learn more about our analytics" paragraph1 = "Stirling PDF has opt-in analytics to help us improve the product. We do not track any personal information or file contents." paragraph2 = "Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better." -privacyAssurance = "We do not track any personal information or the contents of your files." -settings = "You can change the settings for analytics in the config/settings.yml file" title = "Do you want to help make Stirling PDF better?" [annotation] annotationStyle = "Annotation style" -applyChanges = "Apply Changes" backgroundColor = "Background colour" borderOff = "Border: Off" borderOn = "Border: On" @@ -1578,36 +1617,20 @@ comment = "Comment" comments = "Comments" contents = "Text" delete = "Delete" -desc = "Use highlight, pen, text, and notes. Changes stay live-no flattening required." drawing = "Drawing" -duplicate = "Duplicate" -editCircle = "Edit Circle" -editInk = "Edit Pen" -editLine = "Edit Line" -editNote = "Edit Note" -editPolygon = "Edit Polygon" editSelectDescription = "Click an existing annotation to edit its colour, opacity, text, or size." -editSelected = "Edit Annotation" -editSquare = "Edit Square" -editStampHint = "To change the image, delete this stamp and add a new one." -editSwitchToSelect = "Switch to Select & Edit to edit this annotation." editText = "Edit" -editTextMarkup = "Edit Text Markup" -ellipse = "Ellipse" -exit = "Exit annotation mode" fillColor = "Fill Colour" fillOpacity = "Fill Opacity" fontSize = "Font size" freehandHighlighter = "Freehand Highlighter" highlight = "Highlight" imagePreview = "Preview" -inkHighlighter = "Freehand Highlighter" insertText = "Insert Text" line = "Line" lineArrow = "Arrow" noBackground = "No background" note = "Note" -noteIcon = "Note Icon" notesStamps = "Notes & Stamps" opacity = "Opacity" pen = "Pen" @@ -1615,12 +1638,8 @@ polygon = "Polygon" polyline = "Polyline" properties = "Properties" rectangle = "Rectangle" -redo = "Redo" replaceText = "Replace Text" saveChanges = "Save Changes" -saveFailed = "Unable to save copy" -saveReady = "Download ready" -savingCopy = "Preparing download..." select = "Select" selectAndMove = "Select and Edit" settings = "Settings" @@ -1638,24 +1657,17 @@ textAlignment = "Text Alignment" textMarkup = "Text Markup" title = "Annotate" underline = "Underline" -undo = "Undo" -unsupportedType = "This annotation type is not fully supported for editing." width = "Width" [app] description = "The Free Adobe Acrobat alternative (10M+ Downloads)" [attachments] -add = "Add Attachment" convertToPdfA3b = "Convert to PDF/A-3b" convertToPdfA3bDescription = "Creates an archival PDF with embedded attachments" convertToPdfA3bTooltip = "PDF/A-3b is an archival format ensuring long-term preservation. It allows embedding arbitrary file formats as attachments. Conversion requires Ghostscript and may take longer for large files." convertToPdfA3bTooltipHeader = "About PDF/A-3b Conversion" convertToPdfA3bTooltipTitle = "What it does" -embed = "Embed Attachment" -header = "Add Attachments" -remove = "Remove Attachment" -submit = "Add Attachments" tags = "attachments,add,remove,embed,file" title = "Add Attachments" @@ -1671,12 +1683,10 @@ notAvailable = "Audit system not available" notAvailableMessage = "The audit system is not configured or not available." [audit.charts] -byTool = "Top Tools Used" byType = "Events by Type" byUser = "Top Users" day = "Day" error = "Error loading charts" -hourlyActivity = "Hourly Activity" month = "Month" noData = "No data for this period" overTime = "Events Over Time" @@ -1713,17 +1723,12 @@ documentName = "Document" endDate = "End date" error = "Error loading events" eventDetails = "Event Details" -failure = "Failure" fileHash = "File Hash" filterByType = "Filter by type" filterByUser = "Filter by user" ipAddress = "IP Address" noEvents = "No events found" -outcome = "Status" -sortAsc = "Sort ascending" -sortDesc = "Sort descending" startDate = "Start date" -success = "Success" timestamp = "Timestamp" title = "Audit Events" type = "Type" @@ -1731,9 +1736,7 @@ user = "User" viewDetails = "View Details" [audit.export] -clearFilters = "Clear" description = "Export audit events to CSV or JSON format. Use filters to limit the exported data." -endDate = "End date" error = "Failed to export data" exportButton = "Export Data" fieldAuthor = "Author (from PDF)" @@ -1745,23 +1748,15 @@ fieldOperationResults = "Operation Results" fieldOutcome = "Outcome (Success/Failure)" fieldTool = "Tool" fieldUsername = "Username" -filterByType = "Filter by type" -filterByUser = "Filter by user" filters = "Filters (Optional)" format = "Export Format" selectFields = "Select Fields to Include" -startDate = "Start date" title = "Export Audit Data" -verboseRequired = "Requires VERBOSE audit level" [audit.filters] -allOutcomes = "All" -failureOnly = "Failures only" last30Days = "Last 30 days" last7Days = "Last 7 days" -outcomeFilter = "Outcome" quickPresets = "Quick filters" -successOnly = "Success only" thisMonth = "This month" today = "Today" @@ -1770,18 +1765,14 @@ activeUsers = "Active Users" attention = "Attention needed" avgLatency = "Avg Latency" error = "Error loading statistics" -errorLoadingStats = "Failed to load statistics" excellent = "Excellent" good = "Good" noData = "N/A" successRate = "Success Rate" -title = "Summary" totalEvents = "Total Events" vsLastPeriod = "vs last period" [audit.systemStatus] -autoRefresh = "Auto-refresh" -autoRefreshLabel = "Auto-refresh every 30s" captureBySettings = "Enable in settings" capturedFields = "Captured Fields" date = "Date" @@ -1798,7 +1789,6 @@ title = "System Status" tool = "Tool" totalEvents = "Total Events" username = "Username" -verboseOnly = "VERBOSE only" [audit.tabs] clearData = "Clear Data" @@ -1809,8 +1799,6 @@ export = "Export" [auth] accessDenied = "Access Denied" insufficientPermissions = "You do not have permission to perform this action." -pleaseLoginAgain = "Please login again." -sessionExpired = "Session Expired" [auth.displayName] guest = "Guest" @@ -1818,7 +1806,6 @@ user = "User" [auto-rename] description = "Automatically finds the title from your PDF content and uses it as the filename." -header = "Auto Rename PDF" submit = "Auto Rename" tags = "auto-detect,header-based,organize,relabel" title = "Auto Rename" @@ -1826,9 +1813,6 @@ title = "Auto Rename" [auto-rename.error] failed = "An error occurred whilst auto-renaming the PDF." -[auto-rename.files] -placeholder = "Select a PDF file in the main view to get started" - [auto-rename.results] title = "Auto-Rename Results" @@ -1845,10 +1829,10 @@ title = "How Auto-Rename Works" bullet1 = "Looks for text that appears to be a title or heading" bullet2 = "Creates a clean, valid filename from the detected title" bullet3 = "Keeps the original name if no suitable title is found" -text = "Automatically finds the title from your PDF content and uses it as the filename." title = "Smart Renaming" [automate] +tags = "workflow,sequence,automation,automate,batch,batch processing,pipeline,chain,multi-step,recurring,scheduled,automatic,process multiple,bulk operations" copyToSaved = "Copy to Saved" desc = "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks." export = "Export" @@ -1863,7 +1847,6 @@ title = "Automate" [automate.config] cancel = "Cancel" description = "Configure the settings for this tool. These settings will be applied when the automation runs." -loading = "Loading tool configuration..." noSettings = "This tool does not have configurable settings." save = "Save Configuration" title = "Configure {{toolName}}" @@ -1905,16 +1888,6 @@ title = "Unsaved Changes" [automate.entryMenu] label = "Open menu for {{title}}" -[automate.files] -placeholder = "Select files to process with this automation" - -[automate.folderScanWarning] -advice = "You can still download the file (e.g. to inspect or hand-edit it), but the unsupported steps will need to be removed before the backend can run it." -cancel = "Cancel" -confirm = "Export anyway" -intro = "Folder scanning runs on the backend, so it can only execute tools that have a backend endpoint. The following step(s) in this automation do not, and will fail when the pipeline runs:" -title = "Some steps cannot run in folder scanning" - [automate.importModal] cancel = "Cancel" confirm = "Import" @@ -1966,27 +1939,18 @@ secureWorkflow = "Security Workflow" secureWorkflowDesc = "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorised access. Password is set to 'password' by default." [autoRename] +tags = "auto-detect,header-based,organize,relabel,auto rename,automatic rename,smart rename,rename by content,filename,file naming,detect title" description = "This tool will automatically rename PDF files based on their content. It analyzes the document to find the most suitable title from the text." [autoSizeSplitPDF] tags = "pdf,split,document,organization" [autoSplitPDF] -description = "Print, Insert, Scan, upload, and let us auto-separate your documents. No manual work sorting needed." dividerDownload2 = "Download 'Auto Splitter Divider (with instructions).pdf'" duplexMode = "Duplex Mode (Front and back scanning)" -formPrompt = "Submit PDF containing Stirling-PDF Page dividers:" -header = "Auto Split PDF" -submit = "Submit" tags = "QR-based,separate,scan-segment,organize" title = "Auto Split PDF" -[autoSplitPDF.selectText] -1 = "Print out some divider sheets from below (Black and white is fine)." -2 = "Scan all your documents at once by inserting the divider sheet between them." -3 = "Upload the single large scanned PDF file and let Stirling PDF handle the rest." -4 = "Divider pages are automatically detected and removed, guaranteeing a neat final document." - [backendHealth] checking = "Checking backend status..." offline = "Backend Offline" @@ -2023,7 +1987,6 @@ whatHappensNext = "What happens next?" error = "Failed to open billing portal" [bookletImposition] -header = "Booklet Imposition" paperSizeNote = "Paper size is automatically derived from your first page." submit = "Create Booklet" tags = "booklet,imposition,printing,binding,folding,signature" @@ -2070,11 +2033,6 @@ title = "Manual Duplex Mode" label = "Right-to-left binding" tooltip = "For Arabic, Hebrew, or other right-to-left languages" -[bookletImposition.spineLocation] -label = "Spine Location" -left = "Left (Standard)" -right = "Right (RTL)" - [bookletImposition.tooltip.advanced] bullet1 = "Right-to-Left Binding: For Arabic, Hebrew, or RTL languages" bullet2 = "Borders: Shows cut lines for trimming" @@ -2214,7 +2172,6 @@ description = "Use your own PKCS#12 certificate file. Provides full control over title = "Upload Custom P12" [certSign] -allSigned = "All participants have signed. Ready to finalize." awaitingSignatures = "Awaiting signatures" chooseCertificate = "Choose Certificate File" chooseJksFile = "Choose JKS File" @@ -2223,7 +2180,6 @@ choosePfxFile = "Choose PFX File" choosePrivateKey = "Choose Private Key File" declined = "Declined" fetchFailed = "Failed to load signing data" -filenamePrefix = "signed" finalized = "Finalized" location = "Location" logoTitle = "Logo" @@ -2231,7 +2187,6 @@ name = "Name" noLogo = "No Logo" notified = "Pending" pageNumber = "Page Number" -partialNote = "You can finalize early with current signatures. Unsigned participants will be excluded." password = "Certificate Password" passwordOptional = "Leave empty if no password" pending = "Pending" @@ -2299,12 +2254,8 @@ stepTitle = "Certificate Format" [certSign.collab.addParticipants] add = "Add {{count}} Participant(s)" -back = "Back" -configureSignatures = "Configure Signature Settings" -continue = "Continue to Signature Settings" reasonHelp = "Pre-set a signing reason for these participants (optional, they can override when signing)" reasonPlaceholder = "e.g. Approval, Review..." -selectUsers = "Select Users" [certSign.collab.finalize] button = "Finalize and Load Signed PDF" @@ -2323,7 +2274,6 @@ includeSummaryPage = "Include Signature Summary Page" includeSummaryPageHelp = "A summary page will be added at the end with all signature metadata. The digital certificate signature boxes on individual pages will be suppressed (wet signatures are unaffected)." [certSign.collab.sessionDetail] -addButton = "Add Participants" addParticipants = "Add Participants" addParticipantsError = "Failed to add participants" backToList = "Back to Sessions" @@ -2336,7 +2286,6 @@ finalizeError = "Failed to finalize session" loadPdfError = "Failed to load signed PDF" loadSignedPdf = "Load Signed PDF into Active Files" messageLabel = "Message" -noAdditionalInfo = "No additional information" owner = "Owner" participantRemoved = "Participant removed" participants = "Participants" @@ -2357,7 +2306,6 @@ title = "Signature Appearance" [certSign.collab.signRequest] addedToFiles = "Document added to active files" -addSignature = "Add Your Signature" addToFiles = "Add to Active Files" advancedSettings = "Advanced Settings" backToList = "Back to Sign Requests" @@ -2370,35 +2318,27 @@ decline = "Decline Request" declineButton = "Decline" deleteSelected = "Delete selected signature" drawSignature = "Draw your signature below" -dueDate = "Due Date" fileTooLarge = "File size must be less than 5MB" fontFamily = "Font Family" fontSize = "Font Size: {{size}}px" fontSizePlaceholder = "Size" from = "From" -invalidCertFile = "Please select a P12 or PFX certificate file" invalidFileType = "Please select an image file" location = "Location (Optional)" locationPlaceholder = "Where are you signing from?" -message = "Message" noCertificate = "Please select a certificate file" noSignatures = "Please place at least one signature on the PDF" p12File = "P12/PFX Certificate File" password = "Certificate Password" -passwordPlaceholder = "Enter password..." penColor = "Pen Color" penSize = "Pen Size: {{size}}px" -placementActive = "Click PDF to place" -placeSignatureButton = "Place Signature on PDF" reason = "Reason (Optional)" reasonPlaceholder = "Why are you signing?" -removeCertFile = "Remove File" removeImage = "Remove Image" savedSignatures = "Saved Signatures" selectFile = "Select Image File" selectSignatureTitle = "Select or Create Signature" signatureInfo = "These settings are configured by the document owner" -signaturePlaced = "Signature placed on page" signatureSettings = "Signature Settings" signatureText = "Signature Text" signatureTextPlaceholder = "Enter your name..." @@ -2430,9 +2370,6 @@ description = "You have placed {{count}} signature(s). Choose your certificate t sign = "Sign Document" title = "Configure Certificate" -[certSign.collab.signRequest.image] -hint = "Upload a PNG or JPG image of your signature" - [certSign.collab.signRequest.mode] move = "Move Signature" place = "Place Signature" @@ -2443,10 +2380,6 @@ draw = "Draw" image = "Upload" text = "Type" -[certSign.collab.signRequest.placeSignature] -message = "Click on the PDF to place your signature" -title = "Place Signature" - [certSign.collab.signRequest.preview] imageAlt = "Selected signature" missing = "No preview" @@ -2576,23 +2509,19 @@ text = "When you check signatures, the tool tells you if they're valid, who sign title = "Checking Signatures" [changeCreds] -changePassword = "You are using default login credentials. Please enter a new password" changeUsername = "Update your username. You will be logged out after updating." -confirmNewPassword = "Confirm New Password" credsUpdated = "Account updated" description = "Changes saved. Please log in again." error = "Unable to update username. Please verify your password and try again." header = "Update Your Account Details" -newPassword = "New Password" newUsername = "New Username" oldPassword = "Current Password" ssoManaged = "Your account is managed by your identity provider." -submit = "Submit Changes" title = "Change Credentials" [changeMetadata] +tags = "edit,modify,update,metadata,properties,document properties,author,title,subject,keywords,creator,producer,info,document info,file properties" filenamePrefix = "metadata" -header = "Change Metadata" submit = "Change" [changeMetadata.advanced] @@ -2645,9 +2574,6 @@ placeholder = "Document producer" [changeMetadata.results] title = "Updated PDFs" -[changeMetadata.settings] -title = "Metadata Settings" - [changeMetadata.standardFields] title = "Standard Fields" @@ -2686,16 +2612,6 @@ title = "Date Fields" text = "Complete metadata deletion to ensure privacy." title = "Remove Existing Metadata" -[changeMetadata.tooltip.header] -title = "PDF Metadata Overview" - -[changeMetadata.tooltip.options] -bullet1 = "Custom Metadata: Add your own key-value pairs" -bullet2 = "Trapped Status: High-quality printing setting" -bullet3 = "Delete All: Remove all metadata for privacy" -text = "Custom fields and privacy controls." -title = "Additional Options" - [changeMetadata.tooltip.standardFields] bullet1 = "Title: Document name or heading" bullet2 = "Author: Person who created the document" @@ -2712,7 +2628,7 @@ true = "True" unknown = "Unknown" [changePermissions] -completed = "Permissions changed" +tags = "permissions,restrictions,rights,access control,allow,deny,printing,copying,editing,modify permissions,security settings,user rights" desc = "Change document restrictions and permissions." submit = "Change Permissions" title = "Change Permissions" @@ -2765,10 +2681,8 @@ copy = "Copy message" [chat.header] agentMenu = "Stirling agent options" clearChat = "Clear chat" -settings = "Agent settings" [chat.input] -attach = "Attach files" placeholder = "What do you want to do?" send = "Send message" @@ -2793,8 +2707,6 @@ compressMany = "Compress these documents" compressOne = "Compress this document" convertMany = "Convert these documents to PDF" convertOne = "Convert this document to PDF" -fileSummary_one = "1 file in workbench ({{types}})" -fileSummary_other = "{{count}} files in workbench ({{types}})" heading = "Get started" mergeMany = "Merge these {{count}} documents into 1" moreFiles = "+{{count}} more" @@ -2815,8 +2727,6 @@ unsupported_capability = "Unsupported capability: {{capability}}" [chat.toolsUsed] summary = "Ran {{count}} tools" -summary_one = "Ran 1 tool" -summary_other = "Ran {{count}} tools" unknownTool = "Unknown tool" [cloudBadge] @@ -2834,7 +2744,6 @@ back = "Back" cancel = "Cancel" close = "Close" collapse = "Collapse" -collapsed = "collapsed" continue = "Continue" copied = "Copied!" copy = "Copy" @@ -2842,58 +2751,40 @@ done = "Done" error = "Error" expand = "Expand" learnMore = "Learn more" -lines = "lines" loading = "Loading..." next = "Next" operation = "this operation" preview = "Preview" previous = "Previous" refresh = "Refresh" -remaining = "remaining" retry = "Retry" save = "Save" used = "used" [compare] -addFilesHint = "Add PDFs in the Files step to enable selection." clearSelected = "Clear selected" clearSlot = "Remove file" cta = "Compare" -header = "Compare PDFs" loading = "Comparing..." newLine = "new-line" -noFiles = "No PDFs available yet" pages = "Pages" tags = "differentiate,contrast,changes,analysis" title = "Compare" [compare.actions] linkScroll = "Link scroll" -linkScrollPan = "Link scroll and pan" placeSideBySide = "Place side by side" resetView = "Reset view" stackVertically = "Stack vertically" unlinkScroll = "Unlink scroll" -unlinkScrollPan = "Unlink scroll and pan" zoomIn = "Zoom in" zoomOut = "Zoom out" -[compare.base] -label = "Original document" -placeholder = "Select the original PDF" - [compare.clear] confirm = "Clear Selected" confirmBody = "This will close the current comparison and take you back to Active Files." confirmTitle = "Clear selected PDFs?" -[compare.comparison] -label = "Edited document" -placeholder = "Select the edited PDF" - -[compare.complex] -message = "One or both of the provided documents are large files, accuracy of comparison may be reduced" - [compare.dropdown] additions = "Additions ({{count}})" additionsLabel = "Additions" @@ -2925,15 +2816,12 @@ message = "One or Both of the provided documents are too large to process" body = "These PDFs together exceed 2,000 pages. Processing can take several minutes." title = "Large comparison in progress" -[compare.mode] -pixel = "Pixel Comparison" -text = "Text Comparison" - [compare.no.text] message = "One or both of the selected PDFs have no text content. Please choose PDFs with text for comparison." [compare.original] label = "Original PDF" +placeholder = "Select the original PDF" [compare.pixel] base = "Original" @@ -2967,19 +2855,11 @@ pagesRendered = "pages rendered" rendering = "rendering" [compare.review] -actionsHint = "Review the comparison, switch document roles, or export the summary." -exportSummary = "Export summary" -switchOrder = "Switch order" title = "Comparison Result" [compare.selection] originalEditedTitle = "Select Original and Edited PDFs" -[compare.slowOperation] -body = "This comparison is taking longer than usual. You can let it continue or cancel it." -cancel = "Cancel comparison" -title = "Still working…" - [compare.status] complete = "Comparison ready" error = "Comparison failed" @@ -3005,9 +2885,8 @@ unlinkedTitle = "Independent scroll & pan enabled" message = "These documents appear highly dissimilar. Comparison was stopped to save time." [compress] -credit = "This service uses qpdf for PDF Compress/Optimisation." +tags = "shrink,reduce,optimize,compress,smaller,downsize,file size,reduce size,minimize,make smaller,decrease size,optimize size" desc = "Compress PDFs to reduce their file size." -header = "Compress PDF" submit = "Compress" title = "Compress" @@ -3040,15 +2919,6 @@ filesize = "File Size" quality = "Quality" title = "Compression Method" -[compress.selectText] -2 = "Optimisation level:" -4 = "Auto mode - Auto adjusts quality to get PDF to exact size" -5 = "Expected PDF Size (e.g. 25MB, 10.8MB, 25KB)" - -[compress.selectText.1] -1 = "1-3 PDF compression,
4-6 lite image compression,
7-9 intense image compression Will dramatically reduce image quality" -_value = "Compression Settings" - [compress.settings] desiredSize = "Desired File Size" desiredSizePlaceholder = "Enter size" @@ -3154,7 +3024,6 @@ publicKeyAriaLabel = "Public API key" purchasedCredits = "Purchased credits" refreshAriaLabel = "Refresh API key" schemaLink = "API Schema Reference" -totalCredits = "Total Credits" usage = "Include this key in the X-API-KEY header with all API requests." [config.apiKeys.alert] @@ -3186,15 +3055,8 @@ integration = "Integration Configuration" security = "Security Configuration" system = "System Configuration" -[connectionMode.status] -localOffline = "Offline mode running" -localOnline = "Offline mode running" -saas = "Connected to Stirling Cloud" -selfhostedChecking = "Connected to self-hosted server (checking...)" -selfhostedOffline = "Self-hosted server unreachable" -selfhostedOnline = "Connected to self-hosted server" - [convert] +tags = "transform,change,convert,PDF to Word,PDF to Excel,PDF to image,Word to PDF,Excel to PDF,PowerPoint to PDF,HTML to PDF,export,import,file conversion,format change,save as" autoRotate = "Auto Rotate" autoRotateDescription = "Automatically rotate images to better fit the PDF page" blackwhite = "Black & White" @@ -3212,64 +3074,39 @@ combineImages = "Combine Images" combineImagesDescription = "Combine all images into one PDF, or create separate PDFs for each image" combineSvgs = "Combine SVGs into single PDF" combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG" -conversionCompleted = "Conversion completed" conversionResults = "Conversion Results" convertFiles = "Convert Files" convertFrom = "Convert from" converting = "Converting..." convertTo = "Convert to" -defaultFilename = "converted_file" desc = "Convert files between different formats" -downloadConverted = "Download Converted File" downloadHtml = "Download HTML intermediate file instead of PDF" dpi = "DPI" ebookToPdf = "eBook → PDF" emailOptions = "Email to PDF Options" emlToPdf = "Email → PDF" errorConversion = "An error occurred while converting the file." -errorNoFiles = "Please select at least one file to convert." -errorNoFormat = "Please select both source and target formats." -errorNotSupported = "Conversion from {{from}} to {{to}} is not supported." -fileFormat = "File Format" -files = "Files" fileToPdf = "Office/Document → PDF" fillPage = "Fill Page" fitDocumentToPage = "Fit Document to Page" fitOption = "Fit Option" grayscale = "Greyscale" -greyscale = "Greyscale" imageOptions = "Image Options" -images = "Images" -imagesExt = "Images (JPG, PNG, etc.)" includeAllRecipients = "Include CC and BCC recipients in header" includeAttachments = "Include email attachments" maintainAspectRatio = "Maintain Aspect Ratio" -markdown = "Markdown" maxAttachmentSize = "Maximum attachment size (MB)" multiple = "Multiple" -noFileSelected = "No file loaded. Use the file panel to add files." -odpExt = "OpenDocument Presentation (.odp)" -odtExt = "OpenDocument Text (.odt)" -officeDocs = "Office Documents (Word, Excel, PowerPoint)" optimizeForEbook = "Optimize PDF for ebook readers (uses Ghostscript)" output = "Output" outputFormat = "Output Format" -outputOptions = "Output Options" pdfaDigitalSignatureWarning = "The PDF contains a digital signature. This will be removed in the next step." -pdfaFormat = "PDF/A Format" pdfaNote = "PDF/A-1b is more compatible, PDF/A-2b supports more features, PDF/A-3b supports embedded files." pdfaOptions = "PDF/A Options" pdfOptions = "PDF Options" pdfToCbr = "PDF → CBR" pdfToCbz = "PDF → CBZ" pdfToEpub = "PDF → EPUB" -pdfxDescription = "PDF/X is an ISO standard PDF subset for reliable printing and graphics exchange." -pdfxDigitalSignatureWarning = "The PDF contains a digital signature. This will be removed in the next step." -pptExt = "PowerPoint (.pptx)" -results = "Results" -rtfExt = "Rich Text Format (.rtf)" -selectedFiles = "Active files" -selectFilesPlaceholder = "Add files to the workbench to get started" selectSourceFormatFirst = "Choose a source format first" settings = "Settings" single = "Single" @@ -3280,17 +3117,11 @@ svgPdfOptions = "SVG to PDF Options" svgToPdf = "SVG → PDF" svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size." targetFormatPlaceholder = "Target format" -textRtf = "Text/RTF" title = "Convert" -txtExt = "Plain Text (.txt)" webOptions = "Web to PDF Options" -wordDoc = "Word Document" -wordDocExt = "Word Document (.docx)" zoomLevel = "Zoom Level" [convert.ebookOptions] -ebookOptions = "eBook to PDF Options" -ebookOptionsDesc = "Options for converting eBooks to PDF" embedAllFonts = "Embed all fonts" embedAllFontsDesc = "Embed all fonts from the eBook into the generated PDF" includePageNumbers = "Include page numbers" @@ -3303,8 +3134,6 @@ optimizeForEbookPdfDesc = "Optimize the PDF for eBook reading (smaller file size [convert.epubOptions] detectChapters = "Detect chapters" detectChaptersDesc = "Detect headings that look like chapters and insert EPUB page breaks" -epubOptions = "PDF to eBook Options" -epubOptionsDesc = "Options for converting PDF to EPUB/AZW3" kindleEink = "Kindle e-Ink (text optimized)" outputFormat = "Output format" outputFormatDesc = "Choose the output format for the ebook" @@ -3374,7 +3203,7 @@ creditsRemaining = "{{current}} of {{total}} remaining" creditsThisMonth = "Monthly credits" current = "Current Plan" customPricing = "Custom" -customSmartFolders = "Custom Smart Folders" +customWatchedFolders = "Custom Watched Folders" dedicatedSupportSlas = "Dedicated support & SLAs" enterpriseSubscription = "Enterprise" everythingInCredits = "Everything in Credits, plus:" @@ -3417,14 +3246,8 @@ unlimitedApiAccess = "Unlimited API access" unlimitedMonthlyCredits = "Site License" unlimitedSeats = "Unlimited seats" -[credits.modal.features] -everythingInCredits = "Everything in Credits, plus:" -everythingInFree = "Everything in Free, plus:" -forRegularWork = "For regular PDF work:" - [crop] autoCrop = "Auto-crop whitespace" -header = "Crop PDF" noFileSelected = "Add a PDF file to begin cropping" reset = "Reset to full PDF" submit = "Apply Crop" @@ -3475,39 +3298,10 @@ title = "How to Crop PDFs" [database] backupCreated = "Database backup successful" -createBackupFile = "Create Backup File" -creationDate = "Creation Date" -deleteBackupFile = "Delete Backup File" -downloadBackupFile = "Download Backup File" -failedImportFile = "Failed to import file" fileName = "File Name" -fileNotFound = "File not found" -fileNullOrEmpty = "File must not be null or empty" -fileSize = "File Size" -header = "Database Import/Export" -importBackupFile = "Import Backup File" -importIntoDatabaseSuccessed = "Import into database successed" -info_1 = "When importing data, it is crucial to ensure the correct structure. If you are unsure of what you are doing, seek advice and support from a professional. An error in the structure can cause application malfunctions, up to and including the complete inability to run the application." -info_2 = "The file name does not matter when uploading. It will be renamed afterward to follow the format backup_user_yyyyMMddHHmm.sql, ensuring a consistent naming convention." -notSupported = "This function is not available for your database connection." -submit = "Import Backup" title = "Database Import/Export" -[decrypt] -cancelled = "Operation cancelled for PDF: {0}" -invalidPassword = "Please try again with the correct password." -invalidPasswordHeader = "Incorrect password or unsupported encryption for PDF: {0}" -noPassword = "No password provided for encrypted PDF: {0}" -passwordPrompt = "This file is password-protected. Please enter the password:" -serverError = "Server error while decrypting: {0}" -success = "File decrypted successfully." -unexpectedError = "There was an error processing the file. Please try again." - [defaultApp] -description = "You can change this later in your system settings." -dismiss = "Dismiss" -message = "Would you like to set Stirling PDF as your default PDF editor?" -notNow = "Not Now" setDefault = "Set Default" title = "Set as Default PDF App" @@ -3517,7 +3311,6 @@ title = "Error" [defaultApp.prompt] message = "Make Stirling PDF your default application for opening PDF files." -title = "Set as Default PDF Editor" [defaultApp.settingsOpened] message = "In Windows Settings, search for 'PDF' and select Stirling PDF as your default app" @@ -3551,6 +3344,7 @@ edit = "Edit" editSecretValue = "Edit secret value" [editTableOfContents] +tags = "bookmarks,contents,edit,table of contents,TOC,outline,navigation,chapters,sections,add bookmarks,edit bookmarks,PDF outline" submit = "Apply table of contents" [editTableOfContents.actions] @@ -3626,12 +3420,9 @@ title = "Bookmarks & outline" [editTableOfContents.workbench] changeFile = "Change PDF" -fileLabel = "Changes will be applied to the currently selected PDF." filePrompt = "Select a PDF from your library or upload a new one to begin." -noFile = "No PDF selected" selectFile = "Select PDF" subtitle = "Import bookmarks, build hierarchies, and apply the outline without cramped side panels." -tabTitle = "Outline workspace" [editTableOfContents.workbench.empty] description = "Select the Edit Table of Contents tool to load its workspace." @@ -3664,62 +3455,15 @@ label = "PDF password" placeholder = "Enter the PDF password" [endpointStatistics] -all = "All" -dataTypeAll = "All" -dataTypeApi = "API" -dataTypeLabel = "Data Type:" -dataTypeUi = "UI" -endpoint = "Endpoint" -failedToLoad = "Failed to load endpoint data. Please try refreshing." -header = "Endpoint Statistics" -home = "Home" -loading = "Loading..." -login = "Login" -numberOfVisits = "Number of Visits" -percentage = "Percentage" -refresh = "Refresh" -retry = "Retry" -selectedVisits = "Selected Visits" -showing = "Showing" title = "Endpoint Statistics" -top = "Top" -top10 = "Top 10" -top20 = "Top 20" -totalEndpoints = "Total Endpoints" -totalVisits = "Total Visits" -visits = "Visits" -visitsTooltip = "Visits: {0} ({1}% of total)" - -[enterpriseEdition] -button = "Upgrade to Pro" -ssoAdvert = "Looking for more user management features? Check out Stirling PDF Pro" -warning = "This feature is only available to Pro users." -yamlAdvert = "Stirling PDF Pro supports YAML configuration files and other SSO features." [error] _value = "Error" -contactTip = "If you're still having trouble, don't hesitate to reach out to us for help. You can submit a ticket on our GitHub page or contact us through Discord:" -copyStack = "Copy Stack Trace" -discordSubmit = "Discord - Submit Support post" dismissAllErrors = "Dismiss All Errors" -encryptedPdfMustRemovePassword = "This PDF is encrypted or password-protected. Please unlock it before converting to PDF/A." generic = "An error occurred" -github = "Submit a ticket on GitHub" -githubSubmit = "GitHub - Submit a ticket" -incorrectPasswordProvided = "The PDF password is incorrect or not provided." -needHelp = "Need help / Found an issue?" -pdfPassword = "The PDF Document is passworded and either the password was not provided or was incorrect" -showStack = "Show Stack Trace" -sorry = "Sorry for the issue!" - -[error.404] -1 = "We can't seem to find the page you're looking for." -2 = "Something went wrong" -head = "404 - Page Not Found | Oops, we tripped in the code!" [extractImages] allowDuplicates = "Save duplicate images" -header = "Extract Images" selectText = "Select image format to convert extracted images to" submit = "Extract" tags = "picture,photo,save,archive,zip,capture,grab" @@ -3735,6 +3479,7 @@ title = "Settings" tags = "extract" [extractPages] +tags = "pull,select,copy,extract,extract pages,get pages,pull out,save pages,export pages,copy pages,select pages,specific pages" submit = "Extract Pages" title = "Extract Pages" @@ -3758,31 +3503,19 @@ description = "Extracts the selected pages into a new PDF, preserving order." openInFileEditor = "Open in File Editor" viewInViewer = "View in Viewer" -[fileChooser] -click = "Click" -dragAndDrop = "Drag & Drop" -dragAndDropImage = "Drag & Drop Image file" -dragAndDropPDF = "Drag & Drop PDF file" -extractPDF = "Extracting..." -hoveredDragAndDrop = "Drag & Drop file(s) here" -or = "or" - [fileDropdownMenu] closeFile = "Close file" [fileEditor] addFiles = "Add Files" -pageCount = "{{count}} pages" -pageCount_one = "{{count}} page" -pageCount_other = "{{count}} pages" [fileManager] active = "Active" +addToWatchedFolder = "Add to Watched Folder" addToUpload = "Add to Upload" changesNotUploaded = "Changes not uploaded" clearAll = "Clear All" clearSelection = "Clear" -clickToUpload = "Click to upload files" closeAllFiles = "Close all files" closeFile = "Close File" cloudFile = "Cloud file" @@ -3795,10 +3528,7 @@ deselectAll = "Uncheck All" details = "File Details" download = "Download" downloadSelected = "Download Files" -dragDrop = "Drag & Drop files here" dropFilesHere = "Drop files here" -failedToLoad = "Failed to load file to active set." -failedToOpen = "Failed to open file. It may have been removed from storage." fileFormat = "Format" fileHistory = "File History" fileName = "Name" @@ -3820,8 +3550,6 @@ leaveShare = "Remove from my list" leaveShareFailed = "Could not remove the shared file." leaveShareSuccess = "Removed from your shared list." loadingFiles = "Loading files..." -loadingHistory = "Loading History..." -localFiles = "Local Files" localOnly = "Local only" makeCopy = "Make a copy" mobileShort = "Mobile" @@ -3829,7 +3557,6 @@ mobileUpload = "Mobile Upload" mobileUploadNotAvailable = "Mobile upload not enabled" myFiles = "My Files" noFiles = "No files available" -noFileSelected = "No files chosen" noFilesFound = "No files found matching your search" noRecentFiles = "No recent files found" openFile = "Open File" @@ -3839,7 +3566,6 @@ openInPageEditor = "Open in Page Editor" owner = "Owner" ownerUnknown = "Unknown" recent = "Recent" -reloadFiles = "Reload Files" removeBoth = "Remove from both" removeFilePrompt = "This file is saved on this device and on your server. Where would you like to remove it from?" removeFileTitle = "Remove file" @@ -3856,7 +3582,6 @@ saveSelected = "Save Files" searchFiles = "Search files..." selectAll = "Check All" selectedCount = "{{count}} checked" -selectedFiles = "Files Added" share = "Share" shared = "Shared" sharedByYou = "Shared by you" @@ -3872,20 +3597,13 @@ sortByDate = "Sort by Date" sortByName = "Sort by Name" sortBySize = "Sort by Size" storage = "Storage" -storageCleared = "Browser cleared storage. Files have been removed. Please re-upload." -storageError = "Storage error occurred" -storageLow = "Storage is running low. Consider removing old files." storageState = "Storage" -subtitle = "Add files to your storage for easy access across tools" -supportMessage = "Powered by browser database storage for unlimited capacity" synced = "Synced" title = "Upload PDF Files" toolChain = "Tools Applied" -totalSelected = "Total Files" unsupported = "Unsupported" unzip = "Unzip" updateOnServer = "Update on Server" -uploadError = "Failed to upload some files." uploadSelected = "Upload Files" uploadToServer = "Upload to Server" @@ -3957,7 +3675,6 @@ cycleBlocked = "Can't move a folder into one of its own subfolders." delete = "Delete" deleteFolder = "Delete folder" deleteFolderBody = "Delete folder \"{{name}}\"?" -deleteFolderConfirm = "Delete folder \"{{name}}\"? Files inside will be moved to All files. {{count}} file(s) affected." deleteFolderContents = "Also delete {{count}} file(s) inside the folder" deleteFolderContentsWarning = "Files will be permanently removed and cannot be recovered." deleteFolderError = "Could not delete the folder. Try again." @@ -3982,18 +3699,14 @@ inWorkspace = "Open" inWorkspaceAria = "Already in workspace" loading = "Loading…" localFoldersUnavailable = "Folders are cloud-only - save a file to the cloud to organise it." -moreActions = "More folder actions" -moveLocalToCloudBlocked = "Local-only files can't be moved into cloud folders. Save them to the cloud first." moveSkippedRemote = "{{count}} file(s) couldn't be moved on the server (no permission or already deleted)." moveTo = "Move to…" myFiles = "My Files" newFolder = "New folder" newFolderStorageDisabled = "Server folder storage isn't enabled. Ask your admin to turn it on." newFolderTabUnavailable = "Switch to All or Cloud to create folders." -newRootFolder = "New folder at root" offlineNoFolderEdits = "Server folder sync unavailable - folder changes are disabled. Check sign-in and storage configuration." open = "Open" -openInWorkbench = "Open in workbench" openVersionInWorkspace = "Open in workspace" originFilter = "Filter by source" quickView = "Quick view" @@ -4017,8 +3730,6 @@ shareDisabledHint = "File sharing isn't enabled on this server. Ask your admin t shareManage = "Manage sharing" showDetails = "Show details" summary = "{{count}} items" -syncFailed = "Folder sync failed: {{message}}" -syncPartial = "Folder sync partial: {{failed}} of {{total}} folders could not be merged." tree = "Folders" upload = "Upload" uploadedToLocal = "Uploaded files start in Local. Use 'Save to cloud' to put them in a folder." @@ -4115,15 +3826,11 @@ tabName.shared = "Shared with me" tabName.sharedByMe = "Shared by me" tabs.all = "All" tabs.ariaLabel = "File views" -tabs.cloud = "Cloud" -tabs.local = "Local" tabs.recent = "Recent" tabs.shared = "Shared with me" tabs.sharedByMe = "Shared by me" treeMenu.actions = "Folder actions for {{name}}" -treeMenu.collapse = "Collapse folder" treeMenu.delete = "Delete folder" -treeMenu.expand = "Expand folder" treeMenu.newSubfolder = "New subfolder" treeMenu.rename = "Rename" typeFilter.allTypes = "All types" @@ -4133,39 +3840,20 @@ viewMode.label = "View mode" viewMode.list = "List view" [fileToPDF] -credit = "This service uses LibreOffice and Unoconv for file conversion." -header = "Convert any file to PDF" -submit = "Convert to PDF" -supportedFileTypes = "Supported file types should include the below however for a full updated list of supported formats, please refer to the LibreOffice documentation" -supportedFileTypesInfo = "Supported File types" tags = "transformation,format,document,picture,slide,text,conversion,office,docs,word,excel,powerpoint" title = "File to PDF" [fileUpload] -addFiles = "Add Files" -backToTools = "Back to Tools" -chooseFromStorage = "Choose a file from storage or upload a new PDF" -chooseFromStorageMultiple = "Choose files from storage or upload new PDFs" -dragFilesInOrClick = "Drag files in or click \"Add Files\" to browse" -dropFileHere = "Drop file here or click to upload" dropFilesHere = "Drop files here or click the upload button" dropFilesHereOpen = "Drop files here or click the open button" filesAvailable = "files available" loadFromStorage = "Load from Storage" -loading = "Loading..." noFilesInStorage = "No files available in storage. Upload some files first." noFilesInStorageOpen = "No files available in storage. Open some files first." open = "Open" openFile = "Open File" openFiles = "Open Files" -or = "or" -pdfFilesOnly = "PDF files only" -selectFile = "Select a file" -selectFiles = "Select files" selectFromStorage = "Select from Storage" -selectPdfToEdit = "Select a PDF to edit" -selectPdfToView = "Select a PDF to view" -supportedFileTypes = "Supported file types" upload = "Upload" uploadFile = "Upload File" uploadFiles = "Upload Files" @@ -4191,22 +3879,15 @@ welcomeMessage = "For security reasons, you must change your password on your fi welcomeTitle = "Welcome!" [flatten] -filenamePrefix = "flattened" -flattenOnlyForms = "Flatten only forms" -header = "Flatten PDF" +tags = "simplify,remove,interactive,flatten,flatten form,remove form fields,make static,finalize form,lock form,disable editing,convert to image,non-editable" submit = "Flatten" title = "Flatten" [flatten.error] failed = "An error occurred while flattening the PDF." -[flatten.files] -placeholder = "Select a PDF file in the main view to get started" - [flatten.options] -note = "Flattening removes interactive elements from the PDF, making them non-editable." stepTitle = "Flatten Options" -title = "Flatten Options" [flatten.options.flattenOnlyForms] desc = "Only flatten form fields, leaving other interactive elements intact" @@ -4220,9 +3901,6 @@ placeholder = "e.g. 150" [flatten.results] title = "Flatten Results" -[flatten.steps] -settings = "Settings" - [flatten.tooltip.description] bullet1 = "Text boxes become regular text (can't be edited)" bullet2 = "Checkboxes and buttons become pictures" @@ -4258,7 +3936,6 @@ close = "Close sidebar" [getPdfInfo] downloadJson = "Download JSON" downloads = "Downloads" -header = "Get Info on PDF" indexTitle = "Index" noneDetected = "None detected" noResults = "Run the tool to generate a report." @@ -4269,12 +3946,8 @@ tags = "infomation,data,stats,statistics" title = "Get Info on PDF" [getPdfInfo.compliance] -compliant = "Compliant" failed = "Failed" failedCount = "failed" -nonCompliant = "Non-Compliant" -none = "No standards detected" -notDetected = "Not Detected" noVerification = "No Verification Performed" noVerificationDesc = "PDF standards compliance was not verified for this document." passed = "Passed" @@ -4302,7 +3975,6 @@ size = "Size" xobjects = "XObject Counts" [getPdfInfo.report] -entryLabel = "Full information summary" shortTitle = "PDF Information" [getPdfInfo.sections] @@ -4328,7 +4000,6 @@ compliancePassed = "{{standards}} compliant" created = "Created" documentInfo = "Document Information" fileSize = "File Size" -hasCompliance = "Has compliance standards" language = "Language" modified = "Modified" noCompliance = "No Compliance Standards" @@ -4438,18 +4109,6 @@ message = "Create a free account to save your work, access more features, and su signUp = "Sign Up Free" title = "You're using Stirling PDF as a guest!" -[home] -alphabetical = "Alphabetical" -desc = "Your locally hosted one-stop-shop for all your PDF needs." -globalPopularity = "Global Popularity" -hideFavorites = "Hide Favourites" -legacyHomepage = "Old homepage" -newHomePage = "Try our new homepage!" -searchBar = "Search for features..." -setFavorites = "Set Favourites" -showFavorites = "Show Favourites" -sortBy = "Sort by:" - [home.addAttachments] desc = "Add or remove embedded files (attachments) to/from a PDF" tags = "embed,attach,include,attachments,attach files,embed files,include files,add files,file attachment,associated files,supplementary files" @@ -4501,12 +4160,10 @@ tags = "auto-detect,header-based,organize,relabel,auto rename,automatic rename,s title = "Auto Rename PDF File" [home.autoSizeSplitPDF] -desc = "Automatically split PDFs by file size or page count" tags = "auto,split,size" title = "Auto Split by Size/Count" [home.autoSplitPDF] -desc = "Auto Split Scanned PDF with physical scanned page splitter QR Code" tags = "auto,split,QR,auto split,QR code,QR split,barcode,automatic split,divider page,separator page,scan divider,batch scanning" title = "Auto Split Pages" @@ -4600,7 +4257,6 @@ tags = "info,metadata,details,PDF info,document info,properties,file info,get in title = "Get ALL Info on PDF" [home.manageCertificates] -desc = "Import, export, or delete digital certificate files used for signing PDFs." tags = "certificates,import,export,manage certificates,digital certificates,certificate management,PFX,P12,keystore,import certificate,export certificate,certificate store,PKI" title = "Manage Certificates" @@ -4645,7 +4301,6 @@ tags = "AI,agent,comment,annotate,sticky note,review,feedback,notes" title = "Add AI comments" [home.pdfOrganiser] -desc = "Remove/Rearrange pages in any order" tags = "organize,rearrange,reorder,organise,arrange pages,sort,move pages,delete pages,remove pages,page management,page organizer,page organiser,resequence" title = "Organise" @@ -4755,17 +4410,14 @@ tags = "divide,separate,break,split,extract pages,separate pages,divide document title = "Split" [home.splitByChapters] -desc = "Split a PDF into multiple files based on its chapter structure." tags = "split,chapters,structure,split by chapters,split by bookmarks,bookmarks,outline,table of contents,TOC split,chapter split,divide by sections" title = "Split PDF by Chapters" [home.splitBySections] -desc = "Divide each page of a PDF into smaller horizontal and vertical sections" tags = "split,sections,divide,split by sections,grid split,divide pages,split into sections,cut pages,divide grid,section split,horizontal split,vertical split" title = "Split PDF by Sections" [home.swagger] -desc = "View API documentation and test endpoints" tags = "API,documentation,test,swagger,API docs,REST API,endpoints,developer,API reference,API testing,OpenAPI,integration,developer docs" title = "API Documentation" @@ -4785,7 +4437,6 @@ tags = "validate,verify,certificate,validate signature,verify signature,check si title = "Validate PDF Signature" [home.viewPdf] -desc = "View, annotate, draw, add text or images" title = "View/Edit PDF" [home.watermark] @@ -4794,25 +4445,9 @@ tags = "stamp,mark,overlay,watermark,branding,logo,confidential,draft,copyright, title = "Add Watermark" [HTMLToPDF] -credit = "Uses WeasyPrint" -cssMediaType = "Change the CSS media type of the page." -defaultHeader = "Enable Default Header (Name and page number)" header = "HTML To PDF" -help = "Accepts HTML files and ZIPs containing html/css/images etc required" -marginBottom = "Bottom margin of the page in millimeters. (Blank to default)" -marginLeft = "Left margin of the page in millimeters. (Blank to default)" -marginRight = "Right margin of the page in millimeters. (Blank to default)" -marginTop = "Top margin of the page in millimeters. (Blank to default)" -none = "None" -pageHeight = "Height of the page in centimeters. (Blank to default)" -pageWidth = "Width of the page in centimeters. (Blank to default)" -print = "Print" -printBackground = "Render the background of websites." -screen = "Screen" -submit = "Convert" tags = "markup,web-content,transformation,convert" title = "HTML To PDF" -zoom = "Zoom level for displaying the website." [image.upload] hint = "Upload a PNG, JPG, SVG, or other image file to place on the PDF. SVG files will be converted to PNG for compatibility." @@ -4823,20 +4458,9 @@ placeholder = "Select image file" tags = "conversion,img,jpg,picture,photo" [imageToPDF] -fillPage = "Fill Page" -fitDocumentToImage = "Fit Page to Image" header = "Image to PDF" -maintainAspectRatio = "Maintain Aspect Ratios" -selectLabel = "Image Fit Options" -submit = "Convert" title = "Image to PDF" -[imageToPDF.selectText] -2 = "Auto rotate PDF" -3 = "Multi file logic (Only enabled if working with multiple images)" -4 = "Merge into single PDF" -5 = "Convert to separate PDFs" - [infoBanner] dismiss = "Dismiss" @@ -4860,7 +4484,6 @@ linkExpires = "Link expires" passwordMismatch = "Passwords do not match" passwordPlaceholder = "Enter your password" passwordRequired = "Password is required" -passwordTooShort = "Password must be at least 6 characters" signIn = "Sign in" validating = "Validating invitation..." validationError = "Failed to validate invitation link" @@ -4889,36 +4512,21 @@ showCookieBanner = "Cookie Preferences" terms = "Terms and Conditions" [licenses] -header = "3rd Party Licences" -license = "Licence" -module = "Module" -nav = "Licences" title = "3rd Party Licences" -version = "Version" [localMode] toolUnavailable = "This tool requires an account. Sign in to Stirling Cloud or connect to a self-hosted server to use it." -[localMode.banner] -message = "Sign in to unlock all tools." -signIn = "Sign In" -title = "Running locally" - [localMode.toolPicker] message = "Sign in to unlock all tools." signIn = "Sign In" [login] accountCreatedSuccess = "Account created successfully! You can now sign in." -alreadyLoggedIn = "You are already logged in to" -alreadyLoggedIn2 = "devices. Please log out of the devices and try again." backToSignIn = "Back to sign in" -cancel = "Cancel" changePasswordWarning = "Please change your password after logging in for the first time" credentialsUpdated = "Your credentials have been updated. Please sign in again." -debug = "Debug" defaultCredentials = "Default Login Credentials" -dontHaveAccount = "Don't have an account? Sign up" email = "Email" enterEmail = "Enter your email" enterEmailForMagicLink = "Enter your email for magic link" @@ -4927,29 +4535,12 @@ enterPassword = "Enter your password" enterUsername = "Enter username" failedToSignIn = "Failed to sign in with {{provider}}: {{message}}" forgotPassword = "Forgot your password?" -header = "Sign in" -home = "Home" -invalid = "Invalid username or password." -locked = "Your account has been locked." loggingIn = "Logging In..." logIn = "Log In" login = "Login" -logoutMessage = "You have been logged out." magicLinkSent = "Magic link sent to {{email}}! Check your email and click the link to sign in." -maxUsersReached = "Maximum number of users reached for your current license. Please contact the administrator to upgrade your plan or add more seats." mfaCode = "Authentication Code" -mfaPromptBody = "Enter the authentication code from your authenticator app to continue." -mfaPromptTitle = "Two-factor authentication" mfaRequired = "Two-factor code required" -oauth2AccessDenied = "Access Denied" -oAuth2AdminBlockedUser = "Registration or logging in of non-registered users is currently blocked. Please contact the administrator." -oAuth2AutoCreateDisabled = "OAUTH2 Auto-Create User Disabled" -oauth2InvalidIdToken = "Invalid Id Token" -oauth2invalidRequest = "Invalid Request" -oauth2InvalidTokenResponse = "Invalid Token Response" -oauth2InvalidUserInfoResponse = "Invalid User Info Response" -oauth2RequestNotFound = "Authorization request not found" -oAuth2RequiresLicense = "OAuth/SSO login requires a Server or Enterprise license. Please contact the administrator to upgrade your plan." or = "Or" password = "Password" passwordChangedSuccess = "Password changed successfully! Please sign in with your new password." @@ -4957,11 +4548,8 @@ passwordResetSent = "Password reset link sent to {{email}}! Check your email and passwordUpdatedSuccess = "Your password has been updated successfully." pleaseEnterBoth = "Please enter both email and password" pleaseEnterEmail = "Please enter your email address" -relyingPartyRegistrationNotFound = "No relying party registration found" -rememberme = "Remember me" resetHelp = "Enter your email to receive a secure link to reset your password. If the link has expired, please request a new one." resetYourPassword = "Reset your password" -saml2RequiresLicense = "SAML login requires an Enterprise license. Please contact the administrator to upgrade your plan." sending = "Sending…" sendMagicLink = "Send Magic Link" sendResetLink = "Send reset link" @@ -4969,21 +4557,14 @@ sessionExpired = "Your session has expired. Please sign in again." signin = "Sign in" signInAnonymously = "Sign Up as a Guest" signingIn = "Signing in..." -signinTitle = "Please sign in" signInWith = "Sign in with" -signOut = "Sign Out" -ssoSignIn = "Login via Single Sign-on" subtitle = "Sign back in to Stirling PDF" title = "Sign in" -toManySessions = "You have too many active sessions" unexpectedError = "Unexpected error: {{message}}" updatePassword = "Update password" useEmailInstead = "Login with email" useMagicLink = "Use magic link instead" -userIsDisabled = "User is deactivated, login is currently blocked with this username. Please contact the administrator." username = "Username" -verifyingMfa = "Verifying..." -verifyMfa = "Verify code" youAreLoggedIn = "You are logged in!" [login.slides.edit] @@ -5008,10 +4589,7 @@ small = "Small" xLarge = "Extra Large" [MarkdownToPDF] -credit = "Uses WeasyPrint" header = "Markdown To PDF" -help = "Work in progress" -submit = "Convert" tags = "markup,web-content,transformation,convert,md" title = "Markdown To PDF" @@ -5062,8 +4640,6 @@ capture = "Capture Photo" chooseMethod = "Choose Upload Method" chooseMethodDescription = "Select how you want to scan and upload documents" clearBatch = "Clear" -connected = "Connected" -connecting = "Connecting..." edgeDetection = "Edge Detection" fileDescription = "Upload existing photos or documents from your device" fileUpload = "File Upload" @@ -5072,7 +4648,6 @@ flashlight = "Flashlight" httpsRequired = "Camera access requires HTTPS or localhost. Please use HTTPS or access via localhost." noSession = "Invalid Session" noSessionMessage = "Please scan a valid QR code to access this page." -preview = "Preview" processing = "Processing..." retake = "Retake" selectFilesPrompt = "Select files to upload" @@ -5081,7 +4656,6 @@ sessionExpired = "This session has expired. Please refresh and try again." sessionInvalid = "Session Error" sessionNotFound = "Session not found. Please refresh and try again." sessionValidationError = "Unable to verify session. Please try again." -settings = "Settings" title = "Mobile Scanner" upload = "Upload" uploadAll = "Upload All" @@ -5092,7 +4666,6 @@ uploadSuccessMessage = "Your images have been transferred." validating = "Validating session..." [mobileUpload] -connected = "Mobile device connected" description = "Scan to upload photos. Images auto-convert to PDF." descriptionNoConvert = "Scan to upload photos from your mobile device." error = "Connection Error" @@ -5103,55 +4676,14 @@ instructions = "Scan with your phone camera. Images convert to PDF automatically instructionsNoConvert = "Scan with your phone camera to upload files." pollingError = "Error checking for files" sessionCreateError = "Failed to create session" -sessionId = "Session ID" title = "Upload from Mobile" [multiTool] -addFile = "Add File" -delete = "Delete" -deleteSelected = "Delete Selected" -deselectAll = "Deselect All" -downloadAll = "Export" -downloadSelected = "Export Selected" -dragDropMessage = "Page(s) Selected" -header = "PDF Multi Tool" -insertPageBreak = "Insert Page Break" -moveLeft = "Move Left" -moveRight = "Move Right" -page = "Page" -redo = "Redo (CTRL + Y)" -rotateLeft = "Rotate Left" -rotateRight = "Rotate Right" -selectAll = "Select All" -selectedPages = "Selected Pages" -selectPages = "Page Select" -split = "Split" tags = "Multi Tool,Multi operation,UI,click drag,front end,client side,interactive,intractable,move,delete,migrate,divide" title = "PDF Multi Tool" -undo = "Undo (CTRL + Z)" -uploadPrompts = "File Name" - -[multiTool-advert] -message = "This feature is also available in our multi-tool page. Check it out for enhanced page-by-page UI and additional features!" [navbar] -allTools = "Tools" -darkmode = "Dark Mode" -favorite = "Favorites" -language = "Languages" -multiTool = "Multi Tool" -recent = "New and recently updated" search = "Search" -settings = "Settings" - -[navbar.sections] -advance = "Advanced" -convertFrom = "Convert from PDF" -convertTo = "Convert to PDF" -edit = "View & Edit" -organize = "Organize" -popular = "Popular" -security = "Sign & Security" [oauth.error] message = "Authentication was not successful. You can close this window and try again." @@ -5162,11 +4694,7 @@ message = "You can close this window and return to Stirling PDF." title = "Authentication Successful" [ocr] -credit = "This service uses qpdf and Tesseract for OCR." desc = "Cleanup scans and detects text from images within a PDF and re-adds it as text." -header = "Cleanup Scans / OCR (Optical Character Recognition)" -help = "Please read this documentation on how to use this for other languages and/or use not in docker" -submit = "Process PDF with OCR" tags = "recognition,text,image,scan,read,identify,detection,editable" title = "OCR / Scan Cleanup" @@ -5184,20 +4712,6 @@ submit = "Process OCR and Review" [ocr.results] title = "OCR Results" -[ocr.selectText] -1 = "Select languages that are to be detected within the PDF (Ones listed are the ones currently detected):" -10 = "OCR Mode" -11 = "Remove images after OCR (Removes ALL images, only useful if part of conversion step)" -12 = "Render Type (Advanced)" -2 = "Produce text file containing OCR text alongside the OCR'ed PDF" -3 = "Correct pages were scanned at a skewed angle by rotating them back into place" -4 = "Clean page so its less likely that OCR will find text in background noise. (No output change)" -5 = "Clean page so its less likely that OCR will find text in background noise, maintains cleanup in output." -6 = "Ignores pages that have interactive text on them, only OCRs pages that are images" -7 = "Force OCR, will OCR Every page removing all original text elements" -8 = "Normal (Will error if PDF contains text)" -9 = "Additional Settings" - [ocr.settings] title = "Settings" @@ -5275,18 +4789,11 @@ filesButton = "The Files button on the Quick Access bar allows fileSources = "You can upload new files or access recent files from here. For the tour, we'll just use a sample file." finish = "Finish" next = "Next" -pageEditor = "The Page Editor allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting." pinButton = "You can use the Pin button if you'd rather your files stay active after running tools on them." -previous = "Previous" results = "After the tool has finished running, the Review step will show a preview of the results in this panel, and allow you to undo the operation or download the file. " runButton = "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs." -selectControls = "The Right Rail contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language." selectCropTool = "Let's select the Crop tool to demonstrate how to use one of the tools." -startTour = "Start Tour" -startTourDescription = "Take a guided tour of Stirling PDF's key features" toolInterface = "This is the Crop tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet." -viewer = "The Viewer lets you read and annotate your PDFs." -viewSwitcher = "Use these controls to select how you want to view your PDFs." workbench = "This is the Workbench - the main area where you view and edit your PDFs." wrapUp = "You're all set! You can replay this tour anytime - just open Settings and find it here in the Tours section under Help." @@ -5335,21 +4842,12 @@ freeTitle = "Server License" overLimitBody = "Our licensing permits up to {{freeTierLimit}} users for free per server. You have {{overLimitUserCopy}} Stirling users. To continue uninterrupted, upgrade to the Stirling Server plan - unlimited seats, PDF text editing, and full admin control for $99/server/mo." overLimitTitle = "Server License Needed" seePlans = "See Plans →" -skip = "Skip for now" upgrade = "Upgrade now →" [onboarding.tourOverview] body = "Stirling PDF V2 ships with dozens of tools and a refreshed layout. Take a quick tour to see what changed and where to find the features you need." title = "Tour Overview" -[onboarding.welcomeModal] -description = "Would you like to take a quick 1-minute tour to learn the key features and how to get started?" -dontShowAgain = "Don't Show Again" -helpHint = "You can always access this tour later from the Help button in the bottom left." -maybeLater = "Maybe Later" -startTour = "Start Tour" -title = "Welcome to Stirling PDF!" - [onboarding.welcomeSlide] body = "Stirling PDF is now ready for teams of all sizes. This update includes a new layout, powerful new admin capabilities, and our most requested feature - Edit Text." title = "Welcome to Stirling" @@ -5365,19 +4863,14 @@ wrapUp = "That is what is new in V2. Open the Tours menu anytim [overlay-pdfs] desc = "Overlay one PDF on top of another" -header = "Overlay PDF Files" submit = "Submit" tags = "Overlay" title = "Overlay PDFs" -[overlay-pdfs.baseFile] -label = "Select Base PDF File" - [overlay-pdfs.counts] item = "Count for file" label = "Overlay Counts (for Fixed Repeat Mode)" noFiles = "Add overlay files to configure counts" -placeholder = "Enter comma-separated counts (e.g., 2,3,1)" [overlay-pdfs.error] failed = "An error occurred while overlaying PDFs." @@ -5435,23 +4928,7 @@ deselectAll = "None" selectAll = "Check All" [pageEditor] -actualSize = "Actual Size" -addFileNotImplemented = "Add file not implemented in demo" -closePdf = "Close PDF" -deleted = "Deleted:" -fitToWidth = "Fit to Width" -insertedPageBreak = "Inserted page break at:" -movedLeft = "Moved left:" -movedRight = "Moved right:" -noPdfLoaded = "No PDF loaded. Please upload a PDF to edit." -reset = "Reset Changes" -rotatedLeft = "Rotated left:" -rotatedRight = "Rotated right:" -save = "Save Changes" -splitAt = "Split at:" title = "Page Editor" -zoomIn = "Zoom In" -zoomOut = "Zoom Out" [pageEditor.emptyState] body = "Add files to start editing pages" @@ -5465,9 +4942,6 @@ rotateRight = "Rotate Selected Right" undo = "Undo" [pageExtracter] -header = "Extract Pages" -placeholder = "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)" -submit = "Extract" title = "Extract Pages" [pageLayout] @@ -5476,7 +4950,6 @@ borderWidth = "Border Thickness" bottom = "Bottom Margin" cols = "Columns" colsPlaceholder = "Enter columns" -header = "Multi Page Layout" innerMargin = "Inner Margin" left = "Left Margin" pagesPerSheet = "Pages per sheet:" @@ -5566,16 +5039,6 @@ label = "Reading Direction:" ltr = "Left to Right" rtl = "Right to Left" -[pageLayout.tooltip.addBorder] -text = "Draws border lines around each page cell for cutting guides or visual separation." -title = "Add Borders" - -[pageLayout.tooltip.arrangement] -bullet1 = "By Rows: Fill row by row (left-to-right or right-to-left)." -bullet2 = "By Columns: Fill top-to-bottom, column by column." -text = "Controls the order pages fill the grid:" -title = "Page Arrangement" - [pageLayout.tooltip.header] title = "Page Layout Guide" @@ -5585,12 +5048,6 @@ bullet2 = "Custom: Set rows and columns manually." text = "Choose how the grid is configured:" title = "Mode" -[pageLayout.tooltip.orientation] -bullet1 = "Portrait: Taller than wide." -bullet2 = "Landscape: Wider than tall." -text = "Sets the output sheet orientation:" -title = "Orientation" - [pageLayout.tooltip.overview] text = "Fit multiple pages onto a single sheet for handouts or to save paper." title = "What is Page Layout?" @@ -5599,45 +5056,22 @@ title = "What is Page Layout?" text = "Choose how many pages per sheet (e.g. 4 → 2×2, 9 → 3×3)." title = "Pages per Sheet (Default Mode)" -[pageLayout.tooltip.readingDirection] -bullet1 = "LTR: Left to right." -bullet2 = "RTL: Right to left." -text = "Controls the horizontal order of pages:" -title = "Reading Direction" - [pageLayout.tooltip.rowsCols] text = "Set exact grid dimensions. Total pages per sheet = rows × columns." title = "Rows & Columns (Custom Mode)" [pageRemover] -header = "PDF Page remover" -pagesToDelete = "Pages to delete (Enter a comma-separated list of page numbers) :" -placeholder = "(e.g. 1,2,6 or 1-10,15-30)" -submit = "Delete Pages" title = "Page Remover" [pageSelection.tooltip] description = "Choose which pages to use for the operation. Supports single pages, ranges, formulas, and the all keyword." -[pageSelection.tooltip.advanced] -title = "Advanced Features" - -[pageSelection.tooltip.basic] -bullet1 = "Individual pages: 1,3,5" -bullet2 = "Page ranges: 3-6 or 10-15" -bullet3 = "All pages: all" -text = "Select specific pages from your PDF document using simple syntax." -title = "Basic Usage" - [pageSelection.tooltip.complex] bullet1 = "1,3-5,8,2n → pages 1, 3–5, 8, plus evens" bullet2 = "10-,2n-1 → from page 10 to end + odd pages" description = "Mix different types." title = "Complex Combinations" -[pageSelection.tooltip.examples] -title = "Examples" - [pageSelection.tooltip.header] title = "Page Selection Guide" @@ -5655,13 +5089,6 @@ bullet4 = "4n-1 → pages 3, 7, 11, 15…" description = "Use n in formulas for patterns." title = "Mathematical Functions" -[pageSelection.tooltip.operators] -and = "AND: & or \"and\" - require both conditions (e.g., 1-50 & even)" -comma = "Comma: , or | - combine selections (e.g., 1-10, 20)" -not = "NOT: ! or \"not\" - exclude pages (e.g., 3n & not 30)" -text = "AND has higher precedence than comma. NOT applies within the document range." -title = "Operators" - [pageSelection.tooltip.ranges] bullet1 = "3-6 → selects pages 3–6" bullet2 = "10-15 → selects pages 10–15" @@ -5673,53 +5100,28 @@ title = "Page Ranges" bullet1 = "all → selects all pages" title = "Special Keywords" -[pageSelection.tooltip.syntax] -text = "Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported." -title = "Syntax Basics" - -[pageSelection.tooltip.syntax.bullets] -keywords = "Keywords: odd, even" -numbers = "Numbers/ranges: 5, 10-20" -progressions = "Progressions: 3n, 4n+1" - -[pageSelection.tooltip.tips] -bullet1 = "Page numbers start from 1 (not 0)" -bullet2 = "Spaces are automatically removed" -bullet3 = "Invalid expressions are ignored" -text = "Keep these guidelines in mind:" -title = "Tips" - [payment] autoClose = "This window will close automatically..." -billingPeriod = "Billing Period" canCloseWindow = "You can now close this window." checkoutInstructions = "Complete your purchase in the browser window that just opened. After payment is complete, return here and click the button below to refresh your billing information." checkoutOpened = "Checkout Opened in Browser" closeLater = "I'll Do This Later" -emailInvalid = "Please enter a valid email address" -enterpriseNote = "Seats can be adjusted in checkout (1-1000)." error = "Payment Error" generatingLicense = "Generating your license key..." -installationId = "Installation ID" licenseActivated = "License activated! Your license key has been saved. A confirmation email has been sent to your registered email address." licenseDelayed = "Payment successful! Your license is being generated. You will receive an email with your license key shortly. If you don't receive it within 10 minutes, please contact support." licenseDelayedMessage = "Your license key is being generated. Please check your email shortly or contact support." licenseInstructions = "This has been added to your installation. You will receive a copy in your email as well." licenseKey = "Your License Key" -licenseKeyProcessing = "License Key Processing" licensePollingError = "Payment successful but we couldn't retrieve your license key automatically. Please check your email or contact support with your payment confirmation." licenseRetrievalError = "Payment successful but license retrieval failed. You will receive your license key via email. Please contact support if you don't receive it within 10 minutes." licenseSaveError = "Failed to save license key. Please contact support with your license key to complete activation." monthly = "Monthly" paymentCanceled = "Payment was canceled. No charges were made." -paymentSuccess = "Payment successful! Retrieving your license key..." perMonth = "/month" -perYear = "/year" preparing = "Preparing your checkout..." redirecting = "Redirecting to secure checkout..." refreshBilling = "I've Completed Payment - Refresh Billing" -stripeNotConfigured = "Stripe Not Configured" -stripeNotConfiguredMessage = "Stripe payment integration is not configured. Please contact your administrator." success = "Payment Successful!" successMessage = "Your subscription has been activated successfully. You will receive a confirmation email shortly." syncError = "Payment successful but license sync failed. Your license will be updated shortly. Please contact support if issues persist." @@ -5736,25 +5138,17 @@ description = "We'll use this to send your license key and receipts." emailLabel = "Email Address" emailPlaceholder = "your@email.com" modalTitle = "Get Started - {{planName}}" -title = "Enter Your Email" [payment.paymentStage] -backToPlan = "Back to Plan Selection" modalTitle = "Complete Payment - {{planName}}" -selectedPlan = "Selected Plan" [payment.planStage] -basePrice = "Base Price" billedYearly = "Billed yearly at {{currency}}{{amount}}" modalTitle = "Select Billing Period - {{planName}}" savePercent = "Save {{percent}}%" savingsAmount = "You save {{amount}}" -savingsNote = "Save {{percent}}% with annual billing" -seatPrice = "Per Seat" selectMonthly = "Select Monthly" selectYearly = "Select Yearly" -title = "Choose Your Billing Period" -totalForSeats = "Total ({{count}} seats)" [pdfCommentAgent] submit = "Generate comments" @@ -5775,25 +5169,10 @@ title = "Commented PDF" title = "Comment instructions" [pdfOrganiser] -header = "PDF Page Organiser" placeholder = "(e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)" -submit = "Rearrange Pages" tags = "duplex,even,odd,sort,move" title = "Page Organiser" -[pdfOrganiser.desc] -BOOKLET_SORT = "Arrange pages for booklet printing (last, first, second, second last, …)." -CUSTOM = "Use a custom sequence of page numbers or expressions to define a new order." -DUPLEX_SORT = "Interleave fronts then backs as if a duplex scanner scanned all fronts, then all backs (1, n, 2, n-1, …)." -DUPLICATE = "Duplicate each page according to the custom order count (e.g., 4 duplicates each page 4×)." -ODD_EVEN_MERGE = "Merge two PDFs by alternating pages: odd from the first, even from the second." -ODD_EVEN_SPLIT = "Split the document into two outputs: all odd pages and all even pages." -REMOVE_FIRST = "Remove the first page from the document." -REMOVE_FIRST_AND_LAST = "Remove both the first and last pages from the document." -REMOVE_LAST = "Remove the last page from the document." -REVERSE_ORDER = "Flip the document so the last page becomes first and so on." -SIDE_STITCH_BOOKLET_SORT = "Arrange pages for side‑stitch booklet printing (optimised for binding on the side)." - [pdfOrganiser.mode] 1 = "Custom Page Order" 10 = "Odd-Even Merge" @@ -5822,6 +5201,7 @@ REVERSE_ORDER = "Flip the document so the last page becomes first and so on." SIDE_STITCH_BOOKLET_SORT = "Arrange pages for side‑stitch booklet printing (optimized for binding on the side)." [pdfTextEditor] +tags = "edit text,modify text,change text,edit content,update text,rewrite,correct,amend,redline,revise,text editor,content editor" conversionFailed = "Failed to convert PDF. Please try again." converting = "Converting PDF to editable format..." currentFile = "Current file: {{name}}" @@ -5835,26 +5215,15 @@ viewLabel = "PDF Editor" [pdfTextEditor.actions] applyChanges = "Apply Changes" downloadCopy = "Download Copy" -downloadJson = "Download JSON" -generatePdf = "Generate PDF" reset = "Reset Changes" -saveChanges = "Save Changes" [pdfTextEditor.badges] earlyAccess = "Early Access" modified = "Edited" -unsaved = "Edited" - -[pdfTextEditor.disclaimer] -alpha = "This alpha viewer is still evolving-certain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing." -heading = "Preview Limitations" -previewVariance = "Some visuals (such as table borders, shapes, or annotation appearances) may not display exactly in the preview. The exported PDF keeps the original drawing commands whenever possible." -textFocus = "This workspace focuses on editing text and repositioning embedded images. Complex page artwork, form widgets, and layered graphics are preserved for export but are not fully editable here." [pdfTextEditor.empty] dropzone = "Drag and drop a PDF here, or click to browse" dropzoneWithFiles = "Select a file from the Files tab, or drag and drop a PDF here, or click to browse" -subtitle = "Load a PDF or JSON file to begin editing text content." title = "No document loaded" [pdfTextEditor.errors] @@ -5916,13 +5285,14 @@ paragraphDescription = "Groups aligned lines into multi-line paragraph text boxe singleLineDescription = "Keeps each PDF text line as a separate text box." title = "Text Grouping Mode" -[pdfTextEditor.options.manualGrouping] -descriptionInline = "Tip: Hold Ctrl (Cmd) or Shift to multi-select text boxes. A floating toolbar will appear above the selection so you can merge, ungroup, or adjust widths." - [pdfTextEditor.pageType] paragraph = "Paragraph page" sparse = "Sparse text" +[pdfTextEditor.stages] +processing = "Processing" +uploading = "Uploading" + [pdfTextEditor.tooltip.alpha] text = "This alpha viewer is still evolving-certain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing." title = "Alpha Viewer" @@ -5966,164 +5336,78 @@ title = "Welcome to PDF Text Editor (Early Access)" [PDFToCSV] header = "PDF to CSV" -prompt = "Choose page to extract table" -submit = "Extract" title = "PDF to CSV" [PDFToHTML] -credit = "This service uses pdftohtml for file conversion." header = "PDF to HTML" -submit = "Convert" tags = "web content,browser friendly" title = "PDF to HTML" [pdfToImage] -blackwhite = "Black and White (May lose data!)" -color = "Colour" -colorType = "Colour type" -grey = "Greyscale" -header = "PDF to Image" -info = "Python is not installed. Required for WebP conversion." -multi = "Multiple Images, one image per page" -placeholder = "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)" -selectText = "Image Format" -single = "Single Big Image Combing all pages" -singleOrMultiple = "Page to Image result type" -submit = "Convert" tags = "conversion,img,jpg,picture,photo" title = "PDF to Image" [PDFToMarkdown] header = "PDF To Markdown" -submit = "Convert" tags = "markup,web-content,transformation,convert,md" title = "PDF To Markdown" [pdfToPDFA] -credit = "This service uses libreoffice for PDF/A conversion" header = "PDF To PDF/A" -outputFormat = "Output format" -pdfWithDigitalSignature = "The PDF contains a digital signature. This will be removed in the next step." -submit = "Convert" tags = "archive,long-term,standard,conversion,storage,preservation" -tip = "Currently does not work for multiple inputs at once" title = "PDF To PDF/A" [pdfToPDFX] -credit = "This service uses Ghostscript for PDF/X conversion" -header = "PDF To PDF/X" -outputFormat = "Output format" -pdfWithDigitalSignature = "The PDF contains a digital signature. This will be removed in the next step." -submit = "Convert" tags = "print,standard,conversion,production,prepress,archive" -tip = "Currently does not work for multiple inputs at once" title = "PDF To PDF/X" [PDFToPresentation] -credit = "This service uses LibreOffice for file conversion." header = "PDF to Presentation" -submit = "Convert" tags = "slides,show,office,microsoft" title = "PDF to Presentation" -[PDFToPresentation.selectText] -1 = "Output file format" - [PdfToSinglePage] tags = "single page" [pdfToSinglePage] +tags = "combine,merge,single,single page,one page,merge to single,combine all,stitch pages,concatenate vertical,long page,poster" description = "This tool will merge all pages of your PDF into one large single page. The width will remain the same as the original pages, but the height will be the sum of all page heights." -filenamePrefix = "single_page" -header = "PDF To Single Page" submit = "Convert To Single Page" title = "PDF To Single Page" [pdfToSinglePage.error] failed = "An error occurred whilst converting to single page." -[pdfToSinglePage.files] -placeholder = "Select a PDF file in the main view to get started" - [pdfToSinglePage.results] title = "Single Page Results" [PDFToText] -credit = "This service uses LibreOffice for file conversion." header = "PDF to RTF (Text)" -submit = "Convert" tags = "richformat,richtextformat,rich text format" title = "PDF to RTF (Text)" -[PDFToText.selectText] -1 = "Output file format" - [PDFToWord] -credit = "This service uses LibreOffice for file conversion." header = "PDF to Word" -submit = "Convert" tags = "doc,docx,odt,word,transformation,format,conversion,office,microsoft,docfile" title = "PDF to Word" -[PDFToWord.selectText] -1 = "Output file format" - [PDFToXLSX] header = "PDF to Excel" -prompt = "Choose pages to extract tables" -submit = "Convert" tags = "spreadsheet,excel,xlsx,table,extract,convert" title = "PDF to Excel (XLSX)" [PDFToXML] -credit = "This service uses LibreOffice for file conversion." header = "PDF to XML" -submit = "Convert" tags = "data-extraction,structured-content,interop,transformation,convert" title = "PDF to XML" [permissions] -header = "Change Permissions" -submit = "Change" tags = "read,write,edit,print" title = "Change Permissions" -warning = "Warning to have these permissions be unchangeable it is recommended to set them with a password via the add-password page" - -[permissions.selectText] -1 = "Select PDF to change permissions" -10 = "Prevent printing different formats" -2 = "Permissions to set" -3 = "Prevent assembly of document" -4 = "Prevent content extraction" -5 = "Prevent extraction for accessibility" -6 = "Prevent filling in form" -7 = "Prevent modification" -8 = "Prevent annotation modification" -9 = "Prevent printing" [pipeline] -configureButton = "Configure" -defaultOption = "Custom" -deletePrompt = "Are you sure you want to delete pipeline" -header = "Pipeline Menu (Beta)" -help = "Pipeline Help" -scanHelp = "Folder Scanning Help" -submitButton = "Submit" tags = "automate,sequence,scripted,batch-process" title = "Pipeline" -uploadButton = "Upload Custom" - -[pipelineOptions] -addOperationButton = "Add operation" -header = "Pipeline Configuration" -pipelineHeader = "Pipeline:" -pipelineNameLabel = "Pipeline Name" -pipelineNamePrompt = "Enter pipeline name here" -saveButton = "Download" -saveForFolderScanning = "Save for Folder Scanning" -saveSettings = "Save Operation Settings" -selectOperation = "Select Operation" -validateButton = "Validate" [plan] contact = "Contact Us" @@ -6134,7 +5418,6 @@ featureComparison = "Feature Comparison" from = "From" hideComparison = "Hide Feature Comparison" included = "Included" -includedInCurrent = "Included in Your Plan" licensedSeats = "Licensed: {{count}} seats" manage = "Manage" perMonth = "/month" @@ -6235,12 +5518,8 @@ cta = "See plans" overLimit = "more than {{limit}}" title = "Free self-hosted limit reached" -[plan.manageSubscription] -description = "Manage your subscription, billing, and payment methods" - [plan.period] month = "month" -perUserPerMonth = "/user/month" [plan.pro] highlight1 = "Unlimited Tool Usage" @@ -6250,16 +5529,10 @@ name = "Pro" [plan.static] activateLicense = "Activate Your License" -checkoutInstructions = "Complete your purchase in the Stripe tab. After payment, return here and refresh the page to activate your license. You will also receive an email with your license key." -checkoutOpened = "Checkout Opened" -contactSales = "Contact Sales" contactToUpgrade = "Contact us to upgrade or customize your plan" getLicense = "Get Server License" -maxUsers = "Max Users" -message = "Online billing is not currently configured. To upgrade your plan or manage subscriptions, please contact us directly." monthlyBilling = "Monthly Billing" selectPeriod = "Select Billing Period" -title = "Billing Information" upgradeToEnterprise = "Upgrade to Enterprise" upTo = "Up to" yearlyBilling = "Yearly Billing" @@ -6280,7 +5553,6 @@ successMessage = "Your license has been successfully activated. You can now clos [plan.team] name = "Team" -siteLicense = "Site License" [plan.trial] badge = "Trial" @@ -6297,14 +5569,8 @@ subscriptionScheduled = "Subscription scheduled - starts {{date}}" title = "Free Trial Active" [printFile] -header = "Print File to Printer" -submit = "Print" title = "Print File" -[printFile.selectText] -1 = "Select File to Print" -2 = "Enter Printer Name" - [provider.googledrive] name = "Google Drive" scope = "File Import" @@ -6508,6 +5774,7 @@ label = "SMTP Username" [quickAccess] access = "Access" +watchedFolders = "Watched Folders" accessAddPerson = "Add another person" accessBack = "Back" accessCopyLink = "Copy link" @@ -6530,12 +5797,9 @@ accessSelectedFile = "Selected file" accessSendInvite = "Send Invite" accessTitle = "Document Access" accessYou = "You" -account = "Account" activeSessions = "Active Sessions" activeTab = "Active" activity = "Activity" -adminSettings = "Admin Settings" -allSessions = "All Sessions" allTools = "Tools" automate = "Automate" back = "Back" @@ -6544,7 +5808,6 @@ certSign = "Certificate Sign" completedSessions = "Completed Sessions" completedTab = "Completed" config = "Config" -createNew = "Create New Request" createSession = "Create Signing Request" dueDate = "Due date (optional)" files = "Files" @@ -6568,7 +5831,6 @@ selectUsers = "Select users to sign" selectUsersPlaceholder = "Choose participants..." sendingRequest = "Sending..." settings = "Settings" -showMeAround = "Show me around" sign = "Sign" signatureRequests = "Signature Requests" signYourself = "Sign Yourself" @@ -6596,7 +5858,6 @@ title = "Redact" colorLabel = "Box Colour" convertPDFToImageLabel = "Convert PDF to PDF-Image" customPaddingLabel = "Custom Extra Padding" -header = "Auto Redact" useRegexLabel = "Use Regex" wholeWordSearchLabel = "Whole Word Search" @@ -6614,54 +5875,17 @@ title = "Words to Redact" failed = "An error occurred while redacting the PDF." [redact.manual] -activate = "Activate Redaction Tool" -active = "Redaction Mode Active" apply = "Apply" -applyChanges = "Apply Changes" -applyRedactions = "Apply Redactions" applyWarning = "⚠️ Permanent application, cannot be undone and the data underneath will be deleted" -boxRedaction = "Box draw redaction" colorLabel = "Redaction Colour" -colourPicker = "Colour Picker" controlsTitle = "Manual Redaction Controls" -convertPDFToImageLabel = "Convert PDF to PDF-Image (Used to remove text behind the box)" -export = "Export" -findCurrentOutlineItem = "Find current outline item" -header = "Manual Redaction" instructions = "Select text or draw areas on the PDF to mark content for redaction." -markArea = "Mark Area" -markText = "Mark Text" -nextPage = "Next Page" -noMarks = "No redaction marks. Use the tools above to mark content for redaction." -pageBasedRedaction = "Page-based Redaction" -pendingLabel = "Pending:" -previousPage = "Previous Page" -showAttachments = "Show Attachments" -showDocumentOutline = "Show Document Outline (double-click to expand/collapse all items)" -showLayers = "Show Layers (double-click to reset all layers to the default state)" -showThumbnails = "Show Thumbnails" -textBasedRedaction = "Text-based Redaction" title = "Redaction Tools" -toggleSidebar = "Toggle Sidebar" -upload = "Upload" -zoom = "Zoom" -zoomIn = "Zoom in" -zoomOut = "Zoom out" - -[redact.manual.pageRedactionNumbers] -placeholder = "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)" -title = "Pages" - -[redact.manual.redactionColor] -title = "Redaction Colour" [redact.modeSelector] automatic = "Automatic" -automaticDesc = "Redact text based on search terms" automaticDisabledTooltip = "Select files in the file manager to redact multiple files at once" manual = "Manual" -manualComingSoon = "Manual redaction coming soon" -manualDesc = "Click and drag to redact specific areas" mode = "Mode" title = "Redaction Method" @@ -6732,16 +5956,9 @@ title = "Common Examples" title = "Words to Redact" [releases] -footer = "Releases" -header = "Release Notes" -note = "Release notes are only available in English" title = "Release Notes" -[releases.current] -version = "Current Release" - [removeAnnotations] -header = "Remove Annotations" submit = "Remove" tags = "comments,highlight,notes,markup,remove" title = "Remove Annotations" @@ -6751,7 +5968,6 @@ failed = "An error occurred while removing annotations from the PDF." [removeAnnotations.info] description = "This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents." -title = "About Remove Annotations" [removeAnnotations.settings] title = "Settings" @@ -6763,7 +5979,7 @@ title = "What it does" title = "About Remove Annotations" [removeBlanks] -header = "Remove Blank Pages" +tags = "delete,clean,empty,remove blank,delete blank pages,empty pages,white pages,remove empty,clean up,cleanup blank" submit = "Remove blank pages" title = "Remove Blanks" @@ -6808,13 +6024,9 @@ title = "White Percentage Threshold" [removeBlanks.whitePercent] label = "White Percentage Threshold" -unit = "%" [removeCertSign] description = "This tool will remove digital certificate signatures from your PDF document." -filenamePrefix = "unsigned" -header = "Remove the digital certificate from the PDF" -selectPDF = "Select a PDF file:" submit = "Remove Signature" tags = "authenticate,PEM,P12,official,decrypt" title = "Remove Certificate Signature" @@ -6822,15 +6034,11 @@ title = "Remove Certificate Signature" [removeCertSign.error] failed = "An error occurred whilst removing certificate signatures." -[removeCertSign.files] -placeholder = "Select a PDF file in the main view to get started" - [removeCertSign.results] title = "Certificate Removal Results" [removeImage] -header = "Remove image" -removeImage = "Remove image" +tags = "remove,delete,clean,remove image,delete image,strip images,remove pictures,delete photos,clean images,reduce size,remove graphics" submit = "Remove image" title = "Remove image" @@ -6843,11 +6051,7 @@ title = "Remove Images Results" [removeImagePdf] tags = "Remove Image,Page operations,Back end,server side" -[removeMetadata] -submit = "Remove Metadata" - [removePages] -filenamePrefix = "pages_removed" submit = "Remove Pages" tags = "Remove pages,delete pages" title = "Remove Pages" @@ -6855,9 +6059,6 @@ title = "Remove Pages" [removePages.error] failed = "An error occurred whilst removing pages." -[removePages.files] -placeholder = "Select a PDF file in the main view to get started" - [removePages.pageNumbers] error = "Invalid page number format. Use numbers, ranges (1-5), or mathematical expressions (2n+1)" label = "Pages to Remove" @@ -6888,17 +6089,8 @@ bullet4 = "Open ranges: 5- (removes from page 5 to end)" text = "Specify which pages to remove from your PDF. You can select individual pages, ranges, or use mathematical expressions." title = "Page Selection" -[removePages.tooltip.safety] -bullet1 = "Always preview your selection before processing" -bullet2 = "Keep a backup of your original file" -bullet3 = "Page numbers start from 1, not 0" -bullet4 = "Invalid page numbers will be ignored" -text = "Important considerations when removing pages:" -title = "Safety Tips" - [removePassword] desc = "Remove password protection from your PDF document." -filenamePrefix = "decrypted" submit = "Remove Password" tags = "secure,Decrypt,security,unpassword,delete password" title = "Remove Password" @@ -6907,7 +6099,6 @@ title = "Remove Password" failed = "An error occurred while removing the password from the PDF." [removePassword.password] -completed = "Password configured" label = "Current Password" placeholder = "Enter current password" stepTitle = "Remove Password" @@ -6919,6 +6110,7 @@ title = "Decrypted PDFs" description = "Removing password protection requires the password that was used to encrypt the PDF. This will decrypt the document, making it accessible without a password." [reorganizePages] +tags = "rearrange,reorder,organize,reorganize,move pages,page order,sort pages,arrange pages,shuffle,resequence" submit = "Reorganize Pages" [reorganizePages.error] @@ -6932,8 +6124,6 @@ title = "Settings" [repair] description = "This tool will attempt to repair corrupted or damaged PDF files. No additional settings are required." -filenamePrefix = "repaired" -header = "Repair PDFs" submit = "Repair" tags = "fix,restore,correction,recover" title = "Repair" @@ -6941,32 +6131,17 @@ title = "Repair" [repair.error] failed = "An error occurred whilst repairing the PDF." -[repair.files] -placeholder = "Select a PDF file in the main view to get started" - [repair.results] title = "Repair Results" [replace-color] -previewOverlayOpacity = "Preview overlay opacity" -previewOverlayTransparency = "Preview overlay transparency" -previewOverlayVisibility = "Show preview overlay" submit = "Replace" title = "Replace-Invert-Color" -[replace-color.options] -fill = "Fill colour" -gradient = "Gradient" - [replace-color.selectText] 1 = "Replace or invert colour options" 10 = "Choose text Color" 11 = "Choose background Color" -12 = "Choose start colour" -13 = "Choose end colour" -2 = "Default (preset high contrast colours)" -3 = "Custom (choose your own colours)" -4 = "Full invert (invert all colours)" 5 = "High contrast color options" 6 = "White text on black background" 7 = "Black text on white background" @@ -7019,6 +6194,7 @@ text = "Completely invert all colours in the PDF, creating a negative-like effec title = "Invert All Colours" [rotate] +tags = "turn,flip,orient,rotate,orientation,landscape,portrait,90 degrees,180 degrees,clockwise,anticlockwise,counter-clockwise,fix orientation" rotateLeft = "Rotate Anticlockwise" rotateRight = "Rotate Clockwise" selectRotation = "Select Rotation Angle (Clockwise)" @@ -7042,19 +6218,14 @@ text = "Rotate your PDF pages clockwise or anticlockwise in 90-degree increments title = "Rotate Settings Overview" [sanitize] -completed = "Sanitisation completed successfully" +tags = "clean,purge,remove,sanitize,sanitise,remove scripts,remove javascript,remove metadata,strip metadata,security,clean document,remove hidden data,privacy" desc = "Remove potentially harmful elements from PDF files." -filenamePrefix = "sanitised" sanitizationResults = "Sanitisation Results" submit = "Sanitise PDF" title = "Sanitise" [sanitize.error] failed = "An error occurred while sanitising the PDF." -generic = "Sanitisation failed" - -[sanitize.files] -placeholder = "Select a PDF file in the main view to get started" [sanitize.options] note = "Select the elements you want to remove from the PDF. At least one option must be selected." @@ -7085,38 +6256,18 @@ desc = "Remove XMP metadata from the PDF" label = "Remove XMP Metadata" [sanitize.steps] -files = "Files" -results = "Results" settings = "Settings" [sanitizePdf] tags = "clean,secure,safe,remove-threats" [sanitizePDF] -header = "Sanitise a PDF file" -submit = "Sanitize PDF" title = "Sanitise PDF" -[sanitizePDF.selectText] -1 = "Remove JavaScript actions" -2 = "Remove embedded files" -3 = "Remove XMP metadata" -4 = "Remove links" -5 = "Remove fonts" -6 = "Remove Document Info Metadata" - [scalePages] -header = "Adjust page-scale" -keepPageSize = "Original Size" -pageSize = "Size of a page of the document." -scaleFactor = "Zoom level (crop) of a page." -submit = "Submit" +tags = "resize,adjust,scale,page size,resize page,scale page,change size,adjust size,enlarge,shrink page,fit to page,A4,letter size" title = "Adjust page-scale" -[ScannerImageSplit] -info = "Python is not installed. It is required to run." -tags = "separate,auto-detect,scans,multi-photo,organize" - [ScannerImageSplit.selectText] 1 = "Angle Threshold:" 10 = "Extra padding (in pixels) around each saved photo so edges aren't cut." @@ -7131,6 +6282,7 @@ tags = "separate,auto-detect,scans,multi-photo,organize" [scannerImageSplit] submit = "Extract Image Scans" +tags = "separate,auto-detect,scans,multi-photo,organize" title = "Extracted Images" [scannerImageSplit.error] @@ -7165,8 +6317,6 @@ placeholder = "Enter search term..." searching = "Searching..." title = "Search PDF" -[selfHosted] - [selfHosted.offline] hideTools = "Hide unavailable tools ▴" messageNoFallback = "Tools are unavailable until your server comes back online." @@ -7177,7 +6327,6 @@ toolNotAvailableLocally = "Your Stirling-PDF server is offline and \"{{endpoint} [session] expired = "Your session has expired. Please refresh the page and try again." -refreshPage = "Refresh Page" [sessionManagement.tooltip] header = "Managing Signing Sessions" @@ -7219,6 +6368,7 @@ advanced = "Advanced" database = "Database" endpoints = "Endpoints" features = "Features" +mcp = "MCP Server" storageSharing = "File Storage & Sharing" systemSettings = "System Settings" title = "Configuration" @@ -7474,31 +6624,13 @@ people = "People" teams = "Teams" title = "Workspace" -[setup] -description = "Get started by choosing how you want to use Stirling PDF" -welcome = "Welcome to Stirling PDF" - [setup.login] connectingTo = "Connecting to:" -hideInstructions = "Hide instructions" -instructions = "To enable login on your Stirling PDF server:" -instructionsEnvVar = "Set the environment variable:" -instructionsOrYml = "Or in settings.yml:" -instructionsRestart = "Then restart your server for the changes to take effect." oauthPending = "Opening browser for authentication..." orContinueWith = "Or continue with email" -serverRequirement = "Note: The server must have login enabled." -showInstructions = "How to enable?" -signInWith = "Sign in with" skipSignIn = "Continue without signing in" sso = "Single Sign-On" submit = "Login" -subtitle = "Enter your credentials to continue" -title = "Sign In" - -[setup.login.email] -label = "Email" -placeholder = "Enter your email" [setup.login.error] emptyEmail = "Please enter your email" @@ -7506,24 +6638,7 @@ emptyPassword = "Please enter your password" emptyUsername = "Please enter your username" oauthFailed = "OAuth login failed. Please try again." -[setup.login.password] -label = "Password" -placeholder = "Enter your password" - -[setup.login.username] -label = "Username" -placeholder = "Enter your username" - -[setup.mode.saas] -description = "Sign in with your Stirling account" -title = "Stirling Cloud" - -[setup.mode.selfhosted] -description = "Connect to your own Stirling PDF server with your personal account" -title = "Self-Hosted Server" - [setup.saas] -subtitle = "Sign in with your Stirling account" title = "Sign in to Stirling" [setup.selfhosted] @@ -7535,7 +6650,6 @@ title = "Sign in to Server" [setup.selfhosted.unreachable] changeServer = "Connect to a different server" -changeServerLocked = "Your organisation has restricted this app to a specific server" continueOffline = "Use local tools instead" message = "Could not reach {{url}}. Check that the server is running and accessible." retry = "Retry" @@ -7562,33 +6676,14 @@ step2 = "Or set security.enableLogin=true in settings.yml" step3 = "Restart the server" title = "Login Not Enabled" -[setup.server.type] -saas = "Stirling PDF SaaS" -selfhosted = "Self-hosted server" - [setup.server.url] description = "Enter the full URL of your self-hosted Stirling PDF server" label = "Server URL" -[setup.step1] -description = "Offline or Server" -label = "Choose Mode" - -[setup.step2] -description = "Self-hosted server" -label = "Select Server" - -[setup.step3] -description = "Enter credentials" -label = "Login" - [showJS] done = "JavaScript extracted" -downloadJS = "Download Javascript" -header = "Show Javascript" processing = "Extracting JavaScript..." results = "Result" -singleFileWarning = "This tool only supports one file at a time. Please select a single file." submit = "Show" tags = "JS" title = "Show Javascript" @@ -7596,10 +6691,8 @@ title = "Show Javascript" [showJS.view] title = "Extracted JavaScript" -[sidebar] -toggle = "Toggle Sidebar" - [sign] +tags = "signature,autograph,e-sign,electronic signature,digital signature,sign document,approval,signoff,authorize,endorse,ink signature,handwriting" activate = "Activate Signature Placement" add = "Add" addToAll = "Add to all pages" @@ -7772,7 +6865,6 @@ createFailed = "Failed to create signing request" [signup] accountCreatedSuccessfully = "Account created successfully! You can now sign in." -alreadyHaveAccount = "Already have an account? Sign in" checkEmailConfirmation = "Check your email for a confirmation link to complete your registration." confirmPassword = "Confirm password" confirmPasswordPlaceholder = "Confirm password" @@ -7805,67 +6897,45 @@ small = "Small" x-large = "X-Large" [split] -header = "Split PDF" +tags = "divide,separate,break,split,extract pages,separate pages,divide document,break apart,separate files,unbind,split by page,divide by chapter" resultsTitle = "Split Results" selectMethod = "Select a split method" splitPages = "Enter pages to split on:" submit = "Split" title = "Split PDF" -[split.desc] -1 = "The numbers you select are the page number you wish to do a split on" -2 = "As such selecting 1,3,7-9 would split a 10 page document into 6 separate PDFS with:" -3 = "Document #1: Page 1" -4 = "Document #2: Page 2 and 3" -5 = "Document #3: Page 4, 5, 6 and 7" -6 = "Document #4: Page 8" -7 = "Document #5: Page 9" -8 = "Document #6: Page 10" - [split.error] failed = "An error occurred while splitting the PDF." -[split.method] -label = "Choose split method" -placeholder = "Select how to split the PDF" - [split.methods.byChapters] -desc = "Split at bookmark boundaries" name = "Chapters" tooltip = "Uses PDF bookmarks to determine split points" [split.methods.byDocCount] -desc = "Create specific number of files" name = "Document Count" tooltip = "Enter how many files you want to create" [split.methods.byPageCount] -desc = "Fixed pages per file" name = "Page Count" tooltip = "Enter the number of pages for each split file" [split.methods.byPageDivider] -desc = "Auto-split with divider sheets" name = "Page Divider" tooltip = "Use QR code divider sheets between documents when scanning" [split.methods.byPages] -desc = "Extract specific pages (1,3,5-10)" name = "Page Numbers" tooltip = "Enter page numbers separated by commas or ranges with hyphens" [split.methods.byPoster] -desc = "Split large pages into printable sizes" name = "Printable Chunks" tooltip = "Divide oversized pages into smaller chunks suitable for printing on standard paper (A4, Letter, etc.)" [split.methods.bySections] -desc = "Divide pages into grid sections" name = "Sections" tooltip = "Split each page into horizontal and vertical sections" [split.methods.bySize] -desc = "Limit maximum file size" name = "File Size" tooltip = "Specify maximum file size (e.g. 10MB, 500KB)" @@ -7914,13 +6984,6 @@ bullet3 = "Allow Duplicates: Handle repeated bookmark names" text = "Use PDF bookmarks to automatically split at chapter boundaries. Requires PDFs with bookmark structure." title = "Split by Chapters" -[split.tooltip.byCount] -bullet1 = "Page Count: Fixed number of pages per file" -bullet2 = "Document Count: Fixed number of output files" -bullet3 = "Useful for batch processing workflows" -text = "Create multiple PDFs with a specific number of pages or documents each." -title = "Split by Count" - [split.tooltip.byDocCount] bullet1 = "Enter the number of output files you want" bullet2 = "Pages are distributed as evenly as possible" @@ -7973,9 +7036,6 @@ bullet3 = "System will split at page boundaries" text = "Create multiple PDFs that don't exceed a specified file size. Ideal for file size limitations or email attachments." title = "Split by File Size" -[split.tooltip.header] -title = "Split Methods Overview" - [split.value.docCount] label = "Number of Files" placeholder = "e.g. 3, 5" @@ -7989,9 +7049,7 @@ label = "Pages per File" placeholder = "e.g. 5, 10" [split-by-sections] -header = "Split PDF into Sections" merge = "Merge Into One PDF" -submit = "Split PDF" tags = "Section Split, Divide, Customize,Customise" title = "Split PDF by Sections" @@ -8017,16 +7075,8 @@ label = "Vertical Divisions" placeholder = "Enter number of vertical divisions" [split-by-size-or-count] -header = "Split PDF by Size or Count" -submit = "Submit" title = "Split PDF by Size or Count" -[split-by-size-or-count.type] -docCount = "By Document Count" -label = "Select Split Type" -pageCount = "By Page Count" -size = "By Size" - [split-by-size-or-count.value] label = "Enter Value" placeholder = "Enter size (e.g., 2MB or 3KB) or count (e.g., 5)" @@ -8034,29 +7084,12 @@ placeholder = "Enter size (e.g., 2MB or 3KB) or count (e.g., 5)" [splitByChapters] allowDuplicates = "Allow Duplicates" bookmarkLevel = "Bookmark Level" -header = "Split PDF by Chapters" includeMetadata = "Include Metadata" -submit = "Split PDF" title = "Split PDF by Chapters" -[splitByChapters.desc] -1 = "This tool splits a PDF file into multiple PDFs based on its chapter structure." -2 = "Bookmark Level: Choose the level of bookmarks to use for splitting (0 for top-level, 1 for second-level, etc.)." -3 = "Include Metadata: If checked, the original PDF's metadata will be included in each split PDF." -4 = "Allow Duplicates: If checked, allows multiple bookmarks on the same page to create separate PDFs." - [splitPdfByChapters] tags = "split,chapters,bookmarks,organize" -[storage] -approximateSize = "Approximate size" -fileTooLarge = "File too large. Maximum size per file is" -storageFull = "Storage is nearly full. Consider removing some files." -storageLimit = "Storage limit" -storageQuotaExceeded = "Storage quota exceeded. Please remove some files before uploading more." -storageUsed = "Temporary Storage used" -temporaryNotice = "Files are stored temporarily in your browser and may be cleared automatically" - [storageShare] accessDenied = "You do not have access to this shared file. Ask the owner to share it with you." accessDeniedBody = "You do not have access to this file. Ask the owner to share it with you." @@ -8158,43 +7191,11 @@ title = "Upload to Server" updateButton = "Update on Server" uploadButton = "Upload to Server" -[subscription] -cancelsOn = "Cancels on {{date}}" -renewsOn = "Renews on {{date}}" - -[subscription.status] -active = "Active" -canceled = "Canceled" -incomplete = "Incomplete" -none = "No Subscription" -pastDue = "Past Due" -trialing = "Trial" - [survey] -button = "Take Survey" -changes = "Stirling-PDF has changed since the last survey! To find out more please check our blog post here:" -changes2 = "With these changes we are getting paid business support and funding" -description = "Stirling-PDF has no tracking so we want to hear from our users to improve Stirling-PDF!" -disabled = "(Survey popup will be disabled in following updates but available at foot of page)" -dontShowAgain = "Don't show again" nav = "Survey" -please = "Please consider taking our survey to have input on the future of Stirling-PDF!" title = "Stirling-PDF Survey" -[survey.meeting] -1 = "If you're using Stirling PDF at work, we'd love to speak to you. We're offering technical support sessions in exchange for a 15 minute user discovery session." -2 = "This is a chance to:" -3 = "Get help with deployment, integrations, or troubleshooting" -4 = "Provide direct feedback on performance, edge cases, and feature gaps" -5 = "Help us refine Stirling PDF for real-world enterprise use" -6 = "If you're interested, you can book time with our team directly. (English speaking only)" -7 = "Looking forward to digging into your use cases and making Stirling PDF even better!" -button = "Book meeting" -notInterested = "Not a business and/or interested in a meeting?" - [swagger] -desc = "View and test the Stirling PDF API endpoints" -header = "API Documentation" tags = "api,documentation,swagger,endpoints,development" title = "API Documentation" @@ -8280,9 +7281,6 @@ right = "Right" [textInput] clear = "Clear input" -[theme] -toggle = "Toggle Theme" - [time.relative] daysAgo = "{{count}}d ago" hoursAgo = "{{count}}h ago" @@ -8290,19 +7288,14 @@ justNow = "just now" minutesAgo = "{{count}}m ago" [timestampPdf] -completed = "PDF timestamped successfully" +tags = "timestamp,RFC 3161,TSA,time stamp authority,document timestamp,proof of existence,timestamp token,trusted timestamp,sign timestamp,notarise" desc = "Add an RFC 3161 document timestamp to your PDF using a trusted Time Stamp Authority (TSA) server." -filenamePrefix = "timestamped" results = "Timestamp Results" submit = "Apply Timestamp" title = "Timestamp PDF" [timestampPdf.error] failed = "An error occurred while timestamping the PDF." -generic = "Timestamping failed" - -[timestampPdf.files] -placeholder = "Select a PDF file in the main view to get started" [timestampPdf.options] note = "Only a SHA-256 hash of your document is sent to the TSA server; the PDF file itself is never sent to the TSA server." @@ -8333,18 +7326,13 @@ singleFileScope = "Only applying to: {{fileName}}" viewerMode = "Switch to the file editor to add multiple files." [toolPanel] -allTools = "All tools" alpha = "Alpha" backToAllTools = "Back to all tools" -backToDefault = "Back" -backToTools = "Back to tools" collapse = "Collapse panel" -comingSoon = "Coming soon:" expand = "Expand panel" goBack = "Go back" placeholder = "Choose a tool to get started" premiumFeature = "Premium feature:" -search = "Search tools" toolsHeader = "Tools" viewAllTools = "View all tools" @@ -8373,10 +7361,6 @@ sidebarDescription = "Keep tools alongside your workspace for quick switching." sidebarTitle = "Sidebar mode" title = "Choose how you browse tools" -[toolPanel.toggle] -fullscreen = "Switch to fullscreen mode" -sidebar = "Switch to sidebar mode" - [toolPicker] allTools = "ALL TOOLS" noToolsFound = "No tools found" @@ -8410,8 +7394,6 @@ close = "Close tooltip" [unlockPDFForms] description = "This tool will remove read-only restrictions from PDF form fields, making them editable and fillable." -filenamePrefix = "unlocked_forms" -header = "Unlock PDF Forms" submit = "Unlock Forms" tags = "remove,delete,form,field,readonly" title = "Remove Read-Only from Form Fields" @@ -8419,15 +7401,11 @@ title = "Remove Read-Only from Form Fields" [unlockPDFForms.error] failed = "An error occurred whilst unlocking PDF forms." -[unlockPDFForms.files] -placeholder = "Select a PDF file in the main view to get started" - [unlockPDFForms.results] title = "Unlocked Forms Results" [update] allReleases = "All Releases" -availableUpdates = "Available Updates" breaking = "Breaking" breakingChanges = "Breaking Changes" breakingChangesDefault = "This version contains breaking changes." @@ -8440,7 +7418,6 @@ defaultRecommendation = "This update contains important fixes and improvements." downloadLatest = "Download Latest" later = "Later" latest = "Latest Version" -latestStable = "Latest Stable" loadingDetailedInfo = "Loading detailed information..." migrationGuide = "Migration Guide" migrationGuides = "Migration Guides" @@ -8448,18 +7425,14 @@ migrationGuidesDesc = "Review important changes before updating." modalSubtitle = "A new version of Stirling PDF is ready to install." modalTitle = "Update Available" notes = "Notes" -priorityLabel = "Priority" -recommendedAction = "Recommended Action" releaseNotes = "Release Notes" showLess = "Show fewer versions" showMore = "Show all {{count}} versions" stable = "STABLE" -unableToLoadDetails = "Unable to load detailed information." updateAvailable = "Update Available" urgentUpdateAvailable = "Urgent Update" version = "Version" versionHistory = "Version History" -viewAllReleases = "View All Releases" viewGuide = "View Guide" whatsNewIn = "What's new in" @@ -8473,16 +7446,12 @@ urgent = "Urgent" attentionBody = "Your admin needs to sign in to see more info. Please contact them immediately." attentionBodyAdmin = "Review the license requirements to keep this server compliant." attentionTitle = "This server needs admin attention" -dismiss = "Dismiss banner" message = "Get the most out of Stirling PDF with unlimited users and advanced features" seeInfo = "See info" title = "Upgrade to Server Plan" upgradeButton = "Upgrade Now" [URLToPDF] -credit = "Uses WeasyPrint" -header = "URL To PDF" -submit = "Convert" tags = "web-capture,save-page,web-to-doc,archive" title = "URL To PDF" @@ -8535,7 +7504,6 @@ downloadCsv = "Download CSV" downloadJson = "Download JSON" downloadPdf = "Download PDF Report" finalizing = "Preparing downloads..." -header = "Validate Digital Signatures" location = "Location" noResults = "Run the validation to generate a report." noSignatures = "No digital signatures found in this document" @@ -8544,7 +7512,6 @@ processing = "Validating signatures..." reason = "Reason" results = "Validation Results" selectCustomCert = "Custom Certificate File X.509 (Optional)" -selectPDF = "Select signed PDF file" signatureDate = "Signature Date" signer = "Signer" submit = "Validate Signatures" @@ -8556,12 +7523,9 @@ totalSignatures = "Total Signatures" algorithm = "Algorithm" bits = "bits" details = "Certificate Details" -expired = "Certificate has expired" -info = "Certificate Details" issuer = "Issuer" keySize = "Key Size" keyUsage = "Key Usage" -revoked = "Certificate has been revoked" selfSigned = "Self-Signed" serialNumber = "Serial Number" subject = "Subject" @@ -8569,9 +7533,6 @@ validFrom = "Valid From" validUntil = "Valid Until" version = "Version" -[validateSignature.chain] -invalid = "Certificate chain validation failed - cannot verify signer's identity" - [validateSignature.downloadType] csv = "CSV" json = "JSON" @@ -8618,23 +7579,12 @@ title = "Validation Settings" [validateSignature.signature] _value = "Signature" -info = "Signature Information" -mathValid = "Signature is mathematically valid BUT:" [validateSignature.status] -_value = "Status" complete = "Validation complete" invalid = "Invalid" valid = "Valid" -[validateSignature.trust] -invalid = "Certificate not in trust store - source cannot be verified" - -[view] -fileManager = "File Manager" -pageEditor = "Page Editor" -viewer = "Viewer" - [viewer] cannotPreviewFile = "Cannot Preview File" disableColorFilter = "Disable Colour Filter" @@ -8680,10 +7630,8 @@ moreActions = "More actions" nComments = "{{count}} comments" oneComment = "1 comment" pageLabel = "Page {{page}}" -placeholder = "Type your comment..." removeCommentOnly = "Remove comment only" saveReply = "Save reply" -send = "Send" title = "Comments" typeComment = "Comment" typeInsertText = "Insert Text" @@ -8710,7 +7658,6 @@ columnDefault = "Column {{index}}" convertToPdf = "Convert to PDF" csvStats = "{{rows}} rows · {{columns}} columns · {{size}}" emptyFile = "Empty file" -fileTypeBadge = "{{type}} File" htmlPreview = "HTML preview" htmlPreviewWarning = "HTML preview - external resources may not load · {{size}}" invalidJson = "Invalid JSON - showing raw content" @@ -8734,7 +7681,6 @@ resultsOf = "of {{total}}" delete = "Delete signature" [viewPdf] -header = "View PDF" tags = "view,read,annotate,text,image,highlight,edit" title = "View/Edit PDF" @@ -8742,20 +7688,11 @@ title = "View/Edit PDF" tooltipTitle = "Warning" [watermark] -completed = "Watermark added" +tags = "stamp,mark,overlay,watermark,branding,logo,confidential,draft,copyright,trademark,text overlay,image overlay,background text" desc = "Add text or image watermarks to PDF files" -filenamePrefix = "watermarked" submit = "Add Watermark" title = "Add Watermark" -[watermark.alphabet] -arabic = "Arabic" -chinese = "Chinese" -japanese = "Japanese" -korean = "Korean" -roman = "Roman/Latin" -thai = "Thai" - [watermark.error] failed = "An error occurred while adding watermark to the PDF." @@ -8766,16 +7703,12 @@ title = "Watermark Results" alphabet = "Font/Language" color = "Watermark Colour" convertToImage = "Flatten PDF pages to images" -fontSize = "Font Size" opacity = "Opacity (%)" rotation = "Rotation (degrees)" size = "Size" -type = "Watermark Type" [watermark.settings.image] choose = "Choose Image" -label = "Watermark Image" -selected = "Selected: {{filename}}" [watermark.settings.spacing] height = "Height Spacing" @@ -8784,7 +7717,6 @@ vertical = "Vertical Spacing" width = "Width Spacing" [watermark.settings.text] -label = "Watermark Text" placeholder = "Enter watermark text" [watermark.steps] @@ -8818,13 +7750,6 @@ bullet3 = "Higher resolution images maintain quality better" text = "Upload an image file to use as your watermark." title = "Image Selection" -[watermark.tooltip.formatting.appearance] -bullet1 = "Rotation: -360° to 360° for angled watermarks" -bullet2 = "Opacity: 0-100% for transparency control" -bullet3 = "Lower opacity creates subtle watermarks" -text = "Control how your watermark looks and blends with the document." -title = "Appearance Settings" - [watermark.tooltip.formatting.header] title = "Formatting & Layout" @@ -8841,13 +7766,6 @@ bullet1 = "Larger sizes create more prominent watermarks" text = "Adjust the size of your watermark (text or image)." title = "Size Control" -[watermark.tooltip.formatting.spacing] -bullet1 = "Horizontal spacing: Distance between watermarks left to right" -bullet2 = "Vertical spacing: Distance between watermarks top to bottom" -bullet3 = "Higher values create more spread out patterns" -text = "Adjust the spacing between repeated watermarks across the page." -title = "Spacing Control" - [watermark.tooltip.language] text = "Choose the appropriate language setting to ensure proper font rendering for your text." title = "Language Support" @@ -8869,10 +7787,6 @@ title = "Colour Selection" [watermark.tooltip.textStyle.header] title = "Text Style" -[watermark.tooltip.textStyle.language] -text = "Choose the appropriate language setting to ensure proper font rendering." -title = "Language Support" - [watermark.tooltip.type.description] text = "Select between text or image watermarks based on your needs." title = "Choose Your Watermark" @@ -8942,17 +7856,13 @@ annotations = "Annotations" applyRedactionsFirst = "Apply redactions first" closeAll = "Close All Files" closePdf = "Close PDF" -closeSelected = "Close Selected Files" deleteSelected = "Delete Selected Pages" deselectAll = "Deselect All" downloadAll = "Download All" -downloadSelected = "Download Selected Files" -draw = "Draw" exitRedaction = "Exit Redaction Mode" exportAll = "Export PDF" exportSelected = "Export Selected Pages" formFill = "Fill Form" -language = "Language" multiTool = "Multi-Tool" panMode = "Pan Mode" print = "Print PDF" @@ -8971,14 +7881,12 @@ search = "Search PDF" selectAll = "Select All" selectByNumber = "Select by Page Numbers" selectLanguage = "Select language" -share = "Share" toggleAnnotations = "Toggle Annotations Visibility" toggleAttachments = "Toggle Attachments" toggleBookmarks = "Toggle Bookmarks" toggleComments = "Comments" toggleLayers = "Toggle Layers" toggleSidebar = "Toggle Sidebar" -toggleTheme = "Toggle Theme" viewer = "Viewer" [workflow.participant] @@ -9038,7 +7946,6 @@ member = "Member" noMembersFound = "No members found" role = "Role" searchMembers = "Search members..." -status = "Status" team = "Team" title = "People" unlockAccount = "Unlock Account" @@ -9047,25 +7954,21 @@ unlockUserSuccess = "User account unlocked successfully" user = "User" [workspace.people.actions] -label = "Actions" upgrade = "Upgrade" [workspace.people.addMember] authType = "Authentication Type" -cancel = "Cancel" error = "Failed to create user" forceMFA = "Force MFA setup on next login" forcePasswordChange = "Force password change on first login" password = "Password" passwordPlaceholder = "Enter password" passwordRequired = "Password is required" -passwordTooShort = "Password must be at least 6 characters" role = "Role" submit = "Add Member" success = "User created successfully" team = "Team (Optional)" teamPlaceholder = "Select a team" -title = "Add Member" username = "Username (Email)" usernamePlaceholder = "user@example.com" usernameRequired = "Username and password are required" @@ -9101,15 +8004,7 @@ subtitle = "Update the password for" success = "Password updated successfully" title = "Change password" -[workspace.people.delete] -error = "Failed to delete user" -success = "User deleted successfully" - -[workspace.people.directInvite] -tab = "Direct Create" - [workspace.people.editMember] -cancel = "Cancel" editing = "Editing:" error = "Failed to update user" role = "Role" @@ -9121,7 +8016,6 @@ title = "Edit Member" [workspace.people.emailInvite] allFailed = "Failed to invite users" -description = "Type or paste in emails below, separated by commas. Users will receive login credentials via email." emails = "Email Addresses" emailsPlaceholder = "user1@example.com, user2@example.com" emailsRequired = "At least one email address is required" @@ -9129,34 +8023,20 @@ error = "Failed to send invites" partialFailure = "Some invites failed" submit = "Send Invites" success = "user(s) invited successfully" -tab = "Email Invite" [workspace.people.inviteLink] copied = "Link copied to clipboard" -description = "Generate a secure link that allows the user to set their own password" email = "Email Address" emailDescription = "Optional - leave blank for a general invite link that can be used by anyone" -emailFailed = "Invite link generated, but email failed" -emailFailedDetails = "Error: {0}. Please share the invite link manually." -emailOptional = "Optional - leave blank for a general invite link" emailPlaceholder = "user@example.com (optional)" -emailRequired = "Email address is required" -emailRequiredForSend = "Email address is required to send email notification" emailSent = "Invite link generated and sent via email" error = "Failed to generate invite link" -expiryDescription = "How many hours until the link expires" expiryHours = "Expiry Hours" generate = "Generate Link" generated = "Invite Link Generated" sendEmail = "Send invite link via email" sendEmailDescription = "If enabled, the invite link will be sent to the specified email address" -smtpRequired = "SMTP not configured" submit = "Generate Invite Link" -success = "Invite link generated successfully" -successWithEmail = "Invite link generated and sent via email" - -[workspace.people.inviteLinkTab] -tab = "Invite Link" [workspace.people.inviteMembers] label = "Invite Members" @@ -9169,7 +8049,6 @@ link = "Link" username = "Username" [workspace.people.license] -availableSlots = "Available Slots" currentUsage = "Currently using {{current}} of {{max}} user licences" fromLicense = "from license" grandfathered = "Grandfathered" @@ -9193,12 +8072,10 @@ error = "Failed to update user status" success = "User status updated successfully" [workspace.teams] -actions = "Actions" addMember = "Add Member" backToTeams = "Back to Teams" cannotAddToInternal = "Cannot add members to the Internal team" cannotDeleteInternal = "Cannot delete the Internal team" -cannotRemoveFromSystemTeam = "Cannot remove from system team" cannotRenameInternal = "Cannot rename the Internal team" confirmDelete = "Are you sure you want to delete this team? This team must be empty to delete." confirmRemove = "Remove user from this team?" @@ -9224,7 +8101,6 @@ viewTeam = "View Team" [workspace.teams.addMemberToTeam] addingTo = "Adding to" -cancel = "Cancel" currentlyIn = "currently in" error = "Failed to add member to team" selectUser = "Select User" @@ -9248,7 +8124,6 @@ success = "Team changed successfully" title = "Change Team" [workspace.teams.createTeam] -cancel = "Cancel" error = "Failed to create team" nameRequired = "Team name is required" submit = "Create Team" @@ -9260,10 +8135,8 @@ title = "Create New Team" [workspace.teams.deleteTeam] error = "Failed to delete team. Make sure the team is empty." success = "Team deleted successfully" -teamMustBeEmpty = "Team must be empty before deletion" [workspace.teams.renameTeam] -cancel = "Cancel" error = "Failed to rename team" nameRequired = "Team name is required" newTeamName = "New Team Name" @@ -9278,3 +8151,205 @@ cancel = "Cancel" confirm = "Extract" message = "This ZIP contains {{count}} files. Extract anyway?" title = "Large ZIP File" + +[watchedFolders] +alreadyInFolder = "Already in this folder" +deleteConfirmBody = "This will remove the folder and its run history. Files already downloaded are not affected." +deleteConfirmTitle = "Delete folder?" +defaultFolderWarning = "This is a default folder and will be recreated on next reload." +folderNotFound = "Folder not found" +newFolder = "New folder" +noFolders = "No watched folders yet" +sidebarTitle = "Watched Folders" +title = "Watched Folders" +sidebarFiles = "My Files" + +[watchedFolders.modal] +automation = "Automation" +automationSaved = "Automation saved" +createFolder = "Create Folder" +stepsSaved = "Steps saved — click Create Folder to finish" +automationRequired = "Add at least one configured step before saving." +color = "Accent color" +createTitle = "New Watched Folder" +description = "Description" +descriptionPlaceholder = "What does this folder do?" +editTitle = "Edit Watched Folder" +icon = "Icon" +name = "Folder name" +namePlaceholder = "My Watched Folder" +nameRequired = "Folder name is required" +nameTooLong = "Folder name must be 50 characters or less" +saveChanges = "Save Changes" +saveFailed = "Failed to save folder. Please try again." +automationNameFallback = "Watched Folder automation" +retryLabel = "Auto-retry" +maxRetries = "Max retries" +maxRetriesDesc = "0 to disable" +retryDelay = "Delay (minutes)" +outputLabel = "Output" +outputModeVersion = "Replace original?" +outputModeVersionDesc = "Output becomes a new version of the input file rather than a separate file" +outputModeNewDesc = "Output is saved as a new separate file" +outputName = "Output filename prefix" +outputNameSuffix = "Suffix" +outputNamePlaceholderVersion = "Same as original" +sectionFolder = "Folder" +sectionSourceOutput = "Source & Output" +sectionSteps = "Steps" +inputSource = "Input source" +inputSourceBrowser = "Browser — drop files here" +inputSourceLocal = "Local folder (auto-scan)" +inputSourceLocalUnsupported = "Local folder (auto-scan) — Chrome/Edge only" +inputFolder = "Input folder" +inputFolderNotChosen = "No folder chosen — required for auto-scan" +changeFolder = "Change" +chooseFolder = "Choose" +clearFolder = "Clear" +autoScanHelp = "New PDF files in this folder are processed automatically every 10 seconds." +localOutputFolder = "Local output folder" +outputFolderUnsupported = "Not supported in this browser" +outputFolderNotSet = "Not set — outputs stay in app" +advanced = "Advanced" +replaceOriginal = "Replace original file" +autoNumber = "Auto-number" +autoNumberExample = "e.g. document.pdf → document (1).pdf" +outputNamePrefix = "Filename prefix" +positionPrefix = "Prefix" +positionSuffix = "Suffix" + +[watchedFolders.home] +create = "Create your first Watched Folder" +dropHere = "Drop to process" +editFolder = "Edit folder" +empty = "No watched folders yet" +file = "file" +files = "files" +openFolder = "Open folder" +title = "Watched Folders" +subtitle = "Folders that automatically process PDFs with your configured pipeline" +emptyTitle = "Automate your PDF workflows" +emptyDesc = "Set up a Watched Folder once. Drop PDFs in and they're automatically compressed, OCR'd, split, merged — whatever your pipeline does." +addAnother = "Add another Watched Folder" +addAnotherDesc = "Automatically process files with a new pipeline" +resume = "Resume" +pause = "Pause" +deleteFolder = "Delete folder" +noSteps = "No automation steps configured" + +[watchedFolders.card] +edit = "Edit folder" +delete = "Delete folder" + +[watchedFolders.status] +done = "Done" +processing = "Processing" +paused = "Paused" +active = "Active" + +[watchedFolders.howItWorks] +title = "How Watched Folders work" +step1Title = "Drop files" +step1Desc = "Drag PDFs onto any Watched Folder card — or send them from your file list" +step2Title = "Pipeline runs" +step2Desc = "Your configured tools process each file automatically" +step3Title = "Output ready" +step3Desc = "Download processed files from inside the folder" + +[watchedFolders.workbench] +addFiles = "Add files" +inputs = "Inputs" +outputs = "Outputs" +failed = "Failed" +dataSaved = "Saved" +running = "Running" +dropToProcess = "Drop to process" +activity = "Activity" +noActivity = "No activity yet — drop a PDF to start" +noActivityMatch = "No matching activity" +retryAll = "Retry all" +retryIn = "retry in {{count}}m" +retryingSoon = "retrying…" +pause = "Pause" +resume = "Resume" +dayRunning = "day running" +daysRunning = "days running" +sortNewest = "Newest" +sortOldest = "Oldest" +sortNameAsc = "A → Z" +sortNameDesc = "Z → A" +filterAll = "All" +filterPending = "Pending" +search = "Search…" +retryMixedConfirm = "Some selected files have already completed and will be skipped. Only failed files will be retried. Continue?" +watching = "Watching: {{name}}" +noInputFolder = "No input folder — edit to configure" +countProcessing = "{{count}} processing" +countQueued = "{{count}} queued" +exportZip = "Export zip" +exportSeparately = "Export separately" +countSelected = "{{count}} selected" +selectAll = "Select all {{count}}" +delete = "Delete" +clearSelection = "Clear selection" +preview = "Preview" +export = "Export" +previewInput = "Preview input" +previewOutput = "Preview output" +directionIn = "in" +directionOut = "out" +queuedWaiting = "Queued — waiting to run" +allTime = "All time" +queued = "Queued" +inputSize = "Input size" +filesProcessedOverTime = "Files processed over time" +legendComplete = "Complete" +chartTooltipComplete = "{{count}} complete" +chartTooltipFailed = "{{count}} failed" +removeEntry = "Remove entry" +removeEntries = "Remove {{count}} entries" +deleteConfirmBody = "Remove notifications only clears the activity log. Delete outputs also removes the processed files from storage. Your original input files are never touched." +cancel = "Cancel" +removeNotificationsOnly = "Remove notifications only" +deleteOutputs = "Delete outputs" +previewLoadFailed = "Could not load file preview." + +[watchedFolders.actions] +back = "Back" +dismiss = "Dismiss" +retry = "Retry" +view = "View" +download = "Download" +downloadInput = "Download input" +downloadOutput = "Download output" +collapse = "Collapse" +expand = "Expand" + +[watchedFolders.time] +justNow = "just now" +minutesAgo = "{{count}}m ago" +hoursAgo = "{{count}}h ago" +daysAgo = "{{count}}d ago" +[addStamp] +tags = "stamp,mark,seal,approved,rejected,confidential,stamp tool,rubber stamp,date stamp,approval stamp,received,void,copy,original" + +[annotate] +tags = "annotate,highlight,draw,markup,comment,notes,review,redline,feedback,markup tools,sticky notes,shapes,arrows,text box,freehand" + +[devAirgapped] +tags = "air-gapped,offline,isolated,no internet,disconnected,secure,network isolation,standalone" + +[devApi] +tags = "API,development,documentation,developer,REST,integration,endpoints,programmatic,automation,scripting" + +[devFolderScanning] +tags = "automation,folder,scanning,watch folder,hot folder,automatic processing,batch,monitor folder,auto process,folder monitoring" + +[devSsoGuide] +tags = "SSO,single sign-on,authentication,SAML,OAuth,OIDC,login,enterprise,identity provider,IdP" + +[read] +tags = "view,open,display,read,viewer,PDF viewer,PDF reader,open PDF,view PDF,display PDF,preview,browse" + +[scannerEffect] +tags = "scan,simulate,create,fake scan,look scanned,scanner effect,make look scanned,photocopy effect,simulate scanner,realistic scan" diff --git a/frontend/editor/public/sw-folder-retry.js b/frontend/editor/public/sw-folder-retry.js new file mode 100644 index 000000000..fbf0ed1fd --- /dev/null +++ b/frontend/editor/public/sw-folder-retry.js @@ -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); +} diff --git a/frontend/editor/src/core/components/AppProviders.tsx b/frontend/editor/src/core/components/AppProviders.tsx index 6c0010945..d9f4864d8 100644 --- a/frontend/editor/src/core/components/AppProviders.tsx +++ b/frontend/editor/src/core/components/AppProviders.tsx @@ -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({ - {children} + + {children} + diff --git a/frontend/editor/src/core/components/layout/Workbench.tsx b/frontend/editor/src/core/components/layout/Workbench.tsx index c0928d7fe..9848c86f3 100644 --- a/frontend/editor/src/core/components/layout/Workbench.tsx +++ b/frontend/editor/src/core/components/layout/Workbench.tsx @@ -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 && (

diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index 4354f54a2..c11ff76e8 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -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( 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( 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( activeFileId, requestNavigation, isMultiTool, + isWatchedFoldersActive, + activeWatchedFolderId, + setCustomWorkbenchViewData, ], ); @@ -676,6 +759,32 @@ const FileSidebar = forwardRef( )} + {/* Watched Folders entry */} + {WATCHED_FOLDERS_ENABLED && ( +
e.key === "Enter" && openWatchedFolders()} + aria-label={t("watchedFolders.sidebarTitle", "Watched Folders")} + style={ + isWatchedFoldersActive + ? { backgroundColor: "var(--active-bg)" } + : undefined + } + > + + {!collapsed && ( + + {t("watchedFolders.sidebarTitle", "Watched Folders")} + + )} +
+ )} + {/* Files section - always visible when expanded */} {!collapsed && (
@@ -716,6 +825,34 @@ const FileSidebar = forwardRef( (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 => !!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( 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} /> ); })} diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css index 0b6497823..3529f0165 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css @@ -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) ---- */ diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx index e08188920..c03930864 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx @@ -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 ; } +/** 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} - - {dateLabel} - {dateLabel && typeLabel ? " · " : ""} - {typeLabel} + + + {dateLabel} + {dateLabel && typeLabel ? " · " : ""} + {typeLabel} + + {folders.length > 0 && ( + + {visibleFolders.map((folder) => ( + + { + e.stopPropagation(); + onFolderClick?.(folder.id); + }} + > + + + {folder.name} + + + + ))} + {overflowFolders.length > 0 && ( + f.name).join(", ")} + withArrow + position="top" + withinPortal + > + + +{overflowFolders.length} + + + )} + + )}