mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Add MCP server with OAuth/API-key auth (#6570)
Adds an optional MCP server (proprietary module) that exposes Stirling's PDF operations and AI capabilities to MCP clients. Off by default, zero footprint when disabled. ### What - New `/mcp` endpoint: streamable-HTTP + JSON-RPC 2.0; 8 tools (describe_operation, pages/convert/misc/security category tools, AI, upload, download). - Runs real operations over an internal loopback; results returned inline as base64 (small) or by fileId (large). ### Auth (two modes) - OAuth2 resource server: RFC 9728 protected-resource metadata, RFC 8707 audience binding, JWKS, `mcp.tools.read/write` scopes; binds each token to a provisioned Stirling account. - API-key mode: reuses Stirling per-user `X-API-KEY` (no IdP needed). ### Security - Per-user file ownership in FileStorage: async/queued writes scoped to the submitting user; legacy/owner-less files stay readable. - Admin allow/block list controls which operations are exposed. - Python engine gated behind a shared secret (`X-Engine-Auth`). - MCP filter chain is isolated and cannot weaken the main app's security. - Hardened: no upstream error-body leakage, log injection sanitized, fileId path/sidecar enumeration blocked. ### Config / footprint - Disabled by default (`mcp.enabled=false`); all beans `@ConditionalOnProperty`. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details.
This commit is contained in:
@@ -3,3 +3,12 @@
|
||||
# intentionally in #6150 so engine/.env has a working default, with real
|
||||
# credentials overridden via engine/.env.local.
|
||||
engine/.env:generic-api-key:41
|
||||
|
||||
# MCP test fixtures / harness - no real secrets:
|
||||
# - test-only API key constant in an integration test
|
||||
# - JDBC URL + throwaway Keycloak creds in the local test compose
|
||||
# - placeholder / shell-variable Bearer headers in curl-based validation scripts
|
||||
app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpApiKeyIntegrationTest.java:generic-api-key:40
|
||||
testing/compose/docker-compose-keycloak-mcp.yml:generic-api-key:25
|
||||
testing/compose/validate-mcp-apikey.sh:curl-auth-header:73
|
||||
testing/compose/validate-mcp-test.sh:curl-auth-header:92
|
||||
|
||||
@@ -212,3 +212,55 @@ tasks:
|
||||
desc: "Stop the SAML keycloak test environment"
|
||||
cmds:
|
||||
- docker compose -f testing/compose/docker-compose-keycloak-saml.yml down -v
|
||||
|
||||
mcp:up:
|
||||
desc: "Start the MCP keycloak test environment (Stirling as OAuth resource server)"
|
||||
summary: |
|
||||
Brings up Keycloak (OAuth authorization server) + Stirling configured as an
|
||||
MCP resource server, then you can exercise /mcp with real Keycloak tokens.
|
||||
Set LICENSE_KEY=<KEY> to skip the interactive license prompt:
|
||||
task e2e:mcp:up LICENSE_KEY=abc123
|
||||
Pass extra flags via -- :
|
||||
task e2e:mcp:up -- --validate --nobuild
|
||||
ignore_error: true
|
||||
cmds:
|
||||
- bash testing/compose/start-mcp-test.sh {{if .LICENSE_KEY}}--license-key "{{.LICENSE_KEY}}"{{end}} {{.CLI_ARGS}}
|
||||
|
||||
mcp:manual:
|
||||
desc: "Start the MCP keycloak test env in manual mode (prints URLs + a live token for your client)"
|
||||
summary: |
|
||||
Brings the stack up and prints copy-paste URLs/commands plus a freshly minted
|
||||
access token so you can drive your own MCP client (Inspector, curl, ...).
|
||||
task e2e:mcp:manual LICENSE_KEY=<your-license-key>
|
||||
Add --nobuild if the images are already built:
|
||||
task e2e:mcp:manual LICENSE_KEY=<your-license-key> -- --nobuild
|
||||
ignore_error: true
|
||||
cmds:
|
||||
- bash testing/compose/start-mcp-test.sh --manual {{if .LICENSE_KEY}}--license-key "{{.LICENSE_KEY}}"{{end}} {{.CLI_ARGS}}
|
||||
|
||||
mcp:apikey:
|
||||
desc: "Start the MCP test env in API-KEY manual mode (no OAuth/IdP): mints a key + prints client settings"
|
||||
summary: |
|
||||
Brings Stirling up in apikey auth mode and prints copy-paste client settings with a freshly
|
||||
minted X-API-KEY - ideal for clients whose OAuth layer can't reach localhost.
|
||||
task e2e:mcp:apikey LICENSE_KEY=<your-license-key>
|
||||
Add --nobuild if images are already built:
|
||||
task e2e:mcp:apikey LICENSE_KEY=<your-license-key> -- --nobuild
|
||||
ignore_error: true
|
||||
cmds:
|
||||
- bash testing/compose/start-mcp-test.sh --apikey {{if .LICENSE_KEY}}--license-key "{{.LICENSE_KEY}}"{{end}} {{.CLI_ARGS}}
|
||||
|
||||
mcp:validate:
|
||||
desc: "Validate the running MCP keycloak test environment end-to-end (oauth mode + real MCP SDK client)"
|
||||
cmds:
|
||||
- bash testing/compose/validate-mcp-test.sh
|
||||
|
||||
mcp:validate-apikey:
|
||||
desc: "Validate the MCP server in API-KEY auth mode (mints a key + real MCP SDK client), then restore oauth"
|
||||
cmds:
|
||||
- bash testing/compose/validate-mcp-apikey.sh
|
||||
|
||||
mcp:down:
|
||||
desc: "Stop the MCP keycloak test environment"
|
||||
cmds:
|
||||
- docker compose -f testing/compose/docker-compose-keycloak-mcp.yml down -v
|
||||
|
||||
@@ -11,23 +11,39 @@ public interface FileStore {
|
||||
/** Stored file record. */
|
||||
record Stored(String fileId, long size) {}
|
||||
|
||||
/** Store the given stream and return a generated file id and total bytes written. */
|
||||
Stored store(InputStream in, String originalName) throws IOException;
|
||||
/**
|
||||
* Store the given stream and return a generated file id and total bytes written. {@code owner}
|
||||
* may be null to indicate the file has no associated user (anonymous / desktop / async job with
|
||||
* no propagated security context); a non-null value is persisted alongside the data so {@link
|
||||
* #getOwner(String)} can return it later for authorization checks.
|
||||
*/
|
||||
Stored store(InputStream in, String originalName, String owner) throws IOException;
|
||||
|
||||
/** Store with no owner. Equivalent to {@link #store(InputStream, String, String)} with null. */
|
||||
default Stored store(InputStream in, String originalName) throws IOException {
|
||||
return store(in, originalName, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the file at {@code source} and return a generated file id and total bytes written.
|
||||
*
|
||||
* <p>Default implementation opens {@code source} as a stream and delegates to {@link
|
||||
* #store(InputStream, String)}. Local-disk implementations should override to use a direct
|
||||
* file-to-file copy ({@code Files.copy(source, dest)} can use {@code sendfile(2)} on Linux),
|
||||
* which avoids the two-memory-copy hit of streaming a disk-backed upload through the JVM heap.
|
||||
* #store(InputStream, String, String)}. Local-disk implementations should override to use a
|
||||
* direct file-to-file copy ({@code Files.copy(source, dest)} can use {@code sendfile(2)} on
|
||||
* Linux), which avoids the two-memory-copy hit of streaming a disk-backed upload through the
|
||||
* JVM heap.
|
||||
*/
|
||||
default Stored store(Path source, String originalName) throws IOException {
|
||||
default Stored store(Path source, String originalName, String owner) throws IOException {
|
||||
try (InputStream in = Files.newInputStream(source)) {
|
||||
return store(in, originalName);
|
||||
return store(in, originalName, owner);
|
||||
}
|
||||
}
|
||||
|
||||
/** Store with no owner. Equivalent to {@link #store(Path, String, String)} with null. */
|
||||
default Stored store(Path source, String originalName) throws IOException {
|
||||
return store(source, originalName, null);
|
||||
}
|
||||
|
||||
/** Open the stored file for streaming reads. Caller closes. */
|
||||
InputStream retrieve(String fileId) throws IOException;
|
||||
|
||||
@@ -42,4 +58,12 @@ public interface FileStore {
|
||||
|
||||
/** Whether the file id exists in the store. */
|
||||
boolean exists(String fileId);
|
||||
|
||||
/**
|
||||
* Returns the owner identifier recorded at store time, or {@code null} if the file does not
|
||||
* exist or was stored without an owner. Implementations must not throw when the file is missing
|
||||
* or when the owner record is absent; they should return null so callers can treat "no owner"
|
||||
* as a non-authoritative case.
|
||||
*/
|
||||
String getOwner(String fileId) throws IOException;
|
||||
}
|
||||
|
||||
+100
-23
@@ -3,9 +3,12 @@ package stirling.software.common.cluster.inprocess;
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -15,33 +18,47 @@ import stirling.software.common.cluster.FileStore;
|
||||
@Slf4j
|
||||
public class LocalDiskFileStore implements FileStore {
|
||||
|
||||
private static final String OWNER_SUFFIX = ".owner";
|
||||
|
||||
// File ids are generated as random UUIDs; reject anything else so a tainted id can never reach
|
||||
// Files.* APIs (defence in depth on top of the resolve() prefix check, and silences CodeQL's
|
||||
// path-injection finding on the resolveOwner sidecar lookup).
|
||||
private static final Pattern UUID_PATTERN =
|
||||
Pattern.compile(
|
||||
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$");
|
||||
|
||||
private final String baseDirPath;
|
||||
// Fixed-size lock stripes so concurrent store/delete on the same (or colliding) fileId
|
||||
// serialise the data-file + owner-sidecar pair as one critical section. Striped (not
|
||||
// per-id) so the map never has to be cleaned up; collisions across unrelated ids are
|
||||
// harmless contention.
|
||||
private static final int LOCK_STRIPES = 64;
|
||||
private final ReentrantLock[] stripes = new ReentrantLock[LOCK_STRIPES];
|
||||
|
||||
public LocalDiskFileStore(String baseDirPath) {
|
||||
this.baseDirPath = baseDirPath;
|
||||
for (int i = 0; i < LOCK_STRIPES; i++) {
|
||||
stripes[i] = new ReentrantLock();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stored store(InputStream in, String originalName) throws IOException {
|
||||
public Stored store(InputStream in, String originalName, String owner) throws IOException {
|
||||
String fileId = UUID.randomUUID().toString();
|
||||
Path filePath = resolve(fileId);
|
||||
Files.createDirectories(filePath.getParent());
|
||||
ReentrantLock lock = acquire(fileId);
|
||||
boolean success = false;
|
||||
try {
|
||||
long size = Files.copy(in, filePath);
|
||||
writeOwner(fileId, owner);
|
||||
success = true;
|
||||
return new Stored(fileId, size);
|
||||
} finally {
|
||||
if (!success) {
|
||||
try {
|
||||
Files.deleteIfExists(filePath);
|
||||
} catch (IOException cleanupEx) {
|
||||
log.warn(
|
||||
"Failed to clean up partial file {} after store failure",
|
||||
filePath,
|
||||
cleanupEx);
|
||||
}
|
||||
cleanupAfterFailedStore(fileId, filePath);
|
||||
}
|
||||
release(fileId, lock);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,27 +69,44 @@ public class LocalDiskFileStore implements FileStore {
|
||||
* the source size before copying so the post-copy stat is unnecessary.
|
||||
*/
|
||||
@Override
|
||||
public Stored store(Path source, String originalName) throws IOException {
|
||||
public Stored store(Path source, String originalName, String owner) throws IOException {
|
||||
String fileId = UUID.randomUUID().toString();
|
||||
Path filePath = resolve(fileId);
|
||||
Files.createDirectories(filePath.getParent());
|
||||
long size = Files.size(source);
|
||||
ReentrantLock lock = acquire(fileId);
|
||||
boolean success = false;
|
||||
try {
|
||||
Files.copy(source, filePath);
|
||||
writeOwner(fileId, owner);
|
||||
success = true;
|
||||
return new Stored(fileId, size);
|
||||
} finally {
|
||||
if (!success) {
|
||||
try {
|
||||
Files.deleteIfExists(filePath);
|
||||
} catch (IOException cleanupEx) {
|
||||
log.warn(
|
||||
"Failed to clean up partial file {} after store failure",
|
||||
filePath,
|
||||
cleanupEx);
|
||||
}
|
||||
cleanupAfterFailedStore(fileId, filePath);
|
||||
}
|
||||
release(fileId, lock);
|
||||
}
|
||||
}
|
||||
|
||||
private void writeOwner(String fileId, String owner) throws IOException {
|
||||
if (owner == null || owner.isBlank()) {
|
||||
return;
|
||||
}
|
||||
Path ownerPath = resolveOwner(fileId);
|
||||
Files.write(ownerPath, owner.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private void cleanupAfterFailedStore(String fileId, Path filePath) {
|
||||
try {
|
||||
Files.deleteIfExists(filePath);
|
||||
} catch (IOException cleanupEx) {
|
||||
log.warn("Failed to clean up partial file {} after store failure", filePath, cleanupEx);
|
||||
}
|
||||
try {
|
||||
Files.deleteIfExists(resolveOwner(fileId));
|
||||
} catch (IOException cleanupEx) {
|
||||
log.warn("Failed to clean up owner sidecar for {} after store failure", fileId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,11 +135,26 @@ public class LocalDiskFileStore implements FileStore {
|
||||
|
||||
@Override
|
||||
public boolean delete(String fileId) {
|
||||
ReentrantLock lock = acquire(fileId);
|
||||
try {
|
||||
return Files.deleteIfExists(resolve(fileId));
|
||||
} catch (IOException e) {
|
||||
log.error("Error deleting file with ID: {}", fileId, e);
|
||||
return false;
|
||||
// Data first, owner second: a concurrent retrieve that observes the transient
|
||||
// (data-gone, owner-still-present) window simply fails with IOException; the inverse
|
||||
// order would briefly look like an unowned file and could grant cross-user access.
|
||||
boolean removed;
|
||||
try {
|
||||
removed = Files.deleteIfExists(resolve(fileId));
|
||||
} catch (IOException e) {
|
||||
log.error("Error deleting file with ID: {}", fileId, e);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Files.deleteIfExists(resolveOwner(fileId));
|
||||
} catch (IOException e) {
|
||||
log.warn("Error deleting owner sidecar for file ID: {}", fileId, e);
|
||||
}
|
||||
return removed;
|
||||
} finally {
|
||||
release(fileId, lock);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,8 +163,21 @@ public class LocalDiskFileStore implements FileStore {
|
||||
return Files.exists(resolve(fileId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getOwner(String fileId) throws IOException {
|
||||
Path ownerPath = resolveOwner(fileId);
|
||||
if (!Files.exists(ownerPath)) {
|
||||
return null;
|
||||
}
|
||||
byte[] bytes = Files.readAllBytes(ownerPath);
|
||||
if (bytes.length == 0) {
|
||||
return null;
|
||||
}
|
||||
return new String(bytes, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public Path resolve(String fileId) {
|
||||
if (fileId.contains("..") || fileId.contains("/") || fileId.contains("\\")) {
|
||||
if (fileId == null || !UUID_PATTERN.matcher(fileId).matches()) {
|
||||
throw new IllegalArgumentException("Invalid file ID");
|
||||
}
|
||||
Path basePath = Path.of(baseDirPath).normalize().toAbsolutePath();
|
||||
@@ -125,4 +187,19 @@ public class LocalDiskFileStore implements FileStore {
|
||||
}
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
private Path resolveOwner(String fileId) {
|
||||
Path data = resolve(fileId);
|
||||
return data.resolveSibling(data.getFileName().toString() + OWNER_SUFFIX);
|
||||
}
|
||||
|
||||
private ReentrantLock acquire(String fileId) {
|
||||
ReentrantLock lock = stripes[(fileId.hashCode() & Integer.MAX_VALUE) % LOCK_STRIPES];
|
||||
lock.lock();
|
||||
return lock;
|
||||
}
|
||||
|
||||
private void release(String fileId, ReentrantLock lock) {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ public class ApplicationProperties {
|
||||
private ProcessExecutor processExecutor = new ProcessExecutor();
|
||||
private PdfEditor pdfEditor = new PdfEditor();
|
||||
private AiEngine aiEngine = new AiEngine();
|
||||
private Mcp mcp = new Mcp();
|
||||
private InternalApi internalApi = new InternalApi();
|
||||
private Cluster cluster = new Cluster();
|
||||
|
||||
@@ -256,6 +257,94 @@ public class ApplicationProperties {
|
||||
private int longRunningTimeoutSeconds = 600;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model Context Protocol (MCP) server configuration. All keys live under the top-level {@code
|
||||
* mcp.*} prefix. {@link #enabled} defaults to {@code false}: when off, no MCP beans are wired,
|
||||
* no /mcp endpoint exists, and no protected-resource metadata is published.
|
||||
*/
|
||||
@Data
|
||||
public static class Mcp {
|
||||
|
||||
/** Master switch. When {@code false} (default), no MCP beans are wired. */
|
||||
private boolean enabled = false;
|
||||
|
||||
/**
|
||||
* When {@code true} (default), invocations require an OAuth scope: {@code mcp.tools.read}
|
||||
* for read-style operations and {@code mcp.tools.write} for write/destructive ones. When
|
||||
* {@code false}, scope checks are skipped (use only if your IdP issues a single coarse
|
||||
* scope).
|
||||
*/
|
||||
private boolean scopesEnabled = true;
|
||||
|
||||
/** How often to refresh the AI capabilities manifest from the engine. */
|
||||
private int engineCapabilityRefreshMinutes = 5;
|
||||
|
||||
/**
|
||||
* Tool allow-list (operation ids, e.g. {@code compress-pdf}). When non-empty, ONLY these
|
||||
* operations are exposed over MCP; everything else is hidden, undescribable, and
|
||||
* uninvocable - on top of the global endpoint enable/disable config. Empty = allow all.
|
||||
*/
|
||||
private List<String> allowedOperations = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Tool deny-list (operation ids). Any operation listed here is removed from MCP even if it
|
||||
* would otherwise be allowed. Applied after {@link #allowedOperations}.
|
||||
*/
|
||||
private List<String> blockedOperations = new ArrayList<>();
|
||||
|
||||
/** Max MCP request body size in bytes; inline file uploads ride in the JSON-RPC body. */
|
||||
private long maxRequestBytes = 10L * 1024 * 1024;
|
||||
|
||||
/** Results up to this size return inline as base64; larger ones return a fileId only. */
|
||||
private long maxInlineResponseBytes = 10L * 1024 * 1024;
|
||||
|
||||
private Auth auth = new Auth();
|
||||
|
||||
@Data
|
||||
public static class Auth {
|
||||
/**
|
||||
* Authentication mode for the MCP endpoint. {@code oauth} (default) runs a full OAuth2
|
||||
* resource server (JWT, RFC 8707 audience, RFC 9728 metadata). {@code apikey} accepts a
|
||||
* Stirling per-user API key via the {@code X-API-KEY} header (or {@code Authorization:
|
||||
* Bearer <key>}) and binds the request to that user - the low-friction self-host path,
|
||||
* no external IdP required.
|
||||
*/
|
||||
private String mode = "oauth";
|
||||
|
||||
/** OAuth2 issuer URI, e.g. {@code http://localhost:9000}. Required when MCP is on. */
|
||||
private String issuerUri = "";
|
||||
|
||||
/**
|
||||
* JWKS URI. When blank, derived from the issuer's {@code
|
||||
* /.well-known/openid-configuration} document.
|
||||
*/
|
||||
private String jwksUri = "";
|
||||
|
||||
/**
|
||||
* RFC 8707 resource identifier of THIS MCP server, e.g. {@code
|
||||
* http://localhost:8080/mcp}. Tokens that do not list this id in their {@code aud}
|
||||
* claim are rejected with HTTP 401.
|
||||
*/
|
||||
private String resourceId = "";
|
||||
|
||||
/**
|
||||
* JWT claim whose value is matched against a provisioned Stirling username. Defaults to
|
||||
* {@code sub}; set to {@code email} or {@code preferred_username} to match how your IdP
|
||||
* maps users to Stirling accounts.
|
||||
*/
|
||||
private String usernameClaim = "sub";
|
||||
|
||||
/**
|
||||
* When {@code true} (default), a validated token is accepted only if its {@link
|
||||
* #usernameClaim} value resolves to an existing, enabled Stirling user account. Tokens
|
||||
* whose subject has no Stirling account (or a disabled one) are rejected with HTTP 403.
|
||||
* Set to {@code false} only if you intentionally want any IdP-valid token to use MCP
|
||||
* without a local account.
|
||||
*/
|
||||
private boolean requireExistingAccount = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cluster backplane configuration. All keys live under the top-level {@code cluster.*} prefix
|
||||
* (e.g. env var {@code CLUSTER_ENABLED}). The master switch is {@link #enabled} and defaults to
|
||||
|
||||
@@ -5,6 +5,7 @@ import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.PipedInputStream;
|
||||
import java.io.PipedOutputStream;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
@@ -17,6 +18,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.cluster.FileStore;
|
||||
import stirling.software.common.util.JobContext;
|
||||
|
||||
/**
|
||||
* Service for storing and retrieving files with unique file IDs. Used by the AutoJobPostMapping
|
||||
@@ -32,8 +34,10 @@ public class FileStorage {
|
||||
|
||||
private final FileOrUploadService fileOrUploadService;
|
||||
private final FileStore fileStore;
|
||||
private final Optional<JobOwnershipService> jobOwnershipService;
|
||||
|
||||
public String storeFile(MultipartFile file) throws IOException {
|
||||
String owner = resolveOwner();
|
||||
// Fast path: when Spring buffered the multipart to disk (typical for large uploads), the
|
||||
// backing Resource exposes a real File. Hand the Path to the FileStore so it can do a
|
||||
// file-to-file copy (Linux sendfile, no copy through Java heap) rather than streaming
|
||||
@@ -48,7 +52,7 @@ public class FileStorage {
|
||||
if (res != null && res.isFile()) {
|
||||
try {
|
||||
FileStore.Stored stored =
|
||||
fileStore.store(res.getFile().toPath(), file.getOriginalFilename());
|
||||
fileStore.store(res.getFile().toPath(), file.getOriginalFilename(), owner);
|
||||
log.debug("Stored file with ID: {} (fast path)", stored.fileId());
|
||||
return stored.fileId();
|
||||
} catch (IOException ex) {
|
||||
@@ -57,40 +61,45 @@ public class FileStorage {
|
||||
}
|
||||
}
|
||||
try (InputStream in = file.getInputStream()) {
|
||||
FileStore.Stored stored = fileStore.store(in, file.getOriginalFilename());
|
||||
FileStore.Stored stored = fileStore.store(in, file.getOriginalFilename(), owner);
|
||||
log.debug("Stored file with ID: {}", stored.fileId());
|
||||
return stored.fileId();
|
||||
}
|
||||
}
|
||||
|
||||
public String storeBytes(byte[] bytes, String originalName) throws IOException {
|
||||
FileStore.Stored stored = fileStore.store(new ByteArrayInputStream(bytes), originalName);
|
||||
FileStore.Stored stored =
|
||||
fileStore.store(new ByteArrayInputStream(bytes), originalName, resolveOwner());
|
||||
log.debug("Stored byte array with ID: {}", stored.fileId());
|
||||
return stored.fileId();
|
||||
}
|
||||
|
||||
public MultipartFile retrieveFile(String fileId) throws IOException {
|
||||
enforceOwnership(fileId);
|
||||
byte[] fileData = fileStore.retrieveBytes(fileId);
|
||||
return fileOrUploadService.toMockMultipartFile(fileId, fileData);
|
||||
}
|
||||
|
||||
public byte[] retrieveBytes(String fileId) throws IOException {
|
||||
enforceOwnership(fileId);
|
||||
return fileStore.retrieveBytes(fileId);
|
||||
}
|
||||
|
||||
public InputStream retrieveInputStream(String fileId) throws IOException {
|
||||
enforceOwnership(fileId);
|
||||
return fileStore.retrieve(fileId);
|
||||
}
|
||||
|
||||
public StoredFile storeInputStream(InputStream inputStream, String originalName)
|
||||
throws IOException {
|
||||
FileStore.Stored stored = fileStore.store(inputStream, originalName);
|
||||
FileStore.Stored stored = fileStore.store(inputStream, originalName, resolveOwner());
|
||||
log.debug("Stored input stream with ID: {}", stored.fileId());
|
||||
return new StoredFile(stored.fileId(), stored.size());
|
||||
}
|
||||
|
||||
public String storeFromStreamingBody(StreamingResponseBody body, String originalName)
|
||||
throws IOException {
|
||||
String owner = resolveOwner();
|
||||
// Hold Throwable not IOException: an unchecked failure (NPE, IllegalState, OOM, etc.)
|
||||
// from the body writer would otherwise close the pipe with EOF and the consumer would
|
||||
// return a truncated file with no error surfaced to the caller.
|
||||
@@ -115,7 +124,7 @@ public class FileStorage {
|
||||
}
|
||||
}
|
||||
});
|
||||
FileStore.Stored stored = fileStore.store(in, originalName);
|
||||
FileStore.Stored stored = fileStore.store(in, originalName, owner);
|
||||
Throwable writerErr = bodyError.get();
|
||||
if (writerErr != null) {
|
||||
// Body failed mid-write: the FileStore persisted a truncated entry.
|
||||
@@ -159,21 +168,62 @@ public class FileStorage {
|
||||
|
||||
public String storeFromResource(Resource resource, String originalName) throws IOException {
|
||||
try (InputStream in = resource.getInputStream()) {
|
||||
FileStore.Stored stored = fileStore.store(in, originalName);
|
||||
FileStore.Stored stored = fileStore.store(in, originalName, resolveOwner());
|
||||
log.debug("Stored Resource with ID: {}", stored.fileId());
|
||||
return stored.fileId();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean deleteFile(String fileId) {
|
||||
enforceOwnership(fileId);
|
||||
return fileStore.delete(fileId);
|
||||
}
|
||||
|
||||
public boolean fileExists(String fileId) {
|
||||
enforceOwnership(fileId);
|
||||
return fileStore.exists(fileId);
|
||||
}
|
||||
|
||||
public long getFileSize(String fileId) throws IOException {
|
||||
enforceOwnership(fileId);
|
||||
return fileStore.size(fileId);
|
||||
}
|
||||
|
||||
private String resolveOwner() {
|
||||
String propagated = JobContext.getOwner();
|
||||
if (propagated != null) {
|
||||
return propagated;
|
||||
}
|
||||
return jobOwnershipService.flatMap(JobOwnershipService::getCurrentUserId).orElse(null);
|
||||
}
|
||||
|
||||
private void enforceOwnership(String fileId) {
|
||||
if (jobOwnershipService.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Optional<String> currentUser = jobOwnershipService.get().getCurrentUserId();
|
||||
if (currentUser.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String owner;
|
||||
try {
|
||||
owner = fileStore.getOwner(fileId);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to read owner for file {}: {}", fileId, e.getMessage());
|
||||
throw new SecurityException(
|
||||
"Access denied: could not verify ownership of the requested file");
|
||||
}
|
||||
if (owner == null) {
|
||||
return;
|
||||
}
|
||||
if (!owner.equals(currentUser.get())) {
|
||||
log.warn(
|
||||
"Access denied: user {} attempted to access file {} owned by {}",
|
||||
currentUser.get(),
|
||||
fileId,
|
||||
owner);
|
||||
throw new SecurityException(
|
||||
"Access denied: you do not have permission to access this file");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,11 @@ public class JobExecutorService {
|
||||
|
||||
String jobId = scopedJobKey;
|
||||
|
||||
final String jobOwner =
|
||||
jobOwnershipService != null
|
||||
? jobOwnershipService.getCurrentUserId().orElse(null)
|
||||
: null;
|
||||
|
||||
long timeoutToUse = customTimeoutMs > 0 ? customTimeoutMs : effectiveTimeoutMs;
|
||||
|
||||
log.debug(
|
||||
@@ -119,6 +124,7 @@ public class JobExecutorService {
|
||||
try {
|
||||
stirling.software.common.util.JobContext.setJobId(
|
||||
capturedJobIdForQueue);
|
||||
stirling.software.common.util.JobContext.setOwner(jobOwner);
|
||||
Object result = work.get();
|
||||
processJobResult(capturedJobIdForQueue, result);
|
||||
return result;
|
||||
@@ -153,6 +159,7 @@ public class JobExecutorService {
|
||||
timeoutToUse);
|
||||
|
||||
stirling.software.common.util.JobContext.setJobId(capturedJobId);
|
||||
stirling.software.common.util.JobContext.setOwner(jobOwner);
|
||||
Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
|
||||
processJobResult(capturedJobId, result);
|
||||
} catch (TimeoutException te) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
/** Thread-local context for passing job ID across async boundaries */
|
||||
/** Thread-local context for passing job ID and owner across async boundaries */
|
||||
public class JobContext {
|
||||
private static final ThreadLocal<String> CURRENT_JOB_ID = new ThreadLocal<>();
|
||||
private static final ThreadLocal<String> CURRENT_OWNER = new ThreadLocal<>();
|
||||
|
||||
public static void setJobId(String jobId) {
|
||||
CURRENT_JOB_ID.set(jobId);
|
||||
@@ -12,7 +13,16 @@ public class JobContext {
|
||||
return CURRENT_JOB_ID.get();
|
||||
}
|
||||
|
||||
public static void setOwner(String owner) {
|
||||
CURRENT_OWNER.set(owner);
|
||||
}
|
||||
|
||||
public static String getOwner() {
|
||||
return CURRENT_OWNER.get();
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
CURRENT_JOB_ID.remove();
|
||||
CURRENT_OWNER.remove();
|
||||
}
|
||||
}
|
||||
|
||||
+44
@@ -3,11 +3,13 @@ package stirling.software.common.cluster.inprocess;
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -39,4 +41,46 @@ class LocalDiskFileStoreTest {
|
||||
assertThrows(IllegalArgumentException.class, () -> store.resolve("a/b"));
|
||||
assertThrows(IllegalArgumentException.class, () -> store.resolve("a\\b"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerSidecarCannotBeReadAsFileId(@TempDir Path dir) throws IOException {
|
||||
LocalDiskFileStore store = new LocalDiskFileStore(dir.toString());
|
||||
FileStore.Stored stored =
|
||||
store.store(new ByteArrayInputStream("hi".getBytes()), "f.bin", "alice");
|
||||
String sidecarId = stored.fileId() + ".owner";
|
||||
assertThrows(IllegalArgumentException.class, () -> store.resolve(sidecarId));
|
||||
assertThrows(IllegalArgumentException.class, () -> store.retrieveBytes(sidecarId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerIsPersistedAndReturnedByGetOwner(@TempDir Path dir) throws IOException {
|
||||
LocalDiskFileStore store = new LocalDiskFileStore(dir.toString());
|
||||
FileStore.Stored stored =
|
||||
store.store(new ByteArrayInputStream("hi".getBytes()), "f.bin", "alice");
|
||||
assertEquals("alice", store.getOwner(stored.fileId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getOwnerReturnsNullWhenNoOwnerWasRecorded(@TempDir Path dir) throws IOException {
|
||||
LocalDiskFileStore store = new LocalDiskFileStore(dir.toString());
|
||||
FileStore.Stored stored =
|
||||
store.store(new ByteArrayInputStream("hi".getBytes()), "f.bin", null);
|
||||
assertNull(store.getOwner(stored.fileId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getOwnerReturnsNullForUnknownFileId(@TempDir Path dir) throws IOException {
|
||||
LocalDiskFileStore store = new LocalDiskFileStore(dir.toString());
|
||||
assertNull(store.getOwner("00000000-0000-0000-0000-000000000000"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteRemovesOwnerSidecar(@TempDir Path dir) throws IOException {
|
||||
LocalDiskFileStore store = new LocalDiskFileStore(dir.toString());
|
||||
FileStore.Stored stored =
|
||||
store.store(new ByteArrayInputStream("hi".getBytes()), "f.bin", "alice");
|
||||
assertTrue(store.delete(stored.fileId()));
|
||||
assertFalse(Files.exists(dir.resolve(stored.fileId() + ".owner")));
|
||||
assertNull(store.getOwner(stored.fileId()));
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -5,6 +5,7 @@ import static org.mockito.Mockito.mock;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
@@ -19,7 +20,8 @@ class FileStorageDelegationTest {
|
||||
FileStorage fs =
|
||||
new FileStorage(
|
||||
mock(FileOrUploadService.class),
|
||||
new LocalDiskFileStore(tempDir.toString()));
|
||||
new LocalDiskFileStore(tempDir.toString()),
|
||||
Optional.empty());
|
||||
byte[] payload = "round-trip".getBytes();
|
||||
String id = fs.storeBytes(payload, "x.bin");
|
||||
assertArrayEquals(payload, fs.retrieveBytes(id));
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import stirling.software.common.cluster.inprocess.LocalDiskFileStore;
|
||||
import stirling.software.common.util.JobContext;
|
||||
|
||||
class FileStorageOwnershipTest {
|
||||
|
||||
private FileStorage newStorageWithoutSecurity(Path tempDir) {
|
||||
return new FileStorage(
|
||||
mock(FileOrUploadService.class),
|
||||
new LocalDiskFileStore(tempDir.toString()),
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
private FileStorage newStorageWithCurrentUser(Path tempDir, AtomicReference<String> userRef) {
|
||||
JobOwnershipService svc = mock(JobOwnershipService.class);
|
||||
when(svc.getCurrentUserId()).thenAnswer(invocation -> Optional.ofNullable(userRef.get()));
|
||||
return new FileStorage(
|
||||
mock(FileOrUploadService.class),
|
||||
new LocalDiskFileStore(tempDir.toString()),
|
||||
Optional.of(svc));
|
||||
}
|
||||
|
||||
@Test
|
||||
void desktopMode_noOwnershipService_storesAndRetrievesWithoutChecks(@TempDir Path tempDir)
|
||||
throws IOException {
|
||||
FileStorage fs = newStorageWithoutSecurity(tempDir);
|
||||
byte[] payload = "desktop".getBytes();
|
||||
String id = fs.storeBytes(payload, "x.bin");
|
||||
assertArrayEquals(payload, fs.retrieveBytes(id));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameUserStoresAndRetrieves_allowed(@TempDir Path tempDir) throws IOException {
|
||||
AtomicReference<String> user = new AtomicReference<>("alice");
|
||||
FileStorage fs = newStorageWithCurrentUser(tempDir, user);
|
||||
byte[] payload = "alice's file".getBytes();
|
||||
String id = fs.storeBytes(payload, "x.bin");
|
||||
assertArrayEquals(payload, fs.retrieveBytes(id));
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentUserRetrieves_throwsSecurityException(@TempDir Path tempDir) throws IOException {
|
||||
AtomicReference<String> user = new AtomicReference<>("alice");
|
||||
FileStorage fs = newStorageWithCurrentUser(tempDir, user);
|
||||
String id = fs.storeBytes("alice's file".getBytes(), "x.bin");
|
||||
user.set("bob");
|
||||
assertThrows(SecurityException.class, () -> fs.retrieveBytes(id));
|
||||
assertThrows(SecurityException.class, () -> fs.retrieveInputStream(id));
|
||||
assertThrows(SecurityException.class, () -> fs.getFileSize(id));
|
||||
assertThrows(SecurityException.class, () -> fs.fileExists(id));
|
||||
assertThrows(SecurityException.class, () -> fs.deleteFile(id));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anonymousRetrieveOfOwnedFile_allowed_noCurrentUserMeansNoCompare(@TempDir Path tempDir)
|
||||
throws IOException {
|
||||
AtomicReference<String> user = new AtomicReference<>("alice");
|
||||
FileStorage fs = newStorageWithCurrentUser(tempDir, user);
|
||||
byte[] payload = "alice's file".getBytes();
|
||||
String id = fs.storeBytes(payload, "x.bin");
|
||||
user.set(null);
|
||||
assertArrayEquals(payload, fs.retrieveBytes(id));
|
||||
}
|
||||
|
||||
@Test
|
||||
void authedRetrieveOfAnonymousFile_allowed_noOwnerOnFile(@TempDir Path tempDir)
|
||||
throws IOException {
|
||||
AtomicReference<String> user = new AtomicReference<>(null);
|
||||
FileStorage fs = newStorageWithCurrentUser(tempDir, user);
|
||||
byte[] payload = "no-owner".getBytes();
|
||||
String id = fs.storeBytes(payload, "x.bin");
|
||||
user.set("alice");
|
||||
assertArrayEquals(payload, fs.retrieveBytes(id));
|
||||
}
|
||||
|
||||
@Test
|
||||
void propagatedOwner_scopesAsyncWriteWithNoLiveUser(@TempDir Path tempDir) throws IOException {
|
||||
AtomicReference<String> user = new AtomicReference<>(null);
|
||||
FileStorage fs = newStorageWithCurrentUser(tempDir, user);
|
||||
byte[] payload = "alice's async result".getBytes();
|
||||
String id;
|
||||
try {
|
||||
JobContext.setOwner("alice");
|
||||
id = fs.storeBytes(payload, "x.bin");
|
||||
} finally {
|
||||
JobContext.clear();
|
||||
}
|
||||
user.set("alice");
|
||||
assertArrayEquals(payload, fs.retrieveBytes(id));
|
||||
user.set("bob");
|
||||
assertThrows(SecurityException.class, () -> fs.retrieveBytes(id));
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -37,7 +39,10 @@ class FileStorageTest {
|
||||
void setUp() throws IOException {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
fileStorage =
|
||||
new FileStorage(fileOrUploadService, new LocalDiskFileStore(tempDir.toString()));
|
||||
new FileStorage(
|
||||
fileOrUploadService,
|
||||
new LocalDiskFileStore(tempDir.toString()),
|
||||
Optional.empty());
|
||||
|
||||
// Create a mock MultipartFile
|
||||
mockFile = mock(MultipartFile.class);
|
||||
@@ -79,7 +84,7 @@ class FileStorageTest {
|
||||
void testRetrieveFile() throws IOException {
|
||||
// Arrange
|
||||
byte[] fileContent = "Test PDF content".getBytes();
|
||||
String fileId = "test-file-1";
|
||||
String fileId = UUID.randomUUID().toString();
|
||||
Path filePath = tempDir.resolve(fileId);
|
||||
Files.write(filePath, fileContent);
|
||||
|
||||
@@ -99,7 +104,7 @@ class FileStorageTest {
|
||||
void testRetrieveBytes() throws IOException {
|
||||
// Arrange
|
||||
byte[] fileContent = "Test PDF content".getBytes();
|
||||
String fileId = "test-file-2";
|
||||
String fileId = UUID.randomUUID().toString();
|
||||
Path filePath = tempDir.resolve(fileId);
|
||||
Files.write(filePath, fileContent);
|
||||
|
||||
@@ -113,7 +118,7 @@ class FileStorageTest {
|
||||
@Test
|
||||
void testRetrieveFile_FileNotFound() {
|
||||
// Arrange
|
||||
String nonExistentFileId = "non-existent-file";
|
||||
String nonExistentFileId = UUID.randomUUID().toString();
|
||||
|
||||
// Act & Assert
|
||||
assertThrows(IOException.class, () -> fileStorage.retrieveFile(nonExistentFileId));
|
||||
@@ -122,7 +127,7 @@ class FileStorageTest {
|
||||
@Test
|
||||
void testRetrieveBytes_FileNotFound() {
|
||||
// Arrange
|
||||
String nonExistentFileId = "non-existent-file";
|
||||
String nonExistentFileId = UUID.randomUUID().toString();
|
||||
|
||||
// Act & Assert
|
||||
assertThrows(IOException.class, () -> fileStorage.retrieveBytes(nonExistentFileId));
|
||||
@@ -132,7 +137,7 @@ class FileStorageTest {
|
||||
void testDeleteFile() throws IOException {
|
||||
// Arrange
|
||||
byte[] fileContent = "Test PDF content".getBytes();
|
||||
String fileId = "test-file-3";
|
||||
String fileId = UUID.randomUUID().toString();
|
||||
Path filePath = tempDir.resolve(fileId);
|
||||
Files.write(filePath, fileContent);
|
||||
|
||||
@@ -147,7 +152,7 @@ class FileStorageTest {
|
||||
@Test
|
||||
void testDeleteFile_FileNotFound() {
|
||||
// Arrange
|
||||
String nonExistentFileId = "non-existent-file";
|
||||
String nonExistentFileId = UUID.randomUUID().toString();
|
||||
|
||||
// Act
|
||||
boolean result = fileStorage.deleteFile(nonExistentFileId);
|
||||
@@ -160,7 +165,7 @@ class FileStorageTest {
|
||||
void testFileExists() throws IOException {
|
||||
// Arrange
|
||||
byte[] fileContent = "Test PDF content".getBytes();
|
||||
String fileId = "test-file-4";
|
||||
String fileId = UUID.randomUUID().toString();
|
||||
Path filePath = tempDir.resolve(fileId);
|
||||
Files.write(filePath, fileContent);
|
||||
|
||||
@@ -174,7 +179,7 @@ class FileStorageTest {
|
||||
@Test
|
||||
void testFileExists_FileNotFound() {
|
||||
// Arrange
|
||||
String nonExistentFileId = "non-existent-file";
|
||||
String nonExistentFileId = UUID.randomUUID().toString();
|
||||
|
||||
// Act
|
||||
boolean result = fileStorage.fileExists(nonExistentFileId);
|
||||
|
||||
@@ -364,6 +364,24 @@ aiEngine:
|
||||
url: http://localhost:5001 # URL of the Python AI engine
|
||||
timeoutSeconds: 120 # Timeout in seconds for AI engine requests
|
||||
|
||||
# Model Context Protocol (MCP) server. Exposes Stirling's PDF tools (grouped by namespace)
|
||||
# plus the AI agents to MCP clients (Inspector, Claude Desktop, custom). OAuth-protected.
|
||||
# Disabled by default - enable explicitly per deployment after configuring mcp.auth.
|
||||
mcp:
|
||||
enabled: false # Master switch. 'false' (default) means no /mcp endpoint, no metadata, no beans wired.
|
||||
scopesEnabled: true # Enforce mcp.tools.read / mcp.tools.write scopes derived from operation category
|
||||
allowedOperations: [] # Tool allow-list (operation ids, e.g. ['compress-pdf']). Empty = all. When set, ONLY these are exposed over MCP.
|
||||
blockedOperations: [] # Tool deny-list (operation ids). Always removed from MCP even if otherwise allowed.
|
||||
auth:
|
||||
mode: oauth # 'oauth' (full OAuth2 resource server) or 'apikey' (Stirling per-user API key via X-API-KEY header; no external IdP needed - the low-friction self-host option)
|
||||
issuerUri: "" # OAuth2 issuer URI (e.g. http://localhost:9000). Required when mode=oauth.
|
||||
jwksUri: "" # JWKS URI. Blank -> derived from issuer's /.well-known/openid-configuration.
|
||||
resourceId: "" # RFC 8707 resource identifier of THIS MCP server (e.g. http://localhost:8080/mcp).
|
||||
# Required: tokens must list this id in `aud` or the request is rejected.
|
||||
usernameClaim: sub # JWT claim matched against a Stirling username (e.g. 'sub', 'email', 'preferred_username')
|
||||
requireExistingAccount: true # Reject tokens whose subject has no enabled Stirling account (recommended)
|
||||
engineCapabilityRefreshMinutes: 5 # How often to refresh the AI capabilities manifest from the engine
|
||||
|
||||
# Cluster configuration. NOT YET ENABLED - scaffolding for later work. Leave at defaults.
|
||||
cluster:
|
||||
enabled: false # Master switch. 'false' (default) wires the in-process backplane and skips all cluster checks. Single-instance installs do not need to change anything here.
|
||||
|
||||
@@ -52,6 +52,10 @@ dependencies {
|
||||
api 'org.springframework.boot:spring-boot-starter-security'
|
||||
api 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
api 'org.springframework.boot:spring-boot-starter-security-oauth2-client'
|
||||
// MCP server (RFC 8707 audience binding + RFC 9728 metadata) - resource-server side only.
|
||||
// Brings nimbus-jose-jwt onto the proprietary classpath if not already transitive via
|
||||
// oauth2-client; on Boot 4.0.6 the delta is around 130KB because nimbus is already pulled.
|
||||
api 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
|
||||
api 'org.springframework.boot:spring-boot-starter-mail'
|
||||
api 'org.springframework.boot:spring-boot-starter-cache'
|
||||
api 'com.github.ben-manes.caffeine:caffeine'
|
||||
|
||||
+41
-5
@@ -6,6 +6,8 @@ import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -35,6 +37,7 @@ import software.amazon.awssdk.services.s3.model.S3Exception;
|
||||
public class S3FileStore implements FileStore, AutoCloseable {
|
||||
|
||||
public static final String DEFAULT_KEY_PREFIX = "transient/";
|
||||
static final String OWNER_METADATA_KEY = "owner";
|
||||
|
||||
private final S3Client s3Client;
|
||||
private final String bucket;
|
||||
@@ -64,7 +67,7 @@ public class S3FileStore implements FileStore, AutoCloseable {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stored store(InputStream in, String originalName) throws IOException {
|
||||
public Stored store(InputStream in, String originalName, String owner) throws IOException {
|
||||
String fileId = UUID.randomUUID().toString();
|
||||
// S3 PUT requires a known content-length; spool to a temp file first so memory stays
|
||||
// bounded for large payloads, then stream the file to S3 via RequestBody.fromFile.
|
||||
@@ -75,10 +78,13 @@ public class S3FileStore implements FileStore, AutoCloseable {
|
||||
Files.copy(src, tempFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
size = Files.size(tempFile);
|
||||
PutObjectRequest request =
|
||||
PutObjectRequest.builder().bucket(bucket).key(resolveKey(fileId)).build();
|
||||
PutObjectRequest.Builder builder =
|
||||
PutObjectRequest.builder().bucket(bucket).key(resolveKey(fileId));
|
||||
if (owner != null && !owner.isBlank()) {
|
||||
builder.metadata(Map.of(OWNER_METADATA_KEY, owner));
|
||||
}
|
||||
try {
|
||||
s3Client.putObject(request, RequestBody.fromFile(tempFile));
|
||||
s3Client.putObject(builder.build(), RequestBody.fromFile(tempFile));
|
||||
} catch (SdkException e) {
|
||||
throw new IOException("Failed to upload object to S3", e);
|
||||
}
|
||||
@@ -185,6 +191,36 @@ public class S3FileStore implements FileStore, AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getOwner(String fileId) throws IOException {
|
||||
try {
|
||||
validateFileId(fileId);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
HeadObjectRequest request =
|
||||
HeadObjectRequest.builder().bucket(bucket).key(resolveKey(fileId)).build();
|
||||
try {
|
||||
HeadObjectResponse response = s3Client.headObject(request);
|
||||
Map<String, String> metadata =
|
||||
Optional.ofNullable(response.metadata()).orElse(Collections.emptyMap());
|
||||
String owner = metadata.get(OWNER_METADATA_KEY);
|
||||
if (owner != null && !owner.isBlank()) {
|
||||
return owner;
|
||||
}
|
||||
return null;
|
||||
} catch (NoSuchKeyException e) {
|
||||
return null;
|
||||
} catch (S3Exception e) {
|
||||
if (e.statusCode() == 404) {
|
||||
return null;
|
||||
}
|
||||
throw new IOException("Failed to read owner metadata from S3", e);
|
||||
} catch (SdkException e) {
|
||||
throw new IOException("Failed to read owner metadata from S3", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (!ownsClient) {
|
||||
@@ -205,7 +241,7 @@ public class S3FileStore implements FileStore, AutoCloseable {
|
||||
if (fileId == null || fileId.isBlank()) {
|
||||
throw new IllegalArgumentException("File ID must not be blank");
|
||||
}
|
||||
if (fileId.contains("..") || fileId.contains("/") || fileId.contains("\\")) {
|
||||
if (fileId.contains(".") || fileId.contains("/") || fileId.contains("\\")) {
|
||||
throw new IllegalArgumentException("Invalid file ID");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package stirling.software.proprietary.mcp;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/** Per-call context: resolved Stirling identity and granted scopes for an {@link McpTool#call}. */
|
||||
public record McpCallContext(
|
||||
String stirlingUserId, Set<String> grantedScopes, boolean scopesEnabled) {
|
||||
|
||||
public boolean hasScope(String required) {
|
||||
if (!scopesEnabled) {
|
||||
return true;
|
||||
}
|
||||
return required == null || required.isBlank() || grantedScopes.contains(required);
|
||||
}
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
package stirling.software.proprietary.mcp;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.mcp.jsonrpc.JsonRpcError;
|
||||
import stirling.software.proprietary.mcp.jsonrpc.JsonRpcRequest;
|
||||
import stirling.software.proprietary.mcp.jsonrpc.JsonRpcResponse;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ArrayNode;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/** Streamable-HTTP MCP server endpoint serving JSON-RPC 2.0 frames on {@code POST /mcp}. */
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping
|
||||
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
|
||||
public class McpServerController {
|
||||
|
||||
private static final String PREFERRED_PROTOCOL_VERSION = "2025-06-18";
|
||||
private static final Set<String> SUPPORTED_PROTOCOL_VERSIONS =
|
||||
Set.of("2025-06-18", "2025-03-26", "2024-11-05");
|
||||
private static final String SERVER_NAME = "stirling-pdf-mcp";
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final Map<String, McpTool> toolsByName;
|
||||
|
||||
public McpServerController(
|
||||
ObjectMapper mapper, ApplicationProperties applicationProperties, List<McpTool> tools) {
|
||||
this.mapper = mapper;
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.toolsByName = new HashMap<>();
|
||||
for (McpTool tool : tools) {
|
||||
this.toolsByName.put(tool.name(), tool);
|
||||
}
|
||||
log.info(
|
||||
"MCP server controller wired with {} tool(s): {}",
|
||||
toolsByName.size(),
|
||||
toolsByName.keySet());
|
||||
}
|
||||
|
||||
@PostMapping(
|
||||
path = "/mcp",
|
||||
consumes = MediaType.APPLICATION_JSON_VALUE,
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<?> handle(@RequestBody JsonNode body) {
|
||||
JsonRpcRequest request = decode(body);
|
||||
if (request == null) {
|
||||
// Valid JSON but not a JSON-RPC request -> Invalid Request, not Parse error.
|
||||
return ResponseEntity.badRequest()
|
||||
.body(
|
||||
JsonRpcResponse.failure(
|
||||
null,
|
||||
JsonRpcError.invalidRequest(
|
||||
"Body is not a valid JSON-RPC 2.0 request")));
|
||||
}
|
||||
if (request.isNotification()) {
|
||||
log.debug("Notification received: {}", sanitizeForLog(request.method()));
|
||||
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
|
||||
}
|
||||
JsonRpcResponse response;
|
||||
try {
|
||||
response = dispatch(request);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn(
|
||||
"MCP dispatch failed for method {}: {}",
|
||||
sanitizeForLog(request.method()),
|
||||
e.getMessage(),
|
||||
e);
|
||||
response =
|
||||
JsonRpcResponse.failure(
|
||||
request.id(),
|
||||
JsonRpcError.internalError(
|
||||
"Internal error handling " + request.method()));
|
||||
}
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/** Wrap malformed-JSON failures (caught before {@link #handle}) as a JSON-RPC Parse error. */
|
||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||
public ResponseEntity<JsonRpcResponse> handleUnreadable(HttpMessageNotReadableException ex) {
|
||||
return ResponseEntity.badRequest()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(
|
||||
JsonRpcResponse.failure(
|
||||
null, JsonRpcError.parseError("Request body is not valid JSON")));
|
||||
}
|
||||
|
||||
private static String sanitizeForLog(String value) {
|
||||
return value == null ? null : value.replace('\r', ' ').replace('\n', ' ');
|
||||
}
|
||||
|
||||
private JsonRpcRequest decode(JsonNode body) {
|
||||
if (body == null || !body.isObject()) {
|
||||
return null;
|
||||
}
|
||||
JsonNode jsonrpc = body.get("jsonrpc");
|
||||
JsonNode method = body.get("method");
|
||||
if (jsonrpc == null || !"2.0".equals(jsonrpc.asText())) {
|
||||
return null;
|
||||
}
|
||||
if (method == null || !method.isTextual()) {
|
||||
return null;
|
||||
}
|
||||
return new JsonRpcRequest(
|
||||
jsonrpc.asText(), body.get("id"), method.asText(), body.get("params"));
|
||||
}
|
||||
|
||||
private JsonRpcResponse dispatch(JsonRpcRequest request) {
|
||||
return switch (request.method()) {
|
||||
case "initialize" ->
|
||||
JsonRpcResponse.success(request.id(), initializeResult(request.params()));
|
||||
case "tools/list" -> JsonRpcResponse.success(request.id(), toolsListResult());
|
||||
case "tools/call" -> handleToolsCall(request);
|
||||
case "ping" -> JsonRpcResponse.success(request.id(), mapper.createObjectNode());
|
||||
case "notifications/initialized" ->
|
||||
JsonRpcResponse.success(request.id(), mapper.createObjectNode());
|
||||
default ->
|
||||
JsonRpcResponse.failure(
|
||||
request.id(), JsonRpcError.methodNotFound(request.method()));
|
||||
};
|
||||
}
|
||||
|
||||
private ObjectNode initializeResult(JsonNode params) {
|
||||
ObjectNode result = mapper.createObjectNode();
|
||||
// Echo the client's requested protocolVersion when supported, else advertise our preferred.
|
||||
String requested =
|
||||
params != null && params.hasNonNull("protocolVersion")
|
||||
? params.get("protocolVersion").asText()
|
||||
: null;
|
||||
String negotiated =
|
||||
requested != null && SUPPORTED_PROTOCOL_VERSIONS.contains(requested)
|
||||
? requested
|
||||
: PREFERRED_PROTOCOL_VERSION;
|
||||
result.put("protocolVersion", negotiated);
|
||||
ObjectNode caps = result.putObject("capabilities");
|
||||
caps.putObject("tools");
|
||||
ObjectNode info = result.putObject("serverInfo");
|
||||
info.put("name", SERVER_NAME);
|
||||
info.put("version", applicationProperties.getAutomaticallyGenerated().getAppVersion());
|
||||
return result;
|
||||
}
|
||||
|
||||
private ObjectNode toolsListResult() {
|
||||
ObjectNode result = mapper.createObjectNode();
|
||||
ArrayNode tools = result.putArray("tools");
|
||||
for (McpTool t : toolsByName.values()) {
|
||||
ObjectNode entry = mapper.createObjectNode();
|
||||
entry.put("name", t.name());
|
||||
entry.put("description", t.description());
|
||||
entry.set("inputSchema", t.inputSchema());
|
||||
tools.add(entry);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private JsonRpcResponse handleToolsCall(JsonRpcRequest request) {
|
||||
JsonNode params = request.params();
|
||||
if (params == null || !params.isObject()) {
|
||||
return JsonRpcResponse.failure(
|
||||
request.id(), JsonRpcError.invalidParams("Missing params for tools/call"));
|
||||
}
|
||||
JsonNode nameNode = params.get("name");
|
||||
if (nameNode == null || !nameNode.isTextual()) {
|
||||
return JsonRpcResponse.failure(
|
||||
request.id(), JsonRpcError.invalidParams("Missing tool name"));
|
||||
}
|
||||
McpTool tool = toolsByName.get(nameNode.asText());
|
||||
if (tool == null) {
|
||||
return JsonRpcResponse.failure(
|
||||
request.id(), JsonRpcError.invalidParams("Unknown tool: " + nameNode.asText()));
|
||||
}
|
||||
JsonNode args = params.get("arguments");
|
||||
McpCallContext context = resolveContext();
|
||||
ObjectNode toolResult = tool.call(args == null ? mapper.createObjectNode() : args, context);
|
||||
return JsonRpcResponse.success(request.id(), toolResult);
|
||||
}
|
||||
|
||||
private McpCallContext resolveContext() {
|
||||
boolean scopesEnabled = applicationProperties.getMcp().isScopesEnabled();
|
||||
org.springframework.security.core.Authentication auth =
|
||||
org.springframework.security.core.context.SecurityContextHolder.getContext()
|
||||
.getAuthentication();
|
||||
// Fail closed: no/unauthenticated principal yields an empty context so scoped ops are
|
||||
// refused.
|
||||
if (auth == null || !auth.isAuthenticated() || auth.getName() == null) {
|
||||
return new McpCallContext(null, Set.of(), scopesEnabled);
|
||||
}
|
||||
java.util.Set<String> scopes = new java.util.HashSet<>();
|
||||
for (org.springframework.security.core.GrantedAuthority ga : auth.getAuthorities()) {
|
||||
String authority = ga.getAuthority();
|
||||
if (authority != null && authority.startsWith("SCOPE_")) {
|
||||
scopes.add(authority.substring("SCOPE_".length()));
|
||||
}
|
||||
}
|
||||
return new McpCallContext(auth.getName(), scopes, scopesEnabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package stirling.software.proprietary.mcp;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/** Contract every MCP tool registered with the server must satisfy. */
|
||||
public interface McpTool {
|
||||
|
||||
String name();
|
||||
|
||||
String description();
|
||||
|
||||
/** The tool's {@code inputSchema} (an object JSON Schema) published in {@code tools/list}. */
|
||||
ObjectNode inputSchema();
|
||||
|
||||
/** Execute the tool; the controller wraps any thrown exception as an MCP internal error. */
|
||||
ObjectNode call(JsonNode arguments, McpCallContext context);
|
||||
}
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
package stirling.software.proprietary.mcp.catalog;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.event.ContextRefreshedEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Discovers MCP-exposable operations and caches a per-op {@link OperationMeta}. Refreshed on {@link
|
||||
* ContextRefreshedEvent} and filtered on read by {@link
|
||||
* EndpointConfiguration#isEndpointEnabledForUri}. AI capabilities are fed in via {@link
|
||||
* #replaceAiCapabilities}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
|
||||
public class McpToolCatalog {
|
||||
|
||||
private static final String WRITE_SCOPE = "mcp.tools.write";
|
||||
|
||||
private final ApplicationContext applicationContext;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final SimpleSchemaGenerator schemaGenerator;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
// Concurrent: written on the boot thread, read on request threads, AI map replaced at runtime.
|
||||
private final Map<String, OperationMeta> pdfOps = new ConcurrentHashMap<>();
|
||||
|
||||
// Engine-driven AI capabilities. Replaced wholesale by the scheduled refresh task on a
|
||||
// background thread while request threads read via findByOperationId/enabledOps. The volatile
|
||||
// reference makes the swap publication-safe; readers either see the old or the new snapshot,
|
||||
// never a partially-merged one.
|
||||
private volatile Map<String, OperationMeta> aiOps = new ConcurrentHashMap<>();
|
||||
|
||||
public McpToolCatalog(
|
||||
ApplicationContext applicationContext,
|
||||
EndpointConfiguration endpointConfiguration,
|
||||
ApplicationProperties applicationProperties,
|
||||
ObjectMapper objectMapper) {
|
||||
this.applicationContext = applicationContext;
|
||||
this.endpointConfiguration = endpointConfiguration;
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.schemaGenerator = new SimpleSchemaGenerator(objectMapper);
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/** Admin tool filter: non-empty allow list is a whitelist; block list always removes. */
|
||||
private boolean isOperationAllowed(String id) {
|
||||
ApplicationProperties.Mcp mcp = applicationProperties.getMcp();
|
||||
List<String> allowed = mcp.getAllowedOperations();
|
||||
List<String> blocked = mcp.getBlockedOperations();
|
||||
if (blocked != null && blocked.contains(id)) {
|
||||
return false;
|
||||
}
|
||||
if (allowed != null && !allowed.isEmpty()) {
|
||||
return allowed.contains(id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@EventListener(ContextRefreshedEvent.class)
|
||||
public void discover() {
|
||||
pdfOps.clear();
|
||||
for (RequestMappingHandlerMapping mapping :
|
||||
applicationContext.getBeansOfType(RequestMappingHandlerMapping.class).values()) {
|
||||
for (Map.Entry<RequestMappingInfo, HandlerMethod> e :
|
||||
mapping.getHandlerMethods().entrySet()) {
|
||||
indexOne(e.getKey(), e.getValue());
|
||||
}
|
||||
}
|
||||
log.info("MCP tool catalog discovered {} PDF operation(s)", pdfOps.size());
|
||||
}
|
||||
|
||||
private void indexOne(RequestMappingInfo info, HandlerMethod handler) {
|
||||
Set<String> patterns = extractPatterns(info);
|
||||
if (patterns.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Set<RequestMethod> methods = info.getMethodsCondition().getMethods();
|
||||
if (!isInvocableMethod(methods)) {
|
||||
return;
|
||||
}
|
||||
for (String pattern : patterns) {
|
||||
OperationCategory category = OperationCategory.fromUrl(pattern);
|
||||
if (category == null) {
|
||||
continue;
|
||||
}
|
||||
String opId = extractOpId(pattern, category);
|
||||
if (opId == null) {
|
||||
continue;
|
||||
}
|
||||
OperationMeta meta = buildMeta(opId, category, pattern, handler);
|
||||
// First handler wins on duplicate URLs.
|
||||
pdfOps.putIfAbsent(opId, meta);
|
||||
}
|
||||
}
|
||||
|
||||
private OperationMeta buildMeta(
|
||||
String opId, OperationCategory category, String url, HandlerMethod handler) {
|
||||
Method method = handler.getMethod();
|
||||
Operation opAnno = method.getAnnotation(Operation.class);
|
||||
String summary =
|
||||
opAnno != null && !opAnno.summary().isBlank()
|
||||
? opAnno.summary()
|
||||
: prettifyOpId(opId);
|
||||
ObjectNode schema = paramSchemaFor(handler);
|
||||
// Every mutating endpoint requires the write scope.
|
||||
return new OperationMeta(
|
||||
opId,
|
||||
category,
|
||||
summary,
|
||||
schema,
|
||||
WRITE_SCOPE,
|
||||
OperationMeta.Target.JAVA_ENDPOINT,
|
||||
url,
|
||||
handler);
|
||||
}
|
||||
|
||||
private ObjectNode paramSchemaFor(HandlerMethod handler) {
|
||||
Optional<Class<?>> bodyType = firstComplexParamType(handler);
|
||||
return bodyType.map(schemaGenerator::toSchema).orElseGet(() -> emptyObjectSchema());
|
||||
}
|
||||
|
||||
private ObjectNode emptyObjectSchema() {
|
||||
ObjectNode out = objectMapper.createObjectNode();
|
||||
out.put("type", "object");
|
||||
out.put("additionalProperties", true);
|
||||
return out;
|
||||
}
|
||||
|
||||
private Optional<Class<?>> firstComplexParamType(HandlerMethod handler) {
|
||||
for (MethodParameter p : handler.getMethodParameters()) {
|
||||
Class<?> type = p.getParameterType();
|
||||
if (type.isPrimitive() || type == String.class || type.getName().startsWith("java.")) {
|
||||
continue;
|
||||
}
|
||||
// Skip Spring-managed parameter types (HttpServletRequest, Principal, etc.).
|
||||
String pkg = type.getPackageName();
|
||||
if (pkg.startsWith("jakarta.") || pkg.startsWith("org.springframework.")) {
|
||||
continue;
|
||||
}
|
||||
return Optional.of(type);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public List<OperationMeta> enabledOps(OperationCategory category) {
|
||||
if (category == OperationCategory.AI) {
|
||||
List<OperationMeta> ai = new ArrayList<>();
|
||||
for (OperationMeta m : aiOps.values()) {
|
||||
if (isOperationAllowed(m.id())) {
|
||||
ai.add(m);
|
||||
}
|
||||
}
|
||||
return ai;
|
||||
}
|
||||
List<OperationMeta> out = new ArrayList<>();
|
||||
for (OperationMeta m : pdfOps.values()) {
|
||||
if (m.category() == category
|
||||
&& isOperationAllowed(m.id())
|
||||
&& endpointConfiguration.isEndpointEnabledForUri(m.endpointPath())) {
|
||||
out.add(m);
|
||||
}
|
||||
}
|
||||
out.sort((a, b) -> a.id().compareTo(b.id()));
|
||||
return out;
|
||||
}
|
||||
|
||||
public Optional<OperationMeta> findByOperationId(String id) {
|
||||
if (!isOperationAllowed(id)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
// A disabled PDF op returns empty rather than falling through to a same-id AI capability.
|
||||
OperationMeta meta = pdfOps.get(id);
|
||||
if (meta != null) {
|
||||
boolean enabled =
|
||||
meta.target() != OperationMeta.Target.JAVA_ENDPOINT
|
||||
|| endpointConfiguration.isEndpointEnabledForUri(meta.endpointPath());
|
||||
return enabled ? Optional.of(meta) : Optional.empty();
|
||||
}
|
||||
return Optional.ofNullable(aiOps.get(id));
|
||||
}
|
||||
|
||||
/** Replace the AI capabilities snapshot. Called by the engine refresh task. */
|
||||
public void replaceAiCapabilities(Map<String, OperationMeta> updated) {
|
||||
// Build a fresh map then swap atomically via the volatile reference. The previous
|
||||
// implementation did putAll-then-retainAll on a shared ConcurrentHashMap, which left a
|
||||
// transient window where readers could observe stale entries that should have been
|
||||
// removed (race between the two structural updates).
|
||||
Map<String, OperationMeta> next = new ConcurrentHashMap<>(updated);
|
||||
this.aiOps = next;
|
||||
log.info("MCP tool catalog AI capabilities replaced: {} entries", next.size());
|
||||
}
|
||||
|
||||
/** Only POST/PUT endpoints are exposed as tools; DELETE and GET are excluded. */
|
||||
static boolean isInvocableMethod(Set<RequestMethod> methods) {
|
||||
return methods.contains(RequestMethod.POST) || methods.contains(RequestMethod.PUT);
|
||||
}
|
||||
|
||||
private static String extractOpId(String pattern, OperationCategory category) {
|
||||
if (category.urlPrefix() == null || !pattern.startsWith(category.urlPrefix())) {
|
||||
return null;
|
||||
}
|
||||
String tail = pattern.substring(category.urlPrefix().length());
|
||||
if (tail.isBlank() || tail.contains("/") || tail.contains("{")) {
|
||||
// Skip nested paths and path-variable templates.
|
||||
return null;
|
||||
}
|
||||
return tail;
|
||||
}
|
||||
|
||||
private static String prettifyOpId(String id) {
|
||||
return id.replace('-', ' ');
|
||||
}
|
||||
|
||||
private static Set<String> extractPatterns(RequestMappingInfo info) {
|
||||
try {
|
||||
Method getDirectPaths = info.getClass().getMethod("getDirectPaths");
|
||||
Object result = getDirectPaths.invoke(info);
|
||||
if (result instanceof Set<?> set) {
|
||||
Set<String> patterns = new TreeSet<>();
|
||||
for (Object v : set) {
|
||||
if (v instanceof String s) {
|
||||
patterns.add(s);
|
||||
}
|
||||
}
|
||||
return patterns;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.trace("getDirectPaths unavailable on RequestMappingInfo", e);
|
||||
}
|
||||
return Collections.emptySet();
|
||||
}
|
||||
|
||||
public Map<String, OperationMeta> snapshotPdfOps() {
|
||||
return new LinkedHashMap<>(pdfOps);
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package stirling.software.proprietary.mcp.catalog;
|
||||
|
||||
/** MCP tool categories; {@link #urlPrefix} maps a {@code /api/v1/} namespace to a category. */
|
||||
public enum OperationCategory {
|
||||
CONVERT("/api/v1/convert/", "stirling_convert"),
|
||||
PAGES("/api/v1/general/", "stirling_pages"),
|
||||
MISC("/api/v1/misc/", "stirling_misc"),
|
||||
SECURITY("/api/v1/security/", "stirling_security"),
|
||||
AI(null, "stirling_ai");
|
||||
|
||||
private final String urlPrefix;
|
||||
private final String toolName;
|
||||
|
||||
OperationCategory(String urlPrefix, String toolName) {
|
||||
this.urlPrefix = urlPrefix;
|
||||
this.toolName = toolName;
|
||||
}
|
||||
|
||||
public String urlPrefix() {
|
||||
return urlPrefix;
|
||||
}
|
||||
|
||||
public String toolName() {
|
||||
return toolName;
|
||||
}
|
||||
|
||||
public static OperationCategory fromUrl(String url) {
|
||||
if (url == null) {
|
||||
return null;
|
||||
}
|
||||
for (OperationCategory c : values()) {
|
||||
if (c.urlPrefix != null && url.startsWith(c.urlPrefix)) {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package stirling.software.proprietary.mcp.catalog;
|
||||
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/** Metadata for one MCP-exposed operation (PDF endpoint or AI capability). */
|
||||
public record OperationMeta(
|
||||
String id,
|
||||
OperationCategory category,
|
||||
String summary,
|
||||
ObjectNode paramSchema,
|
||||
String requiredScope,
|
||||
Target target,
|
||||
String endpointPath,
|
||||
HandlerMethod handlerMethod) {
|
||||
|
||||
public enum Target {
|
||||
JAVA_ENDPOINT,
|
||||
ENGINE_CAPABILITY
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
package stirling.software.proprietary.mcp.catalog;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.ParameterizedType;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ArrayNode;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Reflection-based JSON Schema generator for controller request-body classes. {@link MultipartFile}
|
||||
* fields are emitted as {@code "type":"string"} with a {@code "format":"file-id"} hint.
|
||||
*/
|
||||
public final class SimpleSchemaGenerator {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
public SimpleSchemaGenerator(ObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public ObjectNode toSchema(Class<?> type) {
|
||||
return toSchema(type, new HashSet<>());
|
||||
}
|
||||
|
||||
private ObjectNode toSchema(Class<?> type, Set<Class<?>> visited) {
|
||||
ObjectNode schema = mapper.createObjectNode();
|
||||
schema.put("type", "object");
|
||||
schema.put("additionalProperties", false);
|
||||
ObjectNode properties = schema.putObject("properties");
|
||||
ArrayNode required = mapper.createArrayNode();
|
||||
if (!visited.add(type)) {
|
||||
// Cycle: emit a loose object and bail.
|
||||
schema.put("additionalProperties", true);
|
||||
return schema;
|
||||
}
|
||||
|
||||
Set<String> seen = new HashSet<>();
|
||||
for (Field field : collectFields(type)) {
|
||||
if (java.lang.reflect.Modifier.isStatic(field.getModifiers())
|
||||
|| java.lang.reflect.Modifier.isTransient(field.getModifiers())) {
|
||||
continue;
|
||||
}
|
||||
// Skip fields Jackson won't (de)serialize.
|
||||
if (field.isAnnotationPresent(JsonIgnore.class)) {
|
||||
continue;
|
||||
}
|
||||
String name = jsonPropertyName(field);
|
||||
if (!seen.add(name)) {
|
||||
continue;
|
||||
}
|
||||
properties.set(name, typeSchema(field.getGenericType(), visited));
|
||||
if (isRequired(field)) {
|
||||
required.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (!required.isEmpty()) {
|
||||
schema.set("required", required);
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
private List<Field> collectFields(Class<?> type) {
|
||||
List<Field> all = new ArrayList<>();
|
||||
for (Class<?> c = type; c != null && c != Object.class; c = c.getSuperclass()) {
|
||||
for (Field f : c.getDeclaredFields()) {
|
||||
all.add(f);
|
||||
}
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
private static String jsonPropertyName(Field field) {
|
||||
JsonProperty ann = field.getAnnotation(JsonProperty.class);
|
||||
if (ann != null && !ann.value().isEmpty()) {
|
||||
return ann.value();
|
||||
}
|
||||
return field.getName();
|
||||
}
|
||||
|
||||
private boolean isRequired(Field field) {
|
||||
JsonProperty json = field.getAnnotation(JsonProperty.class);
|
||||
if (json != null && json.required()) {
|
||||
return true;
|
||||
}
|
||||
return field.isAnnotationPresent(jakarta.validation.constraints.NotNull.class)
|
||||
|| field.isAnnotationPresent(jakarta.validation.constraints.NotBlank.class)
|
||||
|| field.isAnnotationPresent(jakarta.validation.constraints.NotEmpty.class);
|
||||
}
|
||||
|
||||
private ObjectNode typeSchema(Type t, Set<Class<?>> visited) {
|
||||
ObjectNode out = mapper.createObjectNode();
|
||||
if (t instanceof Class<?> c) {
|
||||
populatePrimitive(out, c, visited);
|
||||
} else if (t instanceof ParameterizedType pt) {
|
||||
Type raw = pt.getRawType();
|
||||
if (raw instanceof Class<?> rawClass) {
|
||||
if (java.util.Collection.class.isAssignableFrom(rawClass)) {
|
||||
out.put("type", "array");
|
||||
Type[] args = pt.getActualTypeArguments();
|
||||
if (args.length == 1) {
|
||||
out.set("items", typeSchema(args[0], visited));
|
||||
}
|
||||
} else if (java.util.Map.class.isAssignableFrom(rawClass)) {
|
||||
out.put("type", "object");
|
||||
out.put("additionalProperties", true);
|
||||
} else {
|
||||
populatePrimitive(out, rawClass, visited);
|
||||
}
|
||||
} else {
|
||||
out.put("type", "object");
|
||||
}
|
||||
} else {
|
||||
out.put("type", "object");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private void populatePrimitive(ObjectNode out, Class<?> c, Set<Class<?>> visited) {
|
||||
if (MultipartFile.class.isAssignableFrom(c)) {
|
||||
out.put("type", "string");
|
||||
out.put("format", "file-id");
|
||||
out.put(
|
||||
"description",
|
||||
"Reference to a previously-uploaded file in Stirling's job store.");
|
||||
return;
|
||||
}
|
||||
if (c.isArray()) {
|
||||
out.put("type", "array");
|
||||
out.set("items", typeSchema(c.getComponentType(), visited));
|
||||
return;
|
||||
}
|
||||
if (c == String.class) {
|
||||
out.put("type", "string");
|
||||
} else if (c == boolean.class || c == Boolean.class) {
|
||||
out.put("type", "boolean");
|
||||
} else if (c == int.class
|
||||
|| c == Integer.class
|
||||
|| c == long.class
|
||||
|| c == Long.class
|
||||
|| c == short.class
|
||||
|| c == Short.class
|
||||
|| c == byte.class
|
||||
|| c == Byte.class) {
|
||||
out.put("type", "integer");
|
||||
} else if (c == float.class || c == Float.class || c == double.class || c == Double.class) {
|
||||
out.put("type", "number");
|
||||
} else if (c.isEnum()) {
|
||||
out.put("type", "string");
|
||||
ArrayNode values = out.putArray("enum");
|
||||
for (Object constant : c.getEnumConstants()) {
|
||||
values.add(constant.toString());
|
||||
}
|
||||
} else if (c == java.util.UUID.class) {
|
||||
out.put("type", "string");
|
||||
out.put("format", "uuid");
|
||||
} else if (java.time.temporal.Temporal.class.isAssignableFrom(c)
|
||||
|| c == java.util.Date.class) {
|
||||
out.put("type", "string");
|
||||
out.put("format", "date-time");
|
||||
} else {
|
||||
// Complex bean: recurse with the shared visited set.
|
||||
ObjectNode nested = toSchema(c, visited);
|
||||
out.setAll(nested);
|
||||
}
|
||||
}
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
package stirling.software.proprietary.mcp.engine;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationCategory;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationMeta;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Pulls the engine's capabilities manifest at boot and on a schedule, feeding it into the shared
|
||||
* {@link McpToolCatalog}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
|
||||
public class EngineCapabilityClient {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final McpToolCatalog catalog;
|
||||
private final ObjectMapper mapper;
|
||||
private final HttpClient httpClient;
|
||||
private final String sharedSecret;
|
||||
|
||||
private ScheduledExecutorService scheduler;
|
||||
|
||||
public EngineCapabilityClient(
|
||||
ApplicationProperties applicationProperties,
|
||||
McpToolCatalog catalog,
|
||||
ObjectMapper mapper) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.catalog = catalog;
|
||||
this.mapper = mapper;
|
||||
this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
|
||||
this.sharedSecret = System.getenv("STIRLING_ENGINE_SHARED_SECRET");
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void start() {
|
||||
scheduler =
|
||||
Executors.newSingleThreadScheduledExecutor(
|
||||
r -> {
|
||||
Thread t = new Thread(r, "mcp-engine-capability-refresh");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void onReady() {
|
||||
long minutes =
|
||||
Math.max(1, applicationProperties.getMcp().getEngineCapabilityRefreshMinutes());
|
||||
// First refresh immediately, then on the configured cadence.
|
||||
scheduler.schedule(this::refreshSafely, 0, TimeUnit.SECONDS);
|
||||
scheduler.scheduleAtFixedRate(this::refreshSafely, minutes, minutes, TimeUnit.MINUTES);
|
||||
log.info("MCP engine capability refresh scheduled every {} minute(s)", minutes);
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
void stop() {
|
||||
if (scheduler != null) {
|
||||
scheduler.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshSafely() {
|
||||
try {
|
||||
refresh();
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"MCP engine capability refresh failed ({}). AI tool enum stays at the last"
|
||||
+ " known state until the next successful pull.",
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Visible for testing. */
|
||||
public void refresh() throws IOException, InterruptedException {
|
||||
if (!applicationProperties.getAiEngine().isEnabled()) {
|
||||
log.debug("AI engine disabled; skipping MCP capability refresh");
|
||||
catalog.replaceAiCapabilities(Map.of());
|
||||
return;
|
||||
}
|
||||
// Trim whitespace and any trailing slash to avoid a malformed URI.
|
||||
String base = applicationProperties.getAiEngine().getUrl().strip().replaceAll("/+$", "");
|
||||
URI uri = URI.create(base + "/api/v1/agents/capabilities");
|
||||
HttpRequest.Builder reqBuilder =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(uri)
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.header("Accept", "application/json")
|
||||
.GET();
|
||||
if (sharedSecret != null && !sharedSecret.isBlank()) {
|
||||
reqBuilder.header("X-Engine-Auth", sharedSecret);
|
||||
}
|
||||
HttpResponse<String> response =
|
||||
httpClient.send(reqBuilder.build(), HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException(
|
||||
"Engine capabilities endpoint returned HTTP " + response.statusCode());
|
||||
}
|
||||
Map<String, OperationMeta> parsed = parseManifest(response.body());
|
||||
catalog.replaceAiCapabilities(parsed);
|
||||
}
|
||||
|
||||
private Map<String, OperationMeta> parseManifest(String body) throws IOException {
|
||||
JsonNode root = mapper.readTree(body);
|
||||
JsonNode capabilities = root.get("capabilities");
|
||||
if (capabilities == null || !capabilities.isArray()) {
|
||||
throw new IOException("Manifest missing 'capabilities' array");
|
||||
}
|
||||
Map<String, OperationMeta> out = new LinkedHashMap<>();
|
||||
for (JsonNode entry : capabilities) {
|
||||
JsonNode id = entry.get("id");
|
||||
JsonNode desc = entry.get("description");
|
||||
JsonNode schema = entry.get("input_schema");
|
||||
JsonNode scope = entry.get("required_scope");
|
||||
JsonNode route = entry.get("route");
|
||||
if (id == null || !id.isTextual() || schema == null || !schema.isObject()) {
|
||||
log.warn("Skipping malformed capability entry: {}", entry);
|
||||
continue;
|
||||
}
|
||||
String routeValue = route == null || !route.isTextual() ? null : route.asText();
|
||||
if (routeValue != null && !isSafeRelativeRoute(routeValue)) {
|
||||
// Defence in depth: a tampered manifest must not steer Java at an arbitrary
|
||||
// host/path.
|
||||
log.warn(
|
||||
"Skipping capability '{}' with unsafe route '{}' (must be a server-relative"
|
||||
+ " /api path with no scheme, authority, or '..')",
|
||||
id.asText(),
|
||||
routeValue);
|
||||
continue;
|
||||
}
|
||||
// Fail safe: default to the stricter write scope when the manifest omits one.
|
||||
String requiredScope =
|
||||
scope != null && scope.isTextual() && !scope.asText().isBlank()
|
||||
? scope.asText()
|
||||
: WRITE_SCOPE;
|
||||
ObjectNode schemaCopy = (ObjectNode) schema.deepCopy();
|
||||
out.put(
|
||||
id.asText(),
|
||||
new OperationMeta(
|
||||
id.asText(),
|
||||
OperationCategory.AI,
|
||||
desc == null ? id.asText() : desc.asText(),
|
||||
schemaCopy,
|
||||
requiredScope,
|
||||
OperationMeta.Target.ENGINE_CAPABILITY,
|
||||
routeValue,
|
||||
null));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static final String WRITE_SCOPE = "mcp.tools.write";
|
||||
|
||||
/**
|
||||
* True only for a server-relative {@code /api/} path with no scheme, authority, {@code ..}, or
|
||||
* control chars (blocks SSRF / path escape).
|
||||
*/
|
||||
static boolean isSafeRelativeRoute(String route) {
|
||||
if (route == null || route.isBlank() || !route.startsWith("/api/")) {
|
||||
return false;
|
||||
}
|
||||
if (route.startsWith("//")
|
||||
|| route.contains("..")
|
||||
|| route.contains("@")
|
||||
|| route.contains("\\")
|
||||
|| route.contains(":")) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < route.length(); i++) {
|
||||
char c = route.charAt(i);
|
||||
if (c <= ' ') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package stirling.software.proprietary.mcp.jsonrpc;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
|
||||
/** JSON-RPC 2.0 error object. */
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record JsonRpcError(int code, String message, JsonNode data) {
|
||||
|
||||
public static final int PARSE_ERROR = -32700;
|
||||
public static final int INVALID_REQUEST = -32600;
|
||||
public static final int METHOD_NOT_FOUND = -32601;
|
||||
public static final int INVALID_PARAMS = -32602;
|
||||
public static final int INTERNAL_ERROR = -32603;
|
||||
|
||||
public static JsonRpcError parseError(String message) {
|
||||
return new JsonRpcError(PARSE_ERROR, message, null);
|
||||
}
|
||||
|
||||
public static JsonRpcError invalidRequest(String message) {
|
||||
return new JsonRpcError(INVALID_REQUEST, message, null);
|
||||
}
|
||||
|
||||
public static JsonRpcError methodNotFound(String method) {
|
||||
return new JsonRpcError(METHOD_NOT_FOUND, "Method not found: " + method, null);
|
||||
}
|
||||
|
||||
public static JsonRpcError invalidParams(String message) {
|
||||
return new JsonRpcError(INVALID_PARAMS, message, null);
|
||||
}
|
||||
|
||||
public static JsonRpcError internalError(String message) {
|
||||
return new JsonRpcError(INTERNAL_ERROR, message, null);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package stirling.software.proprietary.mcp.jsonrpc;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
|
||||
/** JSON-RPC 2.0 request frame; a null {@code id} marks a notification. */
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record JsonRpcRequest(String jsonrpc, JsonNode id, String method, JsonNode params) {
|
||||
|
||||
public boolean isNotification() {
|
||||
return id == null || id.isNull();
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package stirling.software.proprietary.mcp.jsonrpc;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
|
||||
/** JSON-RPC 2.0 response; exactly one of {@code result} or {@code error} is non-null. */
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record JsonRpcResponse(String jsonrpc, JsonNode id, Object result, JsonRpcError error) {
|
||||
|
||||
public static JsonRpcResponse success(JsonNode id, Object result) {
|
||||
return new JsonRpcResponse("2.0", id, result, null);
|
||||
}
|
||||
|
||||
public static JsonRpcResponse failure(JsonNode id, JsonRpcError error) {
|
||||
return new JsonRpcResponse("2.0", id, null, error);
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package stirling.software.proprietary.mcp.security;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/**
|
||||
* API-key auth for the MCP endpoint: validates a Stirling per-user API key and binds the request to
|
||||
* that user with the MCP scopes.
|
||||
*/
|
||||
@Slf4j
|
||||
public class McpApiKeyAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final List<GrantedAuthority> MCP_SCOPES =
|
||||
List.of(
|
||||
new SimpleGrantedAuthority("SCOPE_mcp.tools.read"),
|
||||
new SimpleGrantedAuthority("SCOPE_mcp.tools.write"));
|
||||
|
||||
private final UserService userService;
|
||||
|
||||
public McpApiKeyAuthFilter(UserService userService) {
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
Authentication existing = SecurityContextHolder.getContext().getAuthentication();
|
||||
// Treat an anonymous token as not authenticated so the key is still processed.
|
||||
boolean unauthenticated =
|
||||
existing == null
|
||||
|| existing instanceof AnonymousAuthenticationToken
|
||||
|| !existing.isAuthenticated();
|
||||
if (unauthenticated) {
|
||||
String apiKey = extractKey(request);
|
||||
if (apiKey != null && !apiKey.isBlank()) {
|
||||
Optional<User> user = userService.getUserByApiKey(apiKey);
|
||||
if (user.isPresent() && user.get().isEnabled()) {
|
||||
UsernamePasswordAuthenticationToken auth =
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
user.get().getUsername(), null, MCP_SCOPES);
|
||||
SecurityContext context = SecurityContextHolder.createEmptyContext();
|
||||
context.setAuthentication(auth);
|
||||
SecurityContextHolder.setContext(context);
|
||||
} else {
|
||||
log.warn(
|
||||
"MCP access denied: presented API key did not match an active account");
|
||||
}
|
||||
}
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private String extractKey(HttpServletRequest request) {
|
||||
String headerKey = request.getHeader("X-API-KEY");
|
||||
if (headerKey != null && !headerKey.isBlank()) {
|
||||
return headerKey.trim();
|
||||
}
|
||||
String authz = request.getHeader("Authorization");
|
||||
if (authz != null && authz.regionMatches(true, 0, "Bearer ", 0, 7)) {
|
||||
return authz.substring(7).trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package stirling.software.proprietary.mcp.security;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
/**
|
||||
* RFC 8707 audience binding: a JWT at the MCP endpoint must list this server's resource id in its
|
||||
* {@code aud} claim. Fails closed when the resource id is unset.
|
||||
*/
|
||||
public class McpAudienceValidator implements OAuth2TokenValidator<Jwt> {
|
||||
|
||||
private final String expectedResourceId;
|
||||
|
||||
public McpAudienceValidator(String expectedResourceId) {
|
||||
this.expectedResourceId = expectedResourceId == null ? "" : expectedResourceId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidatorResult validate(Jwt token) {
|
||||
if (expectedResourceId.isBlank()) {
|
||||
return OAuth2TokenValidatorResult.failure(
|
||||
new OAuth2Error(
|
||||
"invalid_token",
|
||||
"MCP server has no resource id configured; rejecting all tokens"
|
||||
+ " until mcp.auth.resource-id is set.",
|
||||
null));
|
||||
}
|
||||
List<String> aud = token.getAudience();
|
||||
if (aud == null || !aud.contains(expectedResourceId)) {
|
||||
return OAuth2TokenValidatorResult.failure(
|
||||
new OAuth2Error(
|
||||
"invalid_token",
|
||||
"Token audience does not include this server's resource id ("
|
||||
+ expectedResourceId
|
||||
+ ").",
|
||||
null));
|
||||
}
|
||||
return OAuth2TokenValidatorResult.success();
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package stirling.software.proprietary.mcp.security;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
/**
|
||||
* Emits 401 + {@code WWW-Authenticate: Bearer resource_metadata="..."} (RFC 9728), preferring
|
||||
* X-Forwarded-* headers to build the public-facing metadata URL.
|
||||
*/
|
||||
public class McpAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
||||
|
||||
private final String metadataPath;
|
||||
|
||||
public McpAuthenticationEntryPoint(String metadataPath) {
|
||||
this.metadataPath =
|
||||
metadataPath == null ? "/.well-known/oauth-protected-resource" : metadataPath;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void commence(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
AuthenticationException authException)
|
||||
throws IOException {
|
||||
String scheme = firstForwarded(request, "X-Forwarded-Proto", request.getScheme());
|
||||
String authority = forwardedHost(request, scheme);
|
||||
String metadataUrl = scheme + "://" + authority + metadataPath;
|
||||
response.setHeader(
|
||||
"WWW-Authenticate",
|
||||
"Bearer error=\"invalid_token\", resource_metadata=\"" + metadataUrl + "\"");
|
||||
response.sendError(HttpStatus.UNAUTHORIZED.value(), "Unauthorized");
|
||||
}
|
||||
|
||||
/** host[:port] from forwarded headers when present, else the servlet host/port. */
|
||||
private static String forwardedHost(HttpServletRequest request, String scheme) {
|
||||
String host = firstForwarded(request, "X-Forwarded-Host", null);
|
||||
if (host != null && !host.isBlank()) {
|
||||
// X-Forwarded-Host may already carry a port.
|
||||
if (host.contains(":")) {
|
||||
return host;
|
||||
}
|
||||
String fwdPort = firstForwarded(request, "X-Forwarded-Port", null);
|
||||
if (fwdPort != null && !isDefaultPort(scheme, fwdPort)) {
|
||||
return host + ":" + fwdPort;
|
||||
}
|
||||
return host;
|
||||
}
|
||||
String authority = request.getServerName();
|
||||
int port = request.getServerPort();
|
||||
if (port > 0 && !isDefaultPort(scheme, Integer.toString(port))) {
|
||||
authority = authority + ":" + port;
|
||||
}
|
||||
return authority;
|
||||
}
|
||||
|
||||
/** First (client-most) value of a possibly comma-listed forwarded header, trimmed. */
|
||||
private static String firstForwarded(HttpServletRequest request, String name, String fallback) {
|
||||
String value = request.getHeader(name);
|
||||
if (value == null || value.isBlank()) {
|
||||
return fallback;
|
||||
}
|
||||
int comma = value.indexOf(',');
|
||||
return (comma >= 0 ? value.substring(0, comma) : value).trim();
|
||||
}
|
||||
|
||||
private static boolean isDefaultPort(String scheme, String port) {
|
||||
return ("http".equals(scheme) && "80".equals(port))
|
||||
|| ("https".equals(scheme) && "443".equals(port));
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package stirling.software.proprietary.mcp.security;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ReadListener;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.ServletInputStream;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletRequestWrapper;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
/**
|
||||
* Caps MCP request body size (via Content-Length and by buffering up to the cap) and rejects
|
||||
* oversized bodies with a clean 413 before JSON parsing.
|
||||
*/
|
||||
public class McpRequestSizeFilter extends OncePerRequestFilter {
|
||||
|
||||
private final long maxBodyBytes;
|
||||
|
||||
public McpRequestSizeFilter(long maxBodyBytes) {
|
||||
this.maxBodyBytes = maxBodyBytes > 0 ? maxBodyBytes : 256L * 1024L;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
long declared = request.getContentLengthLong();
|
||||
if (declared > maxBodyBytes) {
|
||||
tooLarge(response);
|
||||
return;
|
||||
}
|
||||
byte[] body;
|
||||
try {
|
||||
body = readUpTo(request.getInputStream(), maxBodyBytes);
|
||||
} catch (BodyTooLargeException e) {
|
||||
tooLarge(response);
|
||||
return;
|
||||
}
|
||||
filterChain.doFilter(new CachedBodyRequest(request, body), response);
|
||||
}
|
||||
|
||||
private static byte[] readUpTo(InputStream in, long max) throws IOException {
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
byte[] chunk = new byte[8192];
|
||||
long total = 0;
|
||||
int n;
|
||||
while ((n = in.read(chunk)) != -1) {
|
||||
total += n;
|
||||
if (total > max) {
|
||||
throw new BodyTooLargeException();
|
||||
}
|
||||
buffer.write(chunk, 0, n);
|
||||
}
|
||||
return buffer.toByteArray();
|
||||
}
|
||||
|
||||
private void tooLarge(HttpServletResponse response) throws IOException {
|
||||
response.setStatus(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter()
|
||||
.write(
|
||||
"{\"error\":\"payload_too_large\",\"message\":\"MCP request body exceeds the"
|
||||
+ " configured limit of "
|
||||
+ maxBodyBytes
|
||||
+ " bytes.\"}");
|
||||
}
|
||||
|
||||
private static final class BodyTooLargeException extends IOException {}
|
||||
|
||||
/** Re-serves the buffered body to the controller. */
|
||||
private static final class CachedBodyRequest extends HttpServletRequestWrapper {
|
||||
private final byte[] body;
|
||||
|
||||
CachedBodyRequest(HttpServletRequest request, byte[] body) {
|
||||
super(request);
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServletInputStream getInputStream() {
|
||||
ByteArrayInputStream source = new ByteArrayInputStream(body);
|
||||
return new ServletInputStream() {
|
||||
@Override
|
||||
public int read() {
|
||||
return source.read();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] b, int off, int len) {
|
||||
return source.read(b, off, len);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isFinished() {
|
||||
return source.available() == 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setReadListener(ReadListener readListener) {
|
||||
// Synchronous buffered body; no async reads.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedReader getReader() {
|
||||
String enc = getCharacterEncoding();
|
||||
Charset cs = enc == null ? StandardCharsets.UTF_8 : Charset.forName(enc);
|
||||
return new BufferedReader(new InputStreamReader(new ByteArrayInputStream(body), cs));
|
||||
}
|
||||
}
|
||||
}
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
package stirling.software.proprietary.mcp.security;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.core.convert.converter.Converter;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtValidators;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
|
||||
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.access.intercept.AuthorizationFilter;
|
||||
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/**
|
||||
* MCP security chain: validates JWTs (JWKS + RFC 8707 audience), maps scope claims to authorities,
|
||||
* and fails closed when the issuer is unset.
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
|
||||
public class McpSecurityConfig {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final UserService userService;
|
||||
|
||||
// Reuse the app's CORS config; ObjectProvider so the chain still wires when no CORS bean
|
||||
// exists.
|
||||
private final ObjectProvider<CorsConfigurationSource> corsConfigurationSource;
|
||||
|
||||
private static final String BASE_PATH = "/mcp";
|
||||
|
||||
public McpSecurityConfig(
|
||||
ApplicationProperties applicationProperties,
|
||||
@Lazy UserService userService,
|
||||
ObjectProvider<CorsConfigurationSource> corsConfigurationSource) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.userService = userService;
|
||||
this.corsConfigurationSource = corsConfigurationSource;
|
||||
}
|
||||
|
||||
/** Enable CORS on the MCP chain using the app-wide source when available. */
|
||||
private void applyCors(HttpSecurity http) throws Exception {
|
||||
CorsConfigurationSource source = corsConfigurationSource.getIfAvailable();
|
||||
if (source != null) {
|
||||
http.cors(cors -> cors.configurationSource(source));
|
||||
}
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void warnIfMisconfigured() {
|
||||
ApplicationProperties.Mcp mcp = applicationProperties.getMcp();
|
||||
if (isApiKeyMode()) {
|
||||
log.info(
|
||||
"MCP auth mode = apikey: clients authenticate with a Stirling per-user API key"
|
||||
+ " (X-API-KEY header). No OAuth issuer required.");
|
||||
} else {
|
||||
if (mcp.getAuth().getIssuerUri().isBlank()) {
|
||||
log.warn(
|
||||
"MCP enabled but mcp.auth.issuer-uri is blank - JWT decoder will reject"
|
||||
+ " every token (fail-closed). Set mcp.auth.issuer-uri and"
|
||||
+ " mcp.auth.resource-id before exposing /mcp to clients.");
|
||||
}
|
||||
if (mcp.getAuth().getResourceId().isBlank()) {
|
||||
log.warn(
|
||||
"MCP enabled but mcp.auth.resource-id is blank - audience validator will"
|
||||
+ " reject every token. Set this to the public URL of the MCP"
|
||||
+ " endpoint (RFC 8707).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(0)
|
||||
SecurityFilterChain mcpSecurityFilterChain(HttpSecurity http, JwtDecoder mcpJwtDecoder)
|
||||
throws Exception {
|
||||
ApplicationProperties.Mcp.Auth auth = applicationProperties.getMcp().getAuth();
|
||||
if (isApiKeyMode()) {
|
||||
return apiKeyFilterChain(http);
|
||||
}
|
||||
return oauthFilterChain(http, mcpJwtDecoder, auth);
|
||||
}
|
||||
|
||||
private boolean isApiKeyMode() {
|
||||
return "apikey".equalsIgnoreCase(applicationProperties.getMcp().getAuth().getMode());
|
||||
}
|
||||
|
||||
/**
|
||||
* API-key chain: a Stirling per-user API key is validated by {@link McpApiKeyAuthFilter};
|
||||
* otherwise 401.
|
||||
*/
|
||||
private SecurityFilterChain apiKeyFilterChain(HttpSecurity http) throws Exception {
|
||||
applyCors(http);
|
||||
http.securityMatcher(BASE_PATH, BASE_PATH + "/**")
|
||||
// CSRF intentionally disabled: /mcp is a stateless JSON-RPC API authenticated by an
|
||||
// out-of-band X-API-KEY header (or Authorization: Bearer <key>). No cookies, no
|
||||
// session, no form submissions; a browser cannot trick a victim into sending the
|
||||
// header cross-origin, so the CSRF attack model does not apply. CodeQL flags this
|
||||
// generically; the SessionCreationPolicy.STATELESS below is the relevant guarantee.
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(a -> a.anyRequest().authenticated())
|
||||
.exceptionHandling(
|
||||
e ->
|
||||
e.authenticationEntryPoint(
|
||||
(request, response, ex) -> {
|
||||
response.setStatus(401);
|
||||
response.setHeader(
|
||||
"WWW-Authenticate",
|
||||
"Bearer realm=\"Stirling MCP (API key)\"");
|
||||
response.setContentType("application/json");
|
||||
response.getWriter()
|
||||
.write(
|
||||
"{\"error\":\"unauthorized\",\"message\":\"Provide a valid Stirling API key via the X-API-KEY header (or Authorization: Bearer <key>).\"}");
|
||||
}))
|
||||
.addFilterBefore(
|
||||
new McpRequestSizeFilter(
|
||||
applicationProperties.getMcp().getMaxRequestBytes()),
|
||||
AuthorizationFilter.class)
|
||||
// Authenticate before the anonymous filter sets an anonymous token.
|
||||
.addFilterBefore(
|
||||
new McpApiKeyAuthFilter(userService), AnonymousAuthenticationFilter.class);
|
||||
return http.build();
|
||||
}
|
||||
|
||||
/** OAuth2 resource-server chain (JWT, RFC 8707 audience, RFC 9728 metadata). */
|
||||
private SecurityFilterChain oauthFilterChain(
|
||||
HttpSecurity http, JwtDecoder mcpJwtDecoder, ApplicationProperties.Mcp.Auth auth)
|
||||
throws Exception {
|
||||
String metadataPath = "/.well-known/oauth-protected-resource";
|
||||
applyCors(http);
|
||||
http.securityMatcher(BASE_PATH, BASE_PATH + "/**", metadataPath)
|
||||
// CSRF intentionally disabled: /mcp is a stateless JSON-RPC resource server
|
||||
// authenticated by OAuth2 Bearer JWTs (Authorization header). No cookies, no
|
||||
// session, no form submissions; CSRF requires browser-attached ambient credentials
|
||||
// and the bearer token is supplied per-request by the MCP client. CodeQL flags
|
||||
// this generically; the SessionCreationPolicy.STATELESS below is the actual
|
||||
// guarantee, and the .well-known metadata endpoint only serves GET.
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(
|
||||
a ->
|
||||
a.requestMatchers(HttpMethod.GET, metadataPath)
|
||||
.permitAll()
|
||||
.anyRequest()
|
||||
.authenticated())
|
||||
// Cap body size pre-auth, then bind the validated token to a Stirling user after
|
||||
// the bearer filter.
|
||||
.addFilterBefore(
|
||||
new McpRequestSizeFilter(
|
||||
applicationProperties.getMcp().getMaxRequestBytes()),
|
||||
BearerTokenAuthenticationFilter.class)
|
||||
.addFilterAfter(
|
||||
new McpUserBindingFilter(
|
||||
userService,
|
||||
auth.getUsernameClaim(),
|
||||
auth.isRequireExistingAccount()),
|
||||
BearerTokenAuthenticationFilter.class)
|
||||
.oauth2ResourceServer(
|
||||
oauth2 ->
|
||||
oauth2.authenticationEntryPoint(
|
||||
new McpAuthenticationEntryPoint(metadataPath))
|
||||
// RFC 9728 protected-resource metadata for OAuth discovery.
|
||||
.protectedResourceMetadata(
|
||||
prm ->
|
||||
prm.protectedResourceMetadataCustomizer(
|
||||
builder -> {
|
||||
if (!auth.getResourceId()
|
||||
.isBlank()) {
|
||||
builder.resource(
|
||||
auth
|
||||
.getResourceId());
|
||||
}
|
||||
if (!auth.getIssuerUri()
|
||||
.isBlank()) {
|
||||
builder.authorizationServer(
|
||||
auth
|
||||
.getIssuerUri());
|
||||
}
|
||||
builder.scope("mcp.tools.read");
|
||||
builder.scope(
|
||||
"mcp.tools.write");
|
||||
}))
|
||||
.jwt(
|
||||
jwt ->
|
||||
jwt.decoder(mcpJwtDecoder)
|
||||
.jwtAuthenticationConverter(
|
||||
mcpJwtAuthenticationConverter())));
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
JwtDecoder mcpJwtDecoder() {
|
||||
ApplicationProperties.Mcp.Auth auth = applicationProperties.getMcp().getAuth();
|
||||
if (auth.getIssuerUri().isBlank()) {
|
||||
// Fail-closed decoder: rejects every token until the issuer is set.
|
||||
return token -> {
|
||||
throw new org.springframework.security.oauth2.jwt.BadJwtException(
|
||||
"mcp.auth.issuer-uri is not configured");
|
||||
};
|
||||
}
|
||||
String jwksUri = auth.getJwksUri();
|
||||
NimbusJwtDecoder decoder =
|
||||
jwksUri.isBlank()
|
||||
? NimbusJwtDecoder.withIssuerLocation(auth.getIssuerUri()).build()
|
||||
: NimbusJwtDecoder.withJwkSetUri(jwksUri).build();
|
||||
OAuth2TokenValidator<Jwt> defaultValidators =
|
||||
JwtValidators.createDefaultWithIssuer(auth.getIssuerUri());
|
||||
OAuth2TokenValidator<Jwt> combined =
|
||||
new DelegatingOAuth2TokenValidator<>(
|
||||
defaultValidators, new McpAudienceValidator(auth.getResourceId()));
|
||||
decoder.setJwtValidator(combined);
|
||||
return decoder;
|
||||
}
|
||||
|
||||
private Converter<Jwt, AbstractAuthenticationToken> mcpJwtAuthenticationConverter() {
|
||||
JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();
|
||||
scopes.setAuthorityPrefix("SCOPE_");
|
||||
scopes.setAuthoritiesClaimName("scope");
|
||||
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
|
||||
converter.setJwtGrantedAuthoritiesConverter(
|
||||
jwt -> {
|
||||
Collection<GrantedAuthority> out = new ArrayList<>(scopes.convert(jwt));
|
||||
List<String> aud = jwt.getAudience();
|
||||
if (aud != null) {
|
||||
for (String a : aud) {
|
||||
out.add(new SimpleGrantedAuthority("AUDIENCE_" + a));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
return converter;
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package stirling.software.proprietary.mcp.security;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Binds an MCP-validated JWT to a provisioned Stirling user: optionally rejects subjects with no
|
||||
* enabled account, then rebinds the principal to the canonical Stirling username (scope authorities
|
||||
* only) so audit/metering attribute correctly.
|
||||
*/
|
||||
@Slf4j
|
||||
public class McpUserBindingFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private final UserService userService;
|
||||
private final String usernameClaim;
|
||||
private final boolean requireExistingAccount;
|
||||
|
||||
public McpUserBindingFilter(
|
||||
UserService userService, String usernameClaim, boolean requireExistingAccount) {
|
||||
this.userService = userService;
|
||||
this.usernameClaim =
|
||||
(usernameClaim == null || usernameClaim.isBlank()) ? "sub" : usernameClaim;
|
||||
this.requireExistingAccount = requireExistingAccount;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
Authentication current = SecurityContextHolder.getContext().getAuthentication();
|
||||
|
||||
// Only act on a JWT-authenticated request; everything else passes through.
|
||||
if (current instanceof JwtAuthenticationToken jwtAuth && jwtAuth.isAuthenticated()) {
|
||||
Jwt jwt = jwtAuth.getToken();
|
||||
String username = jwt.getClaimAsString(usernameClaim);
|
||||
|
||||
if (username == null || username.isBlank()) {
|
||||
reject(
|
||||
response,
|
||||
"Token is missing the '"
|
||||
+ usernameClaim
|
||||
+ "' claim used to map to a"
|
||||
+ " Stirling user.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer the canonical username from the account record; fall back to the claim when
|
||||
// binding is off.
|
||||
String boundUsername = username;
|
||||
if (requireExistingAccount) {
|
||||
Optional<User> account = userService.findByUsernameIgnoreCase(username);
|
||||
if (account.isEmpty() || !account.get().isEnabled()) {
|
||||
log.warn(
|
||||
"MCP access denied: token subject '{}' has no active Stirling account",
|
||||
sanitizeForLog(username));
|
||||
reject(
|
||||
response,
|
||||
"MCP access requires a provisioned, enabled Stirling account for this"
|
||||
+ " subject.");
|
||||
return;
|
||||
}
|
||||
boundUsername = account.get().getUsername();
|
||||
}
|
||||
|
||||
// Rebind to the Stirling username, carrying only the OAuth scope authorities.
|
||||
UsernamePasswordAuthenticationToken bound =
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
boundUsername, null, jwtAuth.getAuthorities());
|
||||
bound.setDetails(jwtAuth.getDetails());
|
||||
SecurityContext context = SecurityContextHolder.createEmptyContext();
|
||||
context.setAuthentication(bound);
|
||||
SecurityContextHolder.setContext(context);
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
/** Strip CR/LF so a crafted claim value can't forge log lines. */
|
||||
private static String sanitizeForLog(String value) {
|
||||
return value == null ? null : value.replace('\r', ' ').replace('\n', ' ');
|
||||
}
|
||||
|
||||
private void reject(HttpServletResponse response, String message) throws IOException {
|
||||
SecurityContextHolder.clearContext();
|
||||
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||
response.setContentType("application/json");
|
||||
ObjectNode body = MAPPER.createObjectNode();
|
||||
body.put("error", "insufficient_account");
|
||||
body.put("message", message);
|
||||
response.getWriter().write(MAPPER.writeValueAsString(body));
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
package stirling.software.proprietary.mcp.tools;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
|
||||
import stirling.software.proprietary.mcp.McpCallContext;
|
||||
import stirling.software.proprietary.mcp.McpTool;
|
||||
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationCategory;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationMeta;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ArrayNode;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Common scaffolding for the PDF category tools. Operation ids and summaries come from the live
|
||||
* {@link McpToolCatalog}.
|
||||
*/
|
||||
abstract class AbstractCategoryTool implements McpTool {
|
||||
|
||||
protected final ObjectMapper mapper;
|
||||
protected final ObjectProvider<McpToolCatalog> catalogProvider;
|
||||
protected final ObjectProvider<McpOperationExecutor> executorProvider;
|
||||
|
||||
protected AbstractCategoryTool(
|
||||
ObjectMapper mapper,
|
||||
ObjectProvider<McpToolCatalog> catalog,
|
||||
ObjectProvider<McpOperationExecutor> executor) {
|
||||
this.mapper = mapper;
|
||||
this.catalogProvider = catalog;
|
||||
this.executorProvider = executor;
|
||||
}
|
||||
|
||||
protected abstract OperationCategory category();
|
||||
|
||||
protected List<OperationMeta> enabledOperations() {
|
||||
McpToolCatalog catalog = catalogProvider.getIfAvailable();
|
||||
if (catalog == null) {
|
||||
return List.of();
|
||||
}
|
||||
return catalog.enabledOps(category());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectNode inputSchema() {
|
||||
ObjectNode schema = mapper.createObjectNode();
|
||||
schema.put("type", "object");
|
||||
schema.put("additionalProperties", false);
|
||||
|
||||
ObjectNode props = schema.putObject("properties");
|
||||
|
||||
ObjectNode op = props.putObject("operation");
|
||||
op.put("type", "string");
|
||||
List<OperationMeta> enabled = enabledOperations();
|
||||
StringBuilder opDesc = new StringBuilder();
|
||||
opDesc.append(
|
||||
"Operation id from this category. Call stirling_describe_operation first to learn"
|
||||
+ " the exact parameters schema. Available operations:\n");
|
||||
ArrayNode opEnum = op.putArray("enum");
|
||||
for (OperationMeta m : enabled) {
|
||||
opEnum.add(m.id());
|
||||
opDesc.append("- ").append(m.id()).append(" - ").append(m.summary()).append('\n');
|
||||
}
|
||||
op.put("description", opDesc.toString().trim());
|
||||
|
||||
ObjectNode params = props.putObject("parameters");
|
||||
params.put("type", "object");
|
||||
params.put(
|
||||
"description",
|
||||
"Per-operation parameters. Schema available via stirling_describe_operation.");
|
||||
params.put("additionalProperties", true);
|
||||
|
||||
McpToolSupport.stringProperty(
|
||||
props,
|
||||
"file",
|
||||
"Base64-encoded file content to process. The recommended way to provide a file for"
|
||||
+ " most uses. Bounded by the MCP request size limit; for very large files"
|
||||
+ " use 'fileId' instead.");
|
||||
McpToolSupport.stringProperty(
|
||||
props,
|
||||
"fileName",
|
||||
"Optional original filename (with extension) for the input; helps operations that"
|
||||
+ " key off file type.");
|
||||
McpToolSupport.stringProperty(
|
||||
props,
|
||||
"fileId",
|
||||
"Reference to a file already stored via stirling_upload. Recommended only for large"
|
||||
+ " files or multi-step workflows; most users should pass the file inline"
|
||||
+ " via 'file' instead.");
|
||||
|
||||
ArrayNode required = schema.putArray("required");
|
||||
required.add("operation");
|
||||
return schema;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectNode call(JsonNode arguments, McpCallContext context) {
|
||||
JsonNode opNode = arguments == null ? null : arguments.get("operation");
|
||||
// No operation chosen: return this category's operation list.
|
||||
if (opNode == null || !opNode.isTextual() || opNode.asText().isBlank()) {
|
||||
return operationListError(null);
|
||||
}
|
||||
String opId = opNode.asText();
|
||||
McpToolCatalog catalog = catalogProvider.getIfAvailable();
|
||||
if (catalog == null) {
|
||||
return McpResponses.error(mapper, "MCP catalog is not available");
|
||||
}
|
||||
OperationMeta meta = catalog.findByOperationId(opId).orElse(null);
|
||||
// Invalid/disabled/wrong-category op: return this category's operations.
|
||||
if (meta == null || meta.category() != category()) {
|
||||
return operationListError(opId);
|
||||
}
|
||||
if (!context.hasScope(meta.requiredScope())) {
|
||||
return McpResponses.error(
|
||||
mapper,
|
||||
"Insufficient scope: this operation requires '" + meta.requiredScope() + "'.");
|
||||
}
|
||||
McpOperationExecutor executor = executorProvider.getIfAvailable();
|
||||
if (executor == null) {
|
||||
return McpResponses.error(mapper, "MCP execution is not available.");
|
||||
}
|
||||
return executor.execute(meta, arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Error for a missing/unknown operation, listing this category's available operation ids and
|
||||
* summaries.
|
||||
*/
|
||||
private ObjectNode operationListError(String badOpId) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (badOpId == null) {
|
||||
sb.append("Missing required argument 'operation' for ").append(category().toolName());
|
||||
} else {
|
||||
sb.append("Unknown or disabled operation '")
|
||||
.append(badOpId)
|
||||
.append("' for ")
|
||||
.append(category().toolName());
|
||||
}
|
||||
List<OperationMeta> ops = enabledOperations();
|
||||
if (ops.isEmpty()) {
|
||||
sb.append(". No operations are currently available in this category.");
|
||||
} else {
|
||||
sb.append(". Available operations:");
|
||||
for (OperationMeta m : ops) {
|
||||
sb.append("\n- ").append(m.id()).append(" - ").append(m.summary());
|
||||
}
|
||||
sb.append("\nRe-call this tool with a valid 'operation'.");
|
||||
}
|
||||
return McpResponses.error(mapper, sb.toString());
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package stirling.software.proprietary.mcp.tools;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import stirling.software.proprietary.mcp.McpCallContext;
|
||||
import stirling.software.proprietary.mcp.McpTool;
|
||||
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationMeta;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ArrayNode;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/** Returns the JSON Schema for one operation's parameters, from the live {@link McpToolCatalog}. */
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
|
||||
public class DescribeOperationTool implements McpTool {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
private final ObjectProvider<McpToolCatalog> catalogProvider;
|
||||
|
||||
public DescribeOperationTool(ObjectMapper mapper, ObjectProvider<McpToolCatalog> catalog) {
|
||||
this.mapper = mapper;
|
||||
this.catalogProvider = catalog;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "stirling_describe_operation";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Return the full JSON Schema for one Stirling operation's parameters. Call this "
|
||||
+ "before invoking a category tool to learn the exact shape of `parameters`. "
|
||||
+ "Argument: { operation: <op-id> } where <op-id> appears in the enum of any "
|
||||
+ "category tool (stirling_convert, _pages, _misc, _security, _ai).";
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectNode inputSchema() {
|
||||
ObjectNode schema = mapper.createObjectNode();
|
||||
schema.put("type", "object");
|
||||
schema.put("additionalProperties", false);
|
||||
ObjectNode props = schema.putObject("properties");
|
||||
ObjectNode op = props.putObject("operation");
|
||||
op.put("type", "string");
|
||||
op.put(
|
||||
"description",
|
||||
"Operation id (e.g. compress-pdf, pdf-to-word, q-and-a). See category tool enums.");
|
||||
ArrayNode required = schema.putArray("required");
|
||||
required.add("operation");
|
||||
return schema;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectNode call(JsonNode arguments, McpCallContext context) {
|
||||
JsonNode opNode = arguments == null ? null : arguments.get("operation");
|
||||
if (opNode == null || !opNode.isTextual() || opNode.asText().isBlank()) {
|
||||
return McpResponses.error(mapper, "Missing required argument: operation");
|
||||
}
|
||||
String opId = opNode.asText();
|
||||
McpToolCatalog catalog = catalogProvider.getIfAvailable();
|
||||
if (catalog == null) {
|
||||
return McpResponses.error(mapper, "MCP catalog is not available");
|
||||
}
|
||||
OperationMeta meta = catalog.findByOperationId(opId).orElse(null);
|
||||
if (meta == null) {
|
||||
return McpResponses.error(mapper, "Unknown or disabled operation: " + opId);
|
||||
}
|
||||
|
||||
ObjectNode payload = mapper.createObjectNode();
|
||||
payload.put("operation", meta.id());
|
||||
payload.put("category", meta.category().toolName());
|
||||
payload.put("summary", meta.summary());
|
||||
payload.put("endpoint", meta.endpointPath());
|
||||
payload.put("requiredScope", meta.requiredScope());
|
||||
payload.set("parametersSchema", meta.paramSchema());
|
||||
return McpResponses.json(mapper, payload);
|
||||
}
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
package stirling.software.proprietary.mcp.tools;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RestClientResponseException;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
import stirling.software.common.service.InternalApiTimeoutException;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationMeta;
|
||||
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Runs a JAVA_ENDPOINT operation: resolves the input file (inline base64 or a fileId), dispatches
|
||||
* to the Stirling endpoint over the loopback via {@link InternalApiClient}, and stores the result.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
|
||||
public class McpOperationExecutor {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
private final InternalApiClient internalApiClient;
|
||||
private final FileStorage fileStorage;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
public McpOperationExecutor(
|
||||
ObjectMapper mapper,
|
||||
InternalApiClient internalApiClient,
|
||||
FileStorage fileStorage,
|
||||
ApplicationProperties applicationProperties) {
|
||||
this.mapper = mapper;
|
||||
this.internalApiClient = internalApiClient;
|
||||
this.fileStorage = fileStorage;
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
public ObjectNode execute(OperationMeta meta, JsonNode arguments) {
|
||||
String fileName = McpToolSupport.textArg(arguments, "fileName");
|
||||
String fileId = McpToolSupport.textArg(arguments, "fileId");
|
||||
byte[] inputBytes;
|
||||
String inputName;
|
||||
if (fileId != null) {
|
||||
try {
|
||||
if (!fileStorage.fileExists(fileId)) {
|
||||
return McpResponses.error(
|
||||
mapper,
|
||||
"Unknown or inaccessible fileId '"
|
||||
+ fileId
|
||||
+ "'. Re-upload with stirling_upload.");
|
||||
}
|
||||
inputBytes = fileStorage.retrieveBytes(fileId);
|
||||
} catch (SecurityException e) {
|
||||
return McpResponses.error(
|
||||
mapper,
|
||||
"Unknown or inaccessible fileId '"
|
||||
+ fileId
|
||||
+ "'. Re-upload with stirling_upload.");
|
||||
} catch (IOException e) {
|
||||
return McpResponses.error(mapper, "Could not read fileId '" + fileId + "'.");
|
||||
}
|
||||
inputName = fileName != null ? fileName : fileId;
|
||||
} else {
|
||||
String base64 = McpToolSupport.textArg(arguments, "file");
|
||||
if (base64 == null) {
|
||||
return McpResponses.error(
|
||||
mapper,
|
||||
"This operation needs an input file. Pass 'file' as base64 (recommended for"
|
||||
+ " most files), or 'fileId' from stirling_upload for large files.");
|
||||
}
|
||||
inputBytes = McpToolSupport.decodeBase64OrNull(base64);
|
||||
if (inputBytes == null) {
|
||||
return McpResponses.error(mapper, "The 'file' argument is not valid base64.");
|
||||
}
|
||||
inputName = fileName != null ? fileName : "input.pdf";
|
||||
}
|
||||
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add("fileInput", bytesResource(inputBytes, inputName));
|
||||
addParameters(body, arguments == null ? null : arguments.get("parameters"));
|
||||
|
||||
ResponseEntity<Resource> response;
|
||||
try {
|
||||
response = internalApiClient.post(meta.endpointPath(), body);
|
||||
} catch (InternalApiTimeoutException e) {
|
||||
return McpResponses.error(
|
||||
mapper,
|
||||
meta.id()
|
||||
+ " timed out after "
|
||||
+ e.getReadTimeout().toSeconds()
|
||||
+ "s. Try a smaller file or a different approach.");
|
||||
} catch (RestClientResponseException e) {
|
||||
log.warn(
|
||||
"MCP {} upstream error: HTTP {} - {}",
|
||||
meta.id(),
|
||||
e.getStatusCode().value(),
|
||||
snippet(e.getResponseBodyAsString()));
|
||||
return McpResponses.error(
|
||||
mapper, meta.id() + " failed: HTTP " + e.getStatusCode().value() + ".");
|
||||
} catch (SecurityException e) {
|
||||
return McpResponses.error(
|
||||
mapper, meta.id() + " endpoint is not permitted for MCP dispatch.");
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("MCP execution of {} failed", meta.id(), e);
|
||||
return McpResponses.error(
|
||||
mapper, meta.id() + " failed unexpectedly. See server logs for details.");
|
||||
}
|
||||
return buildResult(meta, response);
|
||||
}
|
||||
|
||||
private ObjectNode buildResult(OperationMeta meta, ResponseEntity<Resource> response) {
|
||||
Resource body = response.getBody();
|
||||
if (body == null) {
|
||||
return McpResponses.error(mapper, meta.id() + " returned an empty response.");
|
||||
}
|
||||
MediaType contentType = response.getHeaders().getContentType();
|
||||
|
||||
// A JSON body is a structured report (e.g. get-info), not a file.
|
||||
if (contentType != null && MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
|
||||
try (InputStream is = body.getInputStream()) {
|
||||
return McpResponses.text(
|
||||
mapper, new String(is.readAllBytes(), StandardCharsets.UTF_8));
|
||||
} catch (IOException e) {
|
||||
return McpResponses.error(mapper, "Failed to read " + meta.id() + " result.");
|
||||
}
|
||||
}
|
||||
|
||||
String filename =
|
||||
body.getFilename() == null || body.getFilename().isBlank()
|
||||
? meta.id()
|
||||
: body.getFilename();
|
||||
String mimeType =
|
||||
contentType != null
|
||||
? contentType.toString()
|
||||
: MediaType.APPLICATION_OCTET_STREAM_VALUE;
|
||||
long maxInline = applicationProperties.getMcp().getMaxInlineResponseBytes();
|
||||
try {
|
||||
long size = body.contentLength();
|
||||
byte[] inline = null;
|
||||
if (size >= 0 && size <= maxInline) {
|
||||
try (InputStream is = body.getInputStream()) {
|
||||
inline = is.readAllBytes();
|
||||
}
|
||||
}
|
||||
String fileId =
|
||||
inline != null
|
||||
? fileStorage.storeBytes(inline, filename)
|
||||
: storeStreamed(body, filename);
|
||||
String summary =
|
||||
meta.id()
|
||||
+ " succeeded. Result: "
|
||||
+ filename
|
||||
+ " ("
|
||||
+ size
|
||||
+ " bytes), fileId="
|
||||
+ fileId
|
||||
+ ". ";
|
||||
if (inline != null) {
|
||||
return McpResponses.result(
|
||||
mapper,
|
||||
false,
|
||||
McpResponses.textBlock(
|
||||
mapper, summary + "The file is included inline below."),
|
||||
McpResponses.resourceBlock(
|
||||
mapper,
|
||||
"stirling://file/" + fileId,
|
||||
mimeType,
|
||||
Base64.getEncoder().encodeToString(inline)));
|
||||
}
|
||||
return McpResponses.result(
|
||||
mapper,
|
||||
false,
|
||||
McpResponses.textBlock(
|
||||
mapper,
|
||||
summary
|
||||
+ "Large result - fetch it with stirling_download {\"fileId\":\""
|
||||
+ fileId
|
||||
+ "\"}, or pass this fileId to another operation."));
|
||||
} catch (IOException e) {
|
||||
return McpResponses.error(mapper, "Failed to store " + meta.id() + " result.");
|
||||
}
|
||||
}
|
||||
|
||||
private String storeStreamed(Resource body, String filename) throws IOException {
|
||||
try (InputStream is = body.getInputStream()) {
|
||||
return fileStorage.storeInputStream(is, filename).fileId();
|
||||
}
|
||||
}
|
||||
|
||||
private void addParameters(MultiValueMap<String, Object> body, JsonNode params) {
|
||||
if (params == null || !params.isObject()) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> map =
|
||||
mapper.convertValue(params, new TypeReference<Map<String, Object>>() {});
|
||||
for (Map.Entry<String, Object> entry : map.entrySet()) {
|
||||
Object value = entry.getValue();
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
if (value instanceof List<?> list) {
|
||||
if (containsStructured(list)) {
|
||||
body.add(entry.getKey(), mapper.writeValueAsString(list));
|
||||
} else {
|
||||
list.forEach(item -> body.add(entry.getKey(), item));
|
||||
}
|
||||
} else if (value instanceof Map<?, ?>) {
|
||||
body.add(entry.getKey(), mapper.writeValueAsString(value));
|
||||
} else {
|
||||
body.add(entry.getKey(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean containsStructured(List<?> list) {
|
||||
return list.stream().anyMatch(item -> item instanceof Map<?, ?> || item instanceof List<?>);
|
||||
}
|
||||
|
||||
private static Resource bytesResource(byte[] bytes, String filename) {
|
||||
return new ByteArrayResource(bytes) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return filename;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static String snippet(String body) {
|
||||
if (body == null || body.isBlank()) {
|
||||
return "(no body)";
|
||||
}
|
||||
String trimmed = body.strip();
|
||||
return trimmed.length() > 300 ? trimmed.substring(0, 300) + "..." : trimmed;
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package stirling.software.proprietary.mcp.tools;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ArrayNode;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/** Helpers for the MCP {@code CallToolResult} response shape. */
|
||||
public final class McpResponses {
|
||||
|
||||
private McpResponses() {}
|
||||
|
||||
/** Plain-text content block. */
|
||||
public static ObjectNode text(ObjectMapper mapper, String text) {
|
||||
ObjectNode block = mapper.createObjectNode();
|
||||
block.put("type", "text");
|
||||
block.put("text", text);
|
||||
return wrap(mapper, block, false);
|
||||
}
|
||||
|
||||
/** Plain-text error ({@code isError:true}). */
|
||||
public static ObjectNode error(ObjectMapper mapper, String message) {
|
||||
ObjectNode block = mapper.createObjectNode();
|
||||
block.put("type", "text");
|
||||
block.put("text", message);
|
||||
return wrap(mapper, block, true);
|
||||
}
|
||||
|
||||
/** JSON payload as embedded text. */
|
||||
public static ObjectNode json(ObjectMapper mapper, ObjectNode payload) {
|
||||
ObjectNode block = mapper.createObjectNode();
|
||||
block.put("type", "text");
|
||||
block.put("text", payload.toString());
|
||||
return wrap(mapper, block, false);
|
||||
}
|
||||
|
||||
/** A text content block (unwrapped). */
|
||||
public static ObjectNode textBlock(ObjectMapper mapper, String text) {
|
||||
ObjectNode block = mapper.createObjectNode();
|
||||
block.put("type", "text");
|
||||
block.put("text", text);
|
||||
return block;
|
||||
}
|
||||
|
||||
/** An embedded-resource content block carrying base64 file content. */
|
||||
public static ObjectNode resourceBlock(
|
||||
ObjectMapper mapper, String uri, String mimeType, String base64) {
|
||||
ObjectNode block = mapper.createObjectNode();
|
||||
block.put("type", "resource");
|
||||
ObjectNode res = block.putObject("resource");
|
||||
res.put("uri", uri);
|
||||
if (mimeType != null) {
|
||||
res.put("mimeType", mimeType);
|
||||
}
|
||||
res.put("blob", base64);
|
||||
return block;
|
||||
}
|
||||
|
||||
/** Build a result from explicit content blocks. */
|
||||
public static ObjectNode result(ObjectMapper mapper, boolean isError, ObjectNode... blocks) {
|
||||
ObjectNode result = mapper.createObjectNode();
|
||||
ArrayNode content = result.putArray("content");
|
||||
for (ObjectNode b : blocks) {
|
||||
content.add(b);
|
||||
}
|
||||
if (isError) {
|
||||
result.put("isError", true);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ObjectNode wrap(ObjectMapper mapper, ObjectNode block, boolean isError) {
|
||||
ObjectNode result = mapper.createObjectNode();
|
||||
ArrayNode content = result.putArray("content");
|
||||
content.add(block);
|
||||
if (isError) {
|
||||
result.put("isError", true);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package stirling.software.proprietary.mcp.tools;
|
||||
|
||||
import java.util.Base64;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/** Shared helpers for MCP tools: argument parsing and JSON-Schema building. */
|
||||
final class McpToolSupport {
|
||||
|
||||
private McpToolSupport() {}
|
||||
|
||||
/** Trimmed text value of an argument, or null if absent, blank, or not a string. */
|
||||
static String textArg(JsonNode args, String field) {
|
||||
if (args == null) {
|
||||
return null;
|
||||
}
|
||||
JsonNode node = args.get(field);
|
||||
if (node == null || !node.isTextual()) {
|
||||
return null;
|
||||
}
|
||||
String value = node.asText().trim();
|
||||
return value.isEmpty() ? null : value;
|
||||
}
|
||||
|
||||
/** Decode base64 content, or null if the input is not valid base64. */
|
||||
static byte[] decodeBase64OrNull(String base64) {
|
||||
try {
|
||||
return Base64.getDecoder().decode(base64);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a {@code string} property with a description to a JSON-Schema {@code properties} node.
|
||||
*/
|
||||
static void stringProperty(ObjectNode properties, String name, String description) {
|
||||
ObjectNode prop = properties.putObject(name);
|
||||
prop.put("type", "string");
|
||||
prop.put("description", description);
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
package stirling.software.proprietary.mcp.tools;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.mcp.McpCallContext;
|
||||
import stirling.software.proprietary.mcp.McpTool;
|
||||
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationCategory;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationMeta;
|
||||
import stirling.software.proprietary.service.AiEngineClient;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ArrayNode;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Exposes curated Python agent capabilities as a single MCP tool, sourced from the engine
|
||||
* capabilities manifest.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
|
||||
public class StirlingAiTool implements McpTool {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
private final ObjectProvider<McpToolCatalog> catalogProvider;
|
||||
private final ObjectProvider<AiEngineClient> engineClientProvider;
|
||||
|
||||
public StirlingAiTool(
|
||||
ObjectMapper mapper,
|
||||
ObjectProvider<McpToolCatalog> catalog,
|
||||
ObjectProvider<AiEngineClient> engineClient) {
|
||||
this.mapper = mapper;
|
||||
this.catalogProvider = catalog;
|
||||
this.engineClientProvider = engineClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "stirling_ai";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Invoke a Stirling AI agent capability (Q&A about a PDF, edit-plan generation,"
|
||||
+ " inline comments, math audit, draft-spec helper). Call"
|
||||
+ " stirling_describe_operation with the chosen capability id to get its"
|
||||
+ " parameters schema before invoking this tool. Some capabilities return content"
|
||||
+ " inline; others return a job reference that resolves to a file when ready.";
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectNode inputSchema() {
|
||||
ObjectNode schema = mapper.createObjectNode();
|
||||
schema.put("type", "object");
|
||||
schema.put("additionalProperties", false);
|
||||
ObjectNode props = schema.putObject("properties");
|
||||
|
||||
ObjectNode op = props.putObject("operation");
|
||||
op.put("type", "string");
|
||||
StringBuilder desc = new StringBuilder();
|
||||
desc.append("Capability id from the engine manifest. Available capabilities:\n");
|
||||
ArrayNode opEnum = op.putArray("enum");
|
||||
for (OperationMeta m : aiOps()) {
|
||||
opEnum.add(m.id());
|
||||
desc.append("- ").append(m.id()).append(" - ").append(m.summary()).append('\n');
|
||||
}
|
||||
op.put("description", desc.toString().trim());
|
||||
|
||||
ObjectNode params = props.putObject("parameters");
|
||||
params.put("type", "object");
|
||||
params.put("description", "Per-capability parameters.");
|
||||
params.put("additionalProperties", true);
|
||||
|
||||
ObjectNode fileId = props.putObject("fileId");
|
||||
fileId.put("type", "string");
|
||||
fileId.put(
|
||||
"description",
|
||||
"Reference to a previously-uploaded PDF in Stirling's job store. Required for"
|
||||
+ " capabilities that consume a document.");
|
||||
|
||||
ArrayNode required = schema.putArray("required");
|
||||
required.add("operation");
|
||||
return schema;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectNode call(JsonNode arguments, McpCallContext context) {
|
||||
JsonNode opNode = arguments == null ? null : arguments.get("operation");
|
||||
if (opNode == null || !opNode.isTextual() || opNode.asText().isBlank()) {
|
||||
return McpResponses.error(mapper, "Missing required argument: operation");
|
||||
}
|
||||
String opId = opNode.asText();
|
||||
McpToolCatalog catalog = catalogProvider.getIfAvailable();
|
||||
if (catalog == null) {
|
||||
return McpResponses.error(mapper, "MCP catalog is not available");
|
||||
}
|
||||
OperationMeta meta = catalog.findByOperationId(opId).orElse(null);
|
||||
if (meta == null || meta.category() != OperationCategory.AI) {
|
||||
return McpResponses.error(
|
||||
mapper,
|
||||
"Unknown AI capability '"
|
||||
+ opId
|
||||
+ "'. The engine manifest may not be loaded yet - retry shortly or"
|
||||
+ " confirm the engine is reachable.");
|
||||
}
|
||||
if (!context.hasScope(meta.requiredScope())) {
|
||||
return McpResponses.error(
|
||||
mapper,
|
||||
"Insufficient scope: this capability requires '" + meta.requiredScope() + "'.");
|
||||
}
|
||||
AiEngineClient client = engineClientProvider.getIfAvailable();
|
||||
if (client == null) {
|
||||
return McpResponses.error(
|
||||
mapper, "AI engine client is not configured - enable aiEngine in settings.");
|
||||
}
|
||||
if (meta.endpointPath() == null) {
|
||||
return McpResponses.error(
|
||||
mapper,
|
||||
"Capability '" + opId + "' has no route configured in the engine manifest.");
|
||||
}
|
||||
JsonNode params = arguments.get("parameters");
|
||||
String body = (params == null ? mapper.createObjectNode() : params).toString();
|
||||
try {
|
||||
String response = client.post(meta.endpointPath(), body, context.stirlingUserId());
|
||||
return McpResponses.text(mapper, response);
|
||||
} catch (IOException e) {
|
||||
log.warn("MCP AI capability '{}' engine request failed", opId, e);
|
||||
return McpResponses.error(
|
||||
mapper, "Engine request failed for capability '" + opId + "'.");
|
||||
}
|
||||
}
|
||||
|
||||
private List<OperationMeta> aiOps() {
|
||||
McpToolCatalog catalog = catalogProvider.getIfAvailable();
|
||||
if (catalog == null) {
|
||||
return List.of();
|
||||
}
|
||||
return catalog.enabledOps(OperationCategory.AI);
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package stirling.software.proprietary.mcp.tools;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationCategory;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/** Exposes the {@code /api/v1/convert/*} namespace as a single MCP tool. */
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
|
||||
public class StirlingConvertTool extends AbstractCategoryTool {
|
||||
|
||||
public StirlingConvertTool(
|
||||
ObjectMapper mapper,
|
||||
ObjectProvider<McpToolCatalog> catalog,
|
||||
ObjectProvider<McpOperationExecutor> executor) {
|
||||
super(mapper, catalog, executor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "stirling_convert";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Convert files between PDF and other formats (PDF<->Word, PDF<->image, HTML->PDF,"
|
||||
+ " etc.). Inspect the `operation` enum, then call stirling_describe_operation"
|
||||
+ " with the chosen op to get its parameters JSON Schema before calling this"
|
||||
+ " tool.";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected OperationCategory category() {
|
||||
return OperationCategory.CONVERT;
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package stirling.software.proprietary.mcp.tools;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Base64;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.proprietary.mcp.McpCallContext;
|
||||
import stirling.software.proprietary.mcp.McpTool;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Fetches a stored file's content by fileId, returned inline as base64. For large results that were
|
||||
* not returned inline by an operation.
|
||||
*/
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
|
||||
public class StirlingDownloadTool implements McpTool {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
private final FileStorage fileStorage;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
public StirlingDownloadTool(
|
||||
ObjectMapper mapper,
|
||||
FileStorage fileStorage,
|
||||
ApplicationProperties applicationProperties) {
|
||||
this.mapper = mapper;
|
||||
this.fileStorage = fileStorage;
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "stirling_download";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Fetch a stored file's content by fileId (e.g. an operation result), returned inline"
|
||||
+ " as base64. Recommended only when a result was too large to be returned inline."
|
||||
+ " Argument: { fileId: <id> }.";
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectNode inputSchema() {
|
||||
ObjectNode schema = mapper.createObjectNode();
|
||||
schema.put("type", "object");
|
||||
schema.put("additionalProperties", false);
|
||||
ObjectNode props = schema.putObject("properties");
|
||||
McpToolSupport.stringProperty(
|
||||
props, "fileId", "Id of a stored file (e.g. an operation result's fileId).");
|
||||
schema.putArray("required").add("fileId");
|
||||
return schema;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectNode call(JsonNode arguments, McpCallContext context) {
|
||||
if (!context.hasScope("mcp.tools.read")) {
|
||||
return McpResponses.error(
|
||||
mapper, "Insufficient scope: stirling_download requires 'mcp.tools.read'.");
|
||||
}
|
||||
String fileId = McpToolSupport.textArg(arguments, "fileId");
|
||||
if (fileId == null) {
|
||||
return McpResponses.error(mapper, "Missing required argument: fileId.");
|
||||
}
|
||||
long maxInline = applicationProperties.getMcp().getMaxInlineResponseBytes();
|
||||
try {
|
||||
if (!fileStorage.fileExists(fileId)) {
|
||||
return McpResponses.error(
|
||||
mapper, "Unknown or inaccessible fileId '" + fileId + "'.");
|
||||
}
|
||||
long size = fileStorage.getFileSize(fileId);
|
||||
if (size > maxInline) {
|
||||
return McpResponses.error(
|
||||
mapper,
|
||||
"File is "
|
||||
+ size
|
||||
+ " bytes, over the inline limit of "
|
||||
+ maxInline
|
||||
+ " bytes. Raise mcp.maxInlineResponseBytes or retrieve it via the"
|
||||
+ " Stirling UI/API.");
|
||||
}
|
||||
byte[] bytes = fileStorage.retrieveBytes(fileId);
|
||||
return McpResponses.result(
|
||||
mapper,
|
||||
false,
|
||||
McpResponses.textBlock(
|
||||
mapper,
|
||||
"File "
|
||||
+ fileId
|
||||
+ " ("
|
||||
+ bytes.length
|
||||
+ " bytes) included inline below."),
|
||||
McpResponses.resourceBlock(
|
||||
mapper,
|
||||
"stirling://file/" + fileId,
|
||||
MediaType.APPLICATION_OCTET_STREAM_VALUE,
|
||||
Base64.getEncoder().encodeToString(bytes)));
|
||||
} catch (SecurityException e) {
|
||||
return McpResponses.error(mapper, "Unknown or inaccessible fileId '" + fileId + "'.");
|
||||
} catch (IOException e) {
|
||||
return McpResponses.error(mapper, "Failed to read fileId '" + fileId + "'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package stirling.software.proprietary.mcp.tools;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationCategory;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/** Exposes the {@code /api/v1/misc/*} namespace as a single MCP tool. */
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
|
||||
public class StirlingMiscTool extends AbstractCategoryTool {
|
||||
|
||||
public StirlingMiscTool(
|
||||
ObjectMapper mapper,
|
||||
ObjectProvider<McpToolCatalog> catalog,
|
||||
ObjectProvider<McpOperationExecutor> executor) {
|
||||
super(mapper, catalog, executor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "stirling_misc";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Miscellaneous PDF operations: compress, OCR, stamp / watermark, edit metadata,"
|
||||
+ " flatten, repair, and similar utilities. Call stirling_describe_operation with"
|
||||
+ " the chosen op to get its parameters schema before invoking this tool.";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected OperationCategory category() {
|
||||
return OperationCategory.MISC;
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package stirling.software.proprietary.mcp.tools;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationCategory;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/** Exposes the {@code /api/v1/general/*} (page operations) namespace as a single MCP tool. */
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
|
||||
public class StirlingPagesTool extends AbstractCategoryTool {
|
||||
|
||||
public StirlingPagesTool(
|
||||
ObjectMapper mapper,
|
||||
ObjectProvider<McpToolCatalog> catalog,
|
||||
ObjectProvider<McpOperationExecutor> executor) {
|
||||
super(mapper, catalog, executor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "stirling_pages";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Manipulate PDF pages: merge, split, rotate, rearrange, crop, delete, overlay,"
|
||||
+ " add blank pages. Call stirling_describe_operation with the chosen op to get"
|
||||
+ " its parameters schema before invoking this tool.";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected OperationCategory category() {
|
||||
return OperationCategory.PAGES;
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package stirling.software.proprietary.mcp.tools;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationCategory;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/** Exposes the {@code /api/v1/security/*} namespace as a single MCP tool. */
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
|
||||
public class StirlingSecurityTool extends AbstractCategoryTool {
|
||||
|
||||
public StirlingSecurityTool(
|
||||
ObjectMapper mapper,
|
||||
ObjectProvider<McpToolCatalog> catalog,
|
||||
ObjectProvider<McpOperationExecutor> executor) {
|
||||
super(mapper, catalog, executor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "stirling_security";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Security-related PDF operations: password add/remove, redact, sanitize, certify"
|
||||
+ " / sign with cert, validate signature, add watermark. Call"
|
||||
+ " stirling_describe_operation with the chosen op to get its parameters schema"
|
||||
+ " before invoking this tool.";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected OperationCategory category() {
|
||||
return OperationCategory.SECURITY;
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package stirling.software.proprietary.mcp.tools;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.proprietary.mcp.McpCallContext;
|
||||
import stirling.software.proprietary.mcp.McpTool;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Stores a file server-side and returns a fileId. For large files or multi-step workflows only -
|
||||
* most operations accept the file inline via their {@code file} argument.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
|
||||
public class StirlingUploadTool implements McpTool {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
private final FileStorage fileStorage;
|
||||
|
||||
public StirlingUploadTool(ObjectMapper mapper, FileStorage fileStorage) {
|
||||
this.mapper = mapper;
|
||||
this.fileStorage = fileStorage;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "stirling_upload";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Store a file server-side and get back a fileId to reuse across operations."
|
||||
+ " Recommended only for large files or multi-step workflows; for a single"
|
||||
+ " operation on a typical file, pass the file inline via the operation's `file`"
|
||||
+ " argument instead. Argument: { file: <base64>, fileName?: <name> }.";
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectNode inputSchema() {
|
||||
ObjectNode schema = mapper.createObjectNode();
|
||||
schema.put("type", "object");
|
||||
schema.put("additionalProperties", false);
|
||||
ObjectNode props = schema.putObject("properties");
|
||||
McpToolSupport.stringProperty(props, "file", "Base64-encoded file content.");
|
||||
McpToolSupport.stringProperty(
|
||||
props, "fileName", "Optional original filename (with extension).");
|
||||
schema.putArray("required").add("file");
|
||||
return schema;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectNode call(JsonNode arguments, McpCallContext context) {
|
||||
if (!context.hasScope("mcp.tools.write")) {
|
||||
return McpResponses.error(
|
||||
mapper, "Insufficient scope: stirling_upload requires 'mcp.tools.write'.");
|
||||
}
|
||||
String base64 = McpToolSupport.textArg(arguments, "file");
|
||||
if (base64 == null) {
|
||||
return McpResponses.error(
|
||||
mapper, "Missing required argument: file (base64-encoded content).");
|
||||
}
|
||||
byte[] bytes = McpToolSupport.decodeBase64OrNull(base64);
|
||||
if (bytes == null) {
|
||||
return McpResponses.error(mapper, "The 'file' argument is not valid base64.");
|
||||
}
|
||||
String name = McpToolSupport.textArg(arguments, "fileName");
|
||||
if (name == null) {
|
||||
name = "upload.bin";
|
||||
}
|
||||
try {
|
||||
String fileId = fileStorage.storeBytes(bytes, name);
|
||||
return McpResponses.text(
|
||||
mapper,
|
||||
"Stored '"
|
||||
+ name
|
||||
+ "' ("
|
||||
+ bytes.length
|
||||
+ " bytes) as fileId="
|
||||
+ fileId
|
||||
+ ". Pass this fileId to a Stirling operation's 'fileId' argument.");
|
||||
} catch (IOException e) {
|
||||
log.warn("MCP upload failed to store file", e);
|
||||
return McpResponses.error(mapper, "Failed to store the uploaded file.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -618,6 +618,8 @@ public class AdminSettingsController {
|
||||
case "autopipeline", "autoPipeline" -> applicationProperties.getAutoPipeline();
|
||||
case "legal" -> applicationProperties.getLegal();
|
||||
case "telegram" -> applicationProperties.getTelegram();
|
||||
case "aiengine", "aiEngine" -> applicationProperties.getAiEngine();
|
||||
case "mcp" -> applicationProperties.getMcp();
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
@@ -641,7 +643,10 @@ public class AdminSettingsController {
|
||||
"autoPipeline",
|
||||
"autopipeline",
|
||||
"legal",
|
||||
"telegram");
|
||||
"telegram",
|
||||
"aiEngine",
|
||||
"aiengine",
|
||||
"mcp");
|
||||
|
||||
// Pattern to validate safe property paths - only alphanumeric, dots, and underscores
|
||||
private static final Pattern SAFE_KEY_PATTERN =
|
||||
|
||||
+22
-3
@@ -25,6 +25,7 @@ public class AiEngineClient {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final HttpClient httpClient;
|
||||
private final String engineSharedSecret;
|
||||
|
||||
@Autowired
|
||||
public AiEngineClient(ApplicationProperties applicationProperties) {
|
||||
@@ -39,8 +40,17 @@ public class AiEngineClient {
|
||||
|
||||
/** Package-private constructor that accepts an HttpClient directly; intended for tests. */
|
||||
AiEngineClient(ApplicationProperties applicationProperties, HttpClient httpClient) {
|
||||
this(applicationProperties, httpClient, System.getenv("STIRLING_ENGINE_SHARED_SECRET"));
|
||||
}
|
||||
|
||||
/** Package-private constructor that also injects the engine shared secret; for tests. */
|
||||
AiEngineClient(
|
||||
ApplicationProperties applicationProperties,
|
||||
HttpClient httpClient,
|
||||
String engineSharedSecret) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.httpClient = httpClient;
|
||||
this.engineSharedSecret = engineSharedSecret;
|
||||
}
|
||||
|
||||
public String post(String path, String jsonBody, String userId) throws IOException {
|
||||
@@ -78,6 +88,7 @@ public class AiEngineClient {
|
||||
.timeout(timeout)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
|
||||
addUserHeader(builder, userId);
|
||||
addEngineAuthHeader(builder);
|
||||
HttpResponse<String> response = sendRequest(builder.build());
|
||||
|
||||
log.debug("AI engine responded with status {}", response.statusCode());
|
||||
@@ -86,10 +97,15 @@ public class AiEngineClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the X-User-Id header so the engine can scope per-user storage (RAG documents, search
|
||||
* results) to the caller. Skipped when {@code userId} is blank: the engine treats the request
|
||||
* as anonymous and refuses any route that requires tenancy.
|
||||
* Attach the {@code X-Engine-Auth} shared secret when configured so the engine trusts this
|
||||
* backend request.
|
||||
*/
|
||||
private void addEngineAuthHeader(HttpRequest.Builder builder) {
|
||||
if (engineSharedSecret != null && !engineSharedSecret.isBlank()) {
|
||||
builder.header("X-Engine-Auth", engineSharedSecret);
|
||||
}
|
||||
}
|
||||
|
||||
private static void addUserHeader(HttpRequest.Builder builder, String userId) {
|
||||
if (userId != null && !userId.isBlank()) {
|
||||
builder.header("X-User-Id", userId);
|
||||
@@ -129,6 +145,7 @@ public class AiEngineClient {
|
||||
.timeout(timeout)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
|
||||
addUserHeader(builder, userId);
|
||||
addEngineAuthHeader(builder);
|
||||
HttpRequest request = builder.build();
|
||||
|
||||
HttpResponse<Stream<String>> response;
|
||||
@@ -184,6 +201,7 @@ public class AiEngineClient {
|
||||
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()))
|
||||
.DELETE();
|
||||
addUserHeader(builder, userId);
|
||||
addEngineAuthHeader(builder);
|
||||
HttpResponse<String> response = sendRequest(builder.build());
|
||||
|
||||
log.debug("AI engine responded with status {}", response.statusCode());
|
||||
@@ -208,6 +226,7 @@ public class AiEngineClient {
|
||||
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()))
|
||||
.GET();
|
||||
addUserHeader(builder, userId);
|
||||
addEngineAuthHeader(builder);
|
||||
HttpResponse<String> response = sendRequest(builder.build());
|
||||
|
||||
log.debug("AI engine responded with status {}", response.statusCode());
|
||||
|
||||
+23
@@ -250,4 +250,27 @@ class S3FileStoreTest {
|
||||
return toWrite;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void store_withOwner_persistsOwnerMetadata() throws IOException {
|
||||
FileStore.Stored stored =
|
||||
store.store(
|
||||
new ByteArrayInputStream("owned".getBytes(StandardCharsets.UTF_8)),
|
||||
"o.txt",
|
||||
"alice");
|
||||
assertThat(store.getOwner(stored.fileId())).isEqualTo("alice");
|
||||
}
|
||||
|
||||
@Test
|
||||
void store_withoutOwner_yieldsNullFromGetOwner() throws IOException {
|
||||
FileStore.Stored stored =
|
||||
store.store(
|
||||
new ByteArrayInputStream("anon".getBytes(StandardCharsets.UTF_8)), "a.txt");
|
||||
assertThat(store.getOwner(stored.fileId())).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getOwner_returnsNullForUnknownFileId() throws IOException {
|
||||
assertThat(store.getOwner("00000000-0000-0000-0000-000000000000")).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package stirling.software.proprietary.mcp;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
|
||||
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
|
||||
import stirling.software.proprietary.mcp.engine.EngineCapabilityClient;
|
||||
import stirling.software.proprietary.mcp.security.McpSecurityConfig;
|
||||
import stirling.software.proprietary.mcp.tools.DescribeOperationTool;
|
||||
import stirling.software.proprietary.mcp.tools.McpOperationExecutor;
|
||||
import stirling.software.proprietary.mcp.tools.StirlingAiTool;
|
||||
import stirling.software.proprietary.mcp.tools.StirlingConvertTool;
|
||||
import stirling.software.proprietary.mcp.tools.StirlingDownloadTool;
|
||||
import stirling.software.proprietary.mcp.tools.StirlingMiscTool;
|
||||
import stirling.software.proprietary.mcp.tools.StirlingPagesTool;
|
||||
import stirling.software.proprietary.mcp.tools.StirlingSecurityTool;
|
||||
import stirling.software.proprietary.mcp.tools.StirlingUploadTool;
|
||||
|
||||
/** Verifies MCP beans are gated behind {@code @ConditionalOnProperty(name="mcp.enabled")}. */
|
||||
class McpConditionalTest {
|
||||
|
||||
@Test
|
||||
void serverController_isGatedByMcpEnabled() {
|
||||
assertGatedByEnabled(McpServerController.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void securityConfig_isGatedByMcpEnabled() {
|
||||
assertGatedByEnabled(McpSecurityConfig.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void categoryToolsAndDescribeOperation_doNotNeedOwnGate() {
|
||||
// The tool beans are only wired into the gated controller; sanity-check their signatures.
|
||||
Class<?>[] tools = {
|
||||
DescribeOperationTool.class,
|
||||
StirlingConvertTool.class,
|
||||
StirlingPagesTool.class,
|
||||
StirlingMiscTool.class,
|
||||
StirlingSecurityTool.class,
|
||||
StirlingAiTool.class
|
||||
};
|
||||
for (Class<?> t : tools) {
|
||||
assertTrue(
|
||||
McpTool.class.isAssignableFrom(t),
|
||||
t.getSimpleName() + " must implement McpTool");
|
||||
assertNotNull(
|
||||
t.getAnnotation(org.springframework.stereotype.Component.class),
|
||||
t.getSimpleName() + " must be @Component");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void mcpBeans_areNotSaasProfileRestricted() {
|
||||
// Beans gate on mcp.enabled only; no @Profile, so MCP can run under the saas profile too.
|
||||
Class<?>[] beans = {
|
||||
McpServerController.class,
|
||||
McpSecurityConfig.class,
|
||||
McpToolCatalog.class,
|
||||
EngineCapabilityClient.class,
|
||||
McpOperationExecutor.class,
|
||||
DescribeOperationTool.class,
|
||||
StirlingAiTool.class,
|
||||
StirlingConvertTool.class,
|
||||
StirlingMiscTool.class,
|
||||
StirlingPagesTool.class,
|
||||
StirlingSecurityTool.class,
|
||||
StirlingUploadTool.class,
|
||||
StirlingDownloadTool.class
|
||||
};
|
||||
for (Class<?> bean : beans) {
|
||||
assertNull(
|
||||
bean.getAnnotation(Profile.class),
|
||||
bean.getSimpleName()
|
||||
+ " must not be @Profile-restricted so MCP can run under saas");
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertGatedByEnabled(Class<?> beanClass) {
|
||||
ConditionalOnProperty conditional = beanClass.getAnnotation(ConditionalOnProperty.class);
|
||||
assertNotNull(conditional, beanClass.getSimpleName() + " missing @ConditionalOnProperty");
|
||||
assertTrue(
|
||||
Arrays.asList(conditional.name()).contains("mcp.enabled")
|
||||
|| Arrays.asList(conditional.value()).contains("mcp.enabled"),
|
||||
beanClass.getSimpleName() + " must gate on mcp.enabled");
|
||||
assertEquals(
|
||||
"true",
|
||||
conditional.havingValue(),
|
||||
beanClass.getSimpleName() + " must require mcp.enabled=true");
|
||||
}
|
||||
}
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
package stirling.software.proprietary.mcp;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
|
||||
import stirling.software.proprietary.mcp.tools.DescribeOperationTool;
|
||||
import stirling.software.proprietary.mcp.tools.McpOperationExecutor;
|
||||
import stirling.software.proprietary.mcp.tools.StirlingAiTool;
|
||||
import stirling.software.proprietary.mcp.tools.StirlingConvertTool;
|
||||
import stirling.software.proprietary.mcp.tools.StirlingMiscTool;
|
||||
import stirling.software.proprietary.mcp.tools.StirlingPagesTool;
|
||||
import stirling.software.proprietary.mcp.tools.StirlingSecurityTool;
|
||||
import stirling.software.proprietary.service.AiEngineClient;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/** Unit test of the MCP server controller: JSON-RPC framing and the 6-tool contract. */
|
||||
class McpServerControllerTest {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
private final McpServerController controller = buildController();
|
||||
|
||||
private McpServerController buildController() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getAutomaticallyGenerated().setAppVersion("test-version");
|
||||
ObjectProvider<McpToolCatalog> emptyCatalog = emptyProvider();
|
||||
ObjectProvider<AiEngineClient> emptyEngine = emptyProvider();
|
||||
ObjectProvider<McpOperationExecutor> emptyExecutor = emptyProvider();
|
||||
List<McpTool> tools =
|
||||
List.of(
|
||||
new DescribeOperationTool(mapper, emptyCatalog),
|
||||
new StirlingConvertTool(mapper, emptyCatalog, emptyExecutor),
|
||||
new StirlingPagesTool(mapper, emptyCatalog, emptyExecutor),
|
||||
new StirlingMiscTool(mapper, emptyCatalog, emptyExecutor),
|
||||
new StirlingSecurityTool(mapper, emptyCatalog, emptyExecutor),
|
||||
new StirlingAiTool(mapper, emptyCatalog, emptyEngine));
|
||||
return new McpServerController(mapper, props, tools);
|
||||
}
|
||||
|
||||
private static <T> ObjectProvider<T> emptyProvider() {
|
||||
return new ObjectProvider<>() {
|
||||
@Override
|
||||
public T getObject() {
|
||||
throw new UnsupportedOperationException("no bean in unit tests");
|
||||
}
|
||||
|
||||
@Override
|
||||
public T getObject(Object... args) {
|
||||
return getObject();
|
||||
}
|
||||
|
||||
@Override
|
||||
public T getIfAvailable() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public T getIfUnique() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public T getIfAvailable(Supplier<T> defaultSupplier) {
|
||||
return defaultSupplier == null ? null : defaultSupplier.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void ifAvailable(Consumer<T> dependencyConsumer) {}
|
||||
|
||||
@Override
|
||||
public Iterator<T> iterator() {
|
||||
return java.util.Collections.emptyIterator();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolsList_returnsExactlySixTools() throws Exception {
|
||||
JsonNode body = mapper.readTree("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}");
|
||||
|
||||
ResponseEntity<?> response = controller.handle(body);
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
JsonNode tools = mapper.valueToTree(response.getBody()).get("result").get("tools");
|
||||
assertEquals(6, tools.size(), "tools/list must return exactly 6 tools");
|
||||
|
||||
Set<String> names =
|
||||
Set.of(
|
||||
"stirling_describe_operation",
|
||||
"stirling_convert",
|
||||
"stirling_pages",
|
||||
"stirling_misc",
|
||||
"stirling_security",
|
||||
"stirling_ai");
|
||||
Set<String> seen = new java.util.HashSet<>();
|
||||
tools.forEach(t -> seen.add(t.get("name").asText()));
|
||||
assertEquals(names, seen);
|
||||
|
||||
for (JsonNode tool : tools) {
|
||||
assertTrue(tool.get("description").asText().length() > 10, "description present");
|
||||
assertEquals("object", tool.get("inputSchema").get("type").asText());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void initialize_returnsServerInfoAndProtocolVersion() throws Exception {
|
||||
JsonNode body = mapper.readTree("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}");
|
||||
|
||||
ResponseEntity<?> response = controller.handle(body);
|
||||
|
||||
JsonNode result = mapper.valueToTree(response.getBody()).get("result");
|
||||
assertNotNull(result.get("protocolVersion"));
|
||||
assertEquals("stirling-pdf-mcp", result.get("serverInfo").get("name").asText());
|
||||
assertEquals("test-version", result.get("serverInfo").get("version").asText());
|
||||
assertNotNull(result.get("capabilities").get("tools"), "tools capability advertised");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ping_returnsEmptyResult() throws Exception {
|
||||
JsonNode body = mapper.readTree("{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"ping\"}");
|
||||
|
||||
ResponseEntity<?> response = controller.handle(body);
|
||||
|
||||
JsonNode out = mapper.valueToTree(response.getBody());
|
||||
assertEquals(7, out.get("id").asInt());
|
||||
assertNotNull(out.get("result"));
|
||||
assertNull(out.get("error"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void notification_returnsNoContentWithEmptyBody() throws Exception {
|
||||
// No id field: a JSON-RPC notification gets no response object.
|
||||
JsonNode body =
|
||||
mapper.readTree("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}");
|
||||
|
||||
ResponseEntity<?> response = controller.handle(body);
|
||||
|
||||
assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode());
|
||||
assertNull(response.getBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownMethod_returnsMethodNotFoundError() throws Exception {
|
||||
JsonNode body =
|
||||
mapper.readTree("{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"does/not/exist\"}");
|
||||
|
||||
ResponseEntity<?> response = controller.handle(body);
|
||||
|
||||
JsonNode error = mapper.valueToTree(response.getBody()).get("error");
|
||||
assertEquals(-32601, error.get("code").asInt());
|
||||
assertTrue(error.get("message").asText().contains("does/not/exist"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolsCall_unknownTool_returnsInvalidParams() throws Exception {
|
||||
JsonNode body =
|
||||
mapper.readTree(
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\","
|
||||
+ "\"params\":{\"name\":\"stirling_does_not_exist\",\"arguments\":{}}}");
|
||||
|
||||
ResponseEntity<?> response = controller.handle(body);
|
||||
|
||||
JsonNode error = mapper.valueToTree(response.getBody()).get("error");
|
||||
assertEquals(-32602, error.get("code").asInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolsCall_describeOperation_withoutCatalog_returnsErrorContent() throws Exception {
|
||||
// Null catalog: describe must surface an isError content block, not crash.
|
||||
JsonNode body =
|
||||
mapper.readTree(
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\","
|
||||
+ "\"params\":{\"name\":\"stirling_describe_operation\","
|
||||
+ "\"arguments\":{\"operation\":\"compress-pdf\"}}}");
|
||||
|
||||
ResponseEntity<?> response = controller.handle(body);
|
||||
|
||||
JsonNode result = mapper.valueToTree(response.getBody()).get("result");
|
||||
assertTrue(result.get("isError").asBoolean());
|
||||
String text = result.get("content").get(0).get("text").asText();
|
||||
assertTrue(text.toLowerCase().contains("catalog") || text.contains("compress-pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrongShapeJson_returnsInvalidRequest() throws Exception {
|
||||
// Valid JSON but not a JSON-RPC request object -> Invalid Request (-32600).
|
||||
JsonNode body = mapper.readTree("{\"not\":\"a json-rpc frame\"}");
|
||||
|
||||
ResponseEntity<?> response = controller.handle(body);
|
||||
|
||||
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
|
||||
JsonNode error = mapper.valueToTree(response.getBody()).get("error");
|
||||
assertEquals(-32600, error.get("code").asInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void initialize_echoesSupportedClientProtocolVersion() throws Exception {
|
||||
// Older but supported revision -> server echoes it.
|
||||
JsonNode body =
|
||||
mapper.readTree(
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\","
|
||||
+ "\"params\":{\"protocolVersion\":\"2025-03-26\"}}");
|
||||
|
||||
ResponseEntity<?> response = controller.handle(body);
|
||||
|
||||
JsonNode result = mapper.valueToTree(response.getBody()).get("result");
|
||||
assertEquals("2025-03-26", result.get("protocolVersion").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void initialize_unknownClientProtocolVersion_fallsBackToPreferred() throws Exception {
|
||||
JsonNode body =
|
||||
mapper.readTree(
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\","
|
||||
+ "\"params\":{\"protocolVersion\":\"1999-01-01\"}}");
|
||||
|
||||
ResponseEntity<?> response = controller.handle(body);
|
||||
|
||||
JsonNode result = mapper.valueToTree(response.getBody()).get("result");
|
||||
assertEquals("2025-06-18", result.get("protocolVersion").asText());
|
||||
}
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
package stirling.software.proprietary.mcp.catalog;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/** Catalog tests: DELETE/GET exclusion and disabled-PDF-op AI fall-through. */
|
||||
class McpToolCatalogTest {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void isInvocableMethod_excludesDeleteAndGet() {
|
||||
assertTrue(McpToolCatalog.isInvocableMethod(Set.of(RequestMethod.POST)));
|
||||
assertTrue(McpToolCatalog.isInvocableMethod(Set.of(RequestMethod.PUT)));
|
||||
// DELETE/GET handlers must never be cataloged as runnable tools.
|
||||
assertFalse(McpToolCatalog.isInvocableMethod(Set.of(RequestMethod.DELETE)));
|
||||
assertFalse(McpToolCatalog.isInvocableMethod(Set.of(RequestMethod.GET)));
|
||||
// Empty method set (matches all verbs) is not invocable.
|
||||
assertFalse(McpToolCatalog.isInvocableMethod(Set.of()));
|
||||
// Multi-verb mapping including POST stays invocable.
|
||||
assertTrue(
|
||||
McpToolCatalog.isInvocableMethod(Set.of(RequestMethod.POST, RequestMethod.DELETE)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByOperationId_disabledPdfOp_doesNotFallThroughToAi() throws Exception {
|
||||
ApplicationContext ctx = mock(ApplicationContext.class);
|
||||
when(ctx.getBeansOfType(RequestMappingHandlerMapping.class)).thenReturn(Map.of());
|
||||
EndpointConfiguration endpoints = mock(EndpointConfiguration.class);
|
||||
// The PDF op's endpoint is disabled.
|
||||
when(endpoints.isEndpointEnabledForUri(anyString())).thenReturn(false);
|
||||
|
||||
McpToolCatalog catalog =
|
||||
new McpToolCatalog(ctx, endpoints, new ApplicationProperties(), mapper);
|
||||
|
||||
ObjectNode schema = mapper.createObjectNode();
|
||||
OperationMeta pdf =
|
||||
new OperationMeta(
|
||||
"collide",
|
||||
OperationCategory.MISC,
|
||||
"pdf op",
|
||||
schema,
|
||||
"mcp.tools.write",
|
||||
OperationMeta.Target.JAVA_ENDPOINT,
|
||||
"/api/v1/misc/collide",
|
||||
null);
|
||||
OperationMeta ai =
|
||||
new OperationMeta(
|
||||
"collide",
|
||||
OperationCategory.AI,
|
||||
"ai op",
|
||||
schema,
|
||||
"mcp.tools.write",
|
||||
OperationMeta.Target.ENGINE_CAPABILITY,
|
||||
"collide",
|
||||
null);
|
||||
seed(catalog, "pdfOps", "collide", pdf);
|
||||
seed(catalog, "aiOps", "collide", ai);
|
||||
|
||||
// A disabled PDF op must resolve to empty, not a colliding AI capability of the same id.
|
||||
assertTrue(
|
||||
catalog.findByOperationId("collide").isEmpty(),
|
||||
"disabled PDF op must not resolve to a colliding AI capability");
|
||||
|
||||
// A genuine AI-only id still resolves.
|
||||
seed(
|
||||
catalog,
|
||||
"aiOps",
|
||||
"ai-only",
|
||||
new OperationMeta(
|
||||
"ai-only",
|
||||
OperationCategory.AI,
|
||||
"ai op",
|
||||
schema,
|
||||
"mcp.tools.write",
|
||||
OperationMeta.Target.ENGINE_CAPABILITY,
|
||||
"ai-only",
|
||||
null));
|
||||
assertEquals("ai-only", catalog.findByOperationId("ai-only").orElseThrow().id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void blockedOperations_hidesOp() throws Exception {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getMcp().setBlockedOperations(List.of("compress-pdf"));
|
||||
McpToolCatalog catalog = catalogWithEndpointsEnabled(props);
|
||||
seed(catalog, "pdfOps", "compress-pdf", miscOp("compress-pdf"));
|
||||
seed(catalog, "pdfOps", "ocr-pdf", miscOp("ocr-pdf"));
|
||||
|
||||
assertTrue(
|
||||
catalog.findByOperationId("compress-pdf").isEmpty(), "blocked op must be hidden");
|
||||
assertEquals("ocr-pdf", catalog.findByOperationId("ocr-pdf").orElseThrow().id());
|
||||
assertFalse(idsOf(catalog).contains("compress-pdf"));
|
||||
assertTrue(idsOf(catalog).contains("ocr-pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowedOperations_isWhitelist() throws Exception {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getMcp().setAllowedOperations(List.of("compress-pdf"));
|
||||
McpToolCatalog catalog = catalogWithEndpointsEnabled(props);
|
||||
seed(catalog, "pdfOps", "compress-pdf", miscOp("compress-pdf"));
|
||||
seed(catalog, "pdfOps", "ocr-pdf", miscOp("ocr-pdf"));
|
||||
|
||||
assertEquals("compress-pdf", catalog.findByOperationId("compress-pdf").orElseThrow().id());
|
||||
assertTrue(
|
||||
catalog.findByOperationId("ocr-pdf").isEmpty(),
|
||||
"op not on the allow-list must be hidden");
|
||||
assertEquals(List.of("compress-pdf"), idsOf(catalog));
|
||||
}
|
||||
|
||||
@Test
|
||||
void blockedOperations_takePrecedenceOverAllowed() throws Exception {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getMcp().setAllowedOperations(List.of("compress-pdf"));
|
||||
props.getMcp().setBlockedOperations(List.of("compress-pdf"));
|
||||
McpToolCatalog catalog = catalogWithEndpointsEnabled(props);
|
||||
seed(catalog, "pdfOps", "compress-pdf", miscOp("compress-pdf"));
|
||||
|
||||
assertTrue(
|
||||
catalog.findByOperationId("compress-pdf").isEmpty(),
|
||||
"block-list must win over allow-list");
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyAllowAndBlockLists_exposeAllEnabledOps() throws Exception {
|
||||
McpToolCatalog catalog = catalogWithEndpointsEnabled(new ApplicationProperties());
|
||||
seed(catalog, "pdfOps", "compress-pdf", miscOp("compress-pdf"));
|
||||
|
||||
assertEquals("compress-pdf", catalog.findByOperationId("compress-pdf").orElseThrow().id());
|
||||
assertTrue(idsOf(catalog).contains("compress-pdf"));
|
||||
}
|
||||
|
||||
private McpToolCatalog catalogWithEndpointsEnabled(ApplicationProperties props) {
|
||||
ApplicationContext ctx = mock(ApplicationContext.class);
|
||||
when(ctx.getBeansOfType(RequestMappingHandlerMapping.class)).thenReturn(Map.of());
|
||||
EndpointConfiguration endpoints = mock(EndpointConfiguration.class);
|
||||
when(endpoints.isEndpointEnabledForUri(anyString())).thenReturn(true);
|
||||
return new McpToolCatalog(ctx, endpoints, props, mapper);
|
||||
}
|
||||
|
||||
private OperationMeta miscOp(String id) {
|
||||
return new OperationMeta(
|
||||
id,
|
||||
OperationCategory.MISC,
|
||||
id,
|
||||
mapper.createObjectNode(),
|
||||
"mcp.tools.write",
|
||||
OperationMeta.Target.JAVA_ENDPOINT,
|
||||
"/api/v1/misc/" + id,
|
||||
null);
|
||||
}
|
||||
|
||||
private static List<String> idsOf(McpToolCatalog catalog) {
|
||||
return catalog.enabledOps(OperationCategory.MISC).stream().map(OperationMeta::id).toList();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void seed(McpToolCatalog catalog, String field, String id, OperationMeta meta)
|
||||
throws Exception {
|
||||
Field f = McpToolCatalog.class.getDeclaredField(field);
|
||||
f.setAccessible(true);
|
||||
((Map<String, OperationMeta>) f.get(catalog)).put(id, meta);
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package stirling.software.proprietary.mcp.catalog;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/** Schema must describe the JSON wire contract, not raw Java field names. */
|
||||
class SimpleSchemaGeneratorTest {
|
||||
|
||||
private final SimpleSchemaGenerator gen = new SimpleSchemaGenerator(new ObjectMapper());
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
static class SampleRequest {
|
||||
@JsonProperty("file_name")
|
||||
String fileName;
|
||||
|
||||
@JsonProperty(required = true)
|
||||
String mode;
|
||||
|
||||
@JsonIgnore String internalSecret;
|
||||
|
||||
boolean flag;
|
||||
|
||||
@jakarta.validation.constraints.NotBlank String title;
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesJsonPropertyNames_skipsJsonIgnore_marksRequired() {
|
||||
ObjectNode schema = gen.toSchema(SampleRequest.class);
|
||||
ObjectNode props = (ObjectNode) schema.get("properties");
|
||||
|
||||
assertTrue(props.has("file_name"), "must use the @JsonProperty name");
|
||||
assertFalse(props.has("fileName"), "must not emit the raw field name");
|
||||
|
||||
assertFalse(props.has("internalSecret"), "@JsonIgnore field must be skipped");
|
||||
|
||||
assertTrue(props.has("flag"));
|
||||
assertEquals("boolean", props.get("flag").get("type").asText());
|
||||
|
||||
assertNotNull(schema.get("required"), "required array expected");
|
||||
Set<String> required = new HashSet<>();
|
||||
schema.get("required").forEach(n -> required.add(n.asText()));
|
||||
assertTrue(required.contains("mode"), "@JsonProperty(required=true) -> required");
|
||||
assertTrue(required.contains("title"), "@NotBlank -> required");
|
||||
assertFalse(required.contains("file_name"), "optional field not required");
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
package stirling.software.proprietary.mcp.engine;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationCategory;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationMeta;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Verifies {@link EngineCapabilityClient#parseManifest} maps a manifest to {@link OperationMeta}.
|
||||
*/
|
||||
class EngineCapabilityParseTest {
|
||||
|
||||
@Test
|
||||
void manifest_maps_to_operation_meta() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
// parseManifest doesn't touch the catalog, so a null catalog is fine.
|
||||
EngineCapabilityClient client =
|
||||
new EngineCapabilityClient(props, (McpToolCatalog) null, mapper);
|
||||
|
||||
String body =
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"capabilities": [
|
||||
{
|
||||
"id": "pdf-question-answer",
|
||||
"description": "Answer a question about a PDF.",
|
||||
"input_schema": {"type": "object", "properties": {"question": {"type": "string"}}},
|
||||
"mode": "sync",
|
||||
"required_scope": "mcp.tools.read",
|
||||
"route": "/api/v1/pdf-question"
|
||||
},
|
||||
{
|
||||
"id": "pdf-edit-plan",
|
||||
"description": "Produce an edit plan from natural language.",
|
||||
"input_schema": {"type": "object"},
|
||||
"mode": "async",
|
||||
"required_scope": "mcp.tools.write",
|
||||
"route": "/api/v1/pdf-edit"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
Method parse =
|
||||
EngineCapabilityClient.class.getDeclaredMethod("parseManifest", String.class);
|
||||
parse.setAccessible(true);
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, OperationMeta> parsed = (Map<String, OperationMeta>) parse.invoke(client, body);
|
||||
|
||||
assertThat(parsed).hasSize(2);
|
||||
|
||||
OperationMeta qa = parsed.get("pdf-question-answer");
|
||||
assertThat(qa).isNotNull();
|
||||
assertThat(qa.category()).isEqualTo(OperationCategory.AI);
|
||||
assertThat(qa.requiredScope()).isEqualTo("mcp.tools.read");
|
||||
assertThat(qa.endpointPath()).isEqualTo("/api/v1/pdf-question");
|
||||
assertThat(qa.target()).isEqualTo(OperationMeta.Target.ENGINE_CAPABILITY);
|
||||
assertThat(qa.paramSchema().get("type").asText()).isEqualTo("object");
|
||||
|
||||
OperationMeta edit = parsed.get("pdf-edit-plan");
|
||||
assertThat(edit).isNotNull();
|
||||
assertThat(edit.requiredScope()).isEqualTo("mcp.tools.write");
|
||||
assertThat(edit.endpointPath()).isEqualTo("/api/v1/pdf-edit");
|
||||
}
|
||||
|
||||
@Test
|
||||
void missing_capabilities_array_throws() {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
EngineCapabilityClient client =
|
||||
new EngineCapabilityClient(
|
||||
new ApplicationProperties(), (McpToolCatalog) null, mapper);
|
||||
|
||||
try {
|
||||
Method parse =
|
||||
EngineCapabilityClient.class.getDeclaredMethod("parseManifest", String.class);
|
||||
parse.setAccessible(true);
|
||||
parse.invoke(client, "{\"version\":1}");
|
||||
org.junit.jupiter.api.Assertions.fail("Expected IOException");
|
||||
} catch (java.lang.reflect.InvocationTargetException e) {
|
||||
assertThat(e.getCause()).isInstanceOf(java.io.IOException.class);
|
||||
} catch (Exception e) {
|
||||
org.junit.jupiter.api.Assertions.fail("Unexpected exception: " + e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void unsafe_routes_are_skipped_and_blank_scope_fails_safe() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
EngineCapabilityClient client =
|
||||
new EngineCapabilityClient(
|
||||
new ApplicationProperties(), (McpToolCatalog) null, mapper);
|
||||
|
||||
String body =
|
||||
"""
|
||||
{"version":1,"capabilities":[
|
||||
{"id":"good","description":"ok","input_schema":{"type":"object"},"required_scope":"","route":"/api/v1/pdf-question"},
|
||||
{"id":"ssrf-at","description":"x","input_schema":{"type":"object"},"route":"@evil.com/steal"},
|
||||
{"id":"ssrf-proto","description":"x","input_schema":{"type":"object"},"route":"//evil.com/x"},
|
||||
{"id":"escape","description":"x","input_schema":{"type":"object"},"route":"/api/../../internal/secret"},
|
||||
{"id":"scheme","description":"x","input_schema":{"type":"object"},"route":"http://evil.com/x"},
|
||||
{"id":"non-api","description":"x","input_schema":{"type":"object"},"route":"/admin/settings"}
|
||||
]}
|
||||
""";
|
||||
|
||||
Method parse =
|
||||
EngineCapabilityClient.class.getDeclaredMethod("parseManifest", String.class);
|
||||
parse.setAccessible(true);
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, OperationMeta> parsed = (Map<String, OperationMeta>) parse.invoke(client, body);
|
||||
|
||||
// Only the safe, server-relative /api route survives.
|
||||
assertThat(parsed.keySet()).containsExactly("good");
|
||||
// Blank required_scope fails safe to the stricter write scope.
|
||||
assertThat(parsed.get("good").requiredScope()).isEqualTo("mcp.tools.write");
|
||||
}
|
||||
|
||||
@Test
|
||||
void isSafeRelativeRoute_acceptsOnlyServerRelativeApiPaths() {
|
||||
assertThat(EngineCapabilityClient.isSafeRelativeRoute("/api/v1/pdf-question")).isTrue();
|
||||
assertThat(EngineCapabilityClient.isSafeRelativeRoute("@evil.com/x")).isFalse();
|
||||
assertThat(EngineCapabilityClient.isSafeRelativeRoute("//evil.com/x")).isFalse();
|
||||
assertThat(EngineCapabilityClient.isSafeRelativeRoute("/api/../secret")).isFalse();
|
||||
assertThat(EngineCapabilityClient.isSafeRelativeRoute("http://evil/x")).isFalse();
|
||||
assertThat(EngineCapabilityClient.isSafeRelativeRoute("/admin/x")).isFalse();
|
||||
assertThat(EngineCapabilityClient.isSafeRelativeRoute("/api/v1/x y")).isFalse();
|
||||
}
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
package stirling.software.proprietary.mcp.security;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.mcp.McpServerController;
|
||||
import stirling.software.proprietary.mcp.tools.DescribeOperationTool;
|
||||
import stirling.software.proprietary.mcp.tools.StirlingAiTool;
|
||||
import stirling.software.proprietary.mcp.tools.StirlingConvertTool;
|
||||
import stirling.software.proprietary.mcp.tools.StirlingMiscTool;
|
||||
import stirling.software.proprietary.mcp.tools.StirlingPagesTool;
|
||||
import stirling.software.proprietary.mcp.tools.StirlingSecurityTool;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/**
|
||||
* End-to-end test of {@code mcp.auth.mode=apikey} against the real security chain on live Jetty.
|
||||
*/
|
||||
@SpringBootTest(
|
||||
classes = McpApiKeyIntegrationTest.TestApp.class,
|
||||
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
class McpApiKeyIntegrationTest {
|
||||
|
||||
private static final String VALID_KEY = "stirling-test-key-abc123";
|
||||
|
||||
@LocalServerPort private int port;
|
||||
private final HttpClient http = HttpClient.newHttpClient();
|
||||
|
||||
@DynamicPropertySource
|
||||
static void mcpProperties(DynamicPropertyRegistry registry) {
|
||||
registry.add("mcp.enabled", () -> "true");
|
||||
registry.add("mcp.auth.mode", () -> "apikey");
|
||||
}
|
||||
|
||||
@Test
|
||||
void validApiKeyViaHeader_callsToolsList() throws Exception {
|
||||
HttpResponse<String> response =
|
||||
postMcp(
|
||||
b -> b.header("X-API-KEY", VALID_KEY),
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}");
|
||||
assertThat(response.statusCode()).isEqualTo(200);
|
||||
assertThat(response.body()).contains("stirling_describe_operation");
|
||||
}
|
||||
|
||||
@Test
|
||||
void validApiKeyViaBearer_callsToolsList() throws Exception {
|
||||
HttpResponse<String> response =
|
||||
postMcp(
|
||||
b -> b.header("Authorization", "Bearer " + VALID_KEY),
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}");
|
||||
assertThat(response.statusCode()).isEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void noKey_isRejectedWith401() throws Exception {
|
||||
HttpResponse<String> response =
|
||||
postMcp(b -> b, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}");
|
||||
assertThat(response.statusCode()).isEqualTo(401);
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrongKey_isRejectedWith401() throws Exception {
|
||||
HttpResponse<String> response =
|
||||
postMcp(
|
||||
b -> b.header("X-API-KEY", "not-a-real-key"),
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}");
|
||||
assertThat(response.statusCode()).isEqualTo(401);
|
||||
}
|
||||
|
||||
@Test
|
||||
void noOAuthMetadataInApiKeyMode() throws Exception {
|
||||
HttpRequest req =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(URI.create(base() + "/.well-known/oauth-protected-resource"))
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> response = http.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(response.statusCode()).isNotEqualTo(200);
|
||||
}
|
||||
|
||||
private HttpResponse<String> postMcp(
|
||||
java.util.function.UnaryOperator<HttpRequest.Builder> headers, String body)
|
||||
throws Exception {
|
||||
HttpRequest.Builder builder =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(URI.create(base() + "/mcp"))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body));
|
||||
return http.send(headers.apply(builder).build(), HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
private String base() {
|
||||
return "http://localhost:" + port;
|
||||
}
|
||||
|
||||
@SpringBootConfiguration
|
||||
@EnableAutoConfiguration
|
||||
@Import({
|
||||
McpSecurityConfig.class,
|
||||
McpServerController.class,
|
||||
DescribeOperationTool.class,
|
||||
StirlingConvertTool.class,
|
||||
StirlingPagesTool.class,
|
||||
StirlingMiscTool.class,
|
||||
StirlingSecurityTool.class,
|
||||
StirlingAiTool.class
|
||||
})
|
||||
static class TestApp {
|
||||
|
||||
@Bean
|
||||
ApplicationProperties applicationProperties() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getMcp().setEnabled(true);
|
||||
props.getMcp().getAuth().setMode("apikey");
|
||||
props.getAutomaticallyGenerated().setAppVersion("test");
|
||||
return props;
|
||||
}
|
||||
|
||||
@Bean
|
||||
UserService userService() {
|
||||
UserService mock = org.mockito.Mockito.mock(UserService.class);
|
||||
User account = org.mockito.Mockito.mock(User.class);
|
||||
org.mockito.Mockito.when(account.isEnabled()).thenReturn(true);
|
||||
org.mockito.Mockito.when(account.getUsername()).thenReturn("alice");
|
||||
org.mockito.Mockito.when(mock.getUserByApiKey(org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(Optional.empty());
|
||||
org.mockito.Mockito.when(mock.getUserByApiKey(VALID_KEY))
|
||||
.thenReturn(Optional.of(account));
|
||||
return mock;
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package stirling.software.proprietary.mcp.security;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
/** RFC 8707 audience validator: token {@code aud} must list the resource id; blank fails closed. */
|
||||
class McpAudienceValidatorTest {
|
||||
|
||||
private static final String RESOURCE = "http://localhost:8080/mcp";
|
||||
|
||||
private final McpAudienceValidator validator = new McpAudienceValidator(RESOURCE);
|
||||
|
||||
@Test
|
||||
void matchingAudience_isAccepted() {
|
||||
Jwt token = tokenWithAudience(List.of(RESOURCE));
|
||||
OAuth2TokenValidatorResult result = validator.validate(token);
|
||||
assertThat(result.hasErrors()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void multiAudienceIncludingResource_isAccepted() {
|
||||
Jwt token = tokenWithAudience(List.of("https://other.example.com", RESOURCE));
|
||||
OAuth2TokenValidatorResult result = validator.validate(token);
|
||||
assertThat(result.hasErrors()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrongAudience_isRejected() {
|
||||
Jwt token = tokenWithAudience(List.of("https://other.example.com"));
|
||||
OAuth2TokenValidatorResult result = validator.validate(token);
|
||||
assertThat(result.hasErrors()).isTrue();
|
||||
assertThat(result.getErrors()).anyMatch(e -> e.getErrorCode().equals("invalid_token"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingAudienceClaim_isRejected() {
|
||||
Jwt token = tokenWithAudience(null);
|
||||
OAuth2TokenValidatorResult result = validator.validate(token);
|
||||
assertThat(result.hasErrors()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankResourceId_failsClosed_rejectingEvenMatchingTokens() {
|
||||
McpAudienceValidator blank = new McpAudienceValidator("");
|
||||
OAuth2TokenValidatorResult result = blank.validate(tokenWithAudience(List.of(RESOURCE)));
|
||||
assertThat(result.hasErrors()).isTrue();
|
||||
}
|
||||
|
||||
private static Jwt tokenWithAudience(List<String> audience) {
|
||||
return new Jwt(
|
||||
"header.payload.signature",
|
||||
Instant.now(),
|
||||
Instant.now().plusSeconds(60),
|
||||
Map.of("alg", "RS256"),
|
||||
audience == null ? Map.of("sub", "u1") : Map.of("sub", "u1", "aud", audience));
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package stirling.software.proprietary.mcp.security;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
/** The resource_metadata URL must reflect the public host behind a reverse proxy. */
|
||||
class McpAuthenticationEntryPointTest {
|
||||
|
||||
private static final String META = "/.well-known/oauth-protected-resource";
|
||||
private final McpAuthenticationEntryPoint entryPoint = new McpAuthenticationEntryPoint(META);
|
||||
|
||||
@Test
|
||||
void usesForwardedHeadersForMetadataUrl() throws Exception {
|
||||
HttpServletRequest req = mock(HttpServletRequest.class);
|
||||
when(req.getScheme()).thenReturn("http");
|
||||
when(req.getServerName()).thenReturn("internal-host");
|
||||
when(req.getServerPort()).thenReturn(8080);
|
||||
when(req.getHeader("X-Forwarded-Proto")).thenReturn("https");
|
||||
when(req.getHeader("X-Forwarded-Host")).thenReturn("mcp.example.com");
|
||||
when(req.getHeader("X-Forwarded-Port")).thenReturn("443");
|
||||
HttpServletResponse resp = mock(HttpServletResponse.class);
|
||||
|
||||
entryPoint.commence(req, resp, null);
|
||||
|
||||
ArgumentCaptor<String> header = ArgumentCaptor.forClass(String.class);
|
||||
verify(resp).setHeader(eq("WWW-Authenticate"), header.capture());
|
||||
String www = header.getValue();
|
||||
assertTrue(
|
||||
www.contains("resource_metadata=\"https://mcp.example.com" + META + "\""),
|
||||
"must use forwarded host/proto, got: " + www);
|
||||
assertFalse(www.contains("internal-host"), "internal host must not leak");
|
||||
verify(resp).sendError(anyInt(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToServletHostWithoutForwardedHeaders() throws Exception {
|
||||
HttpServletRequest req = mock(HttpServletRequest.class);
|
||||
when(req.getScheme()).thenReturn("http");
|
||||
when(req.getServerName()).thenReturn("localhost");
|
||||
when(req.getServerPort()).thenReturn(8080);
|
||||
HttpServletResponse resp = mock(HttpServletResponse.class);
|
||||
|
||||
entryPoint.commence(req, resp, null);
|
||||
|
||||
ArgumentCaptor<String> header = ArgumentCaptor.forClass(String.class);
|
||||
verify(resp).setHeader(eq("WWW-Authenticate"), header.capture());
|
||||
assertTrue(header.getValue().contains("http://localhost:8080" + META), header.getValue());
|
||||
}
|
||||
}
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
package stirling.software.proprietary.mcp.security;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
|
||||
import com.nimbusds.jose.JWSAlgorithm;
|
||||
import com.nimbusds.jose.JWSHeader;
|
||||
import com.nimbusds.jose.crypto.RSASSASigner;
|
||||
import com.nimbusds.jose.jwk.JWKSet;
|
||||
import com.nimbusds.jose.jwk.RSAKey;
|
||||
import com.nimbusds.jwt.JWTClaimsSet;
|
||||
import com.nimbusds.jwt.SignedJWT;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.mcp.McpServerController;
|
||||
import stirling.software.proprietary.mcp.tools.DescribeOperationTool;
|
||||
import stirling.software.proprietary.mcp.tools.StirlingAiTool;
|
||||
import stirling.software.proprietary.mcp.tools.StirlingConvertTool;
|
||||
import stirling.software.proprietary.mcp.tools.StirlingMiscTool;
|
||||
import stirling.software.proprietary.mcp.tools.StirlingPagesTool;
|
||||
import stirling.software.proprietary.mcp.tools.StirlingSecurityTool;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
import okhttp3.mockwebserver.Dispatcher;
|
||||
import okhttp3.mockwebserver.MockResponse;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
import okhttp3.mockwebserver.RecordedRequest;
|
||||
|
||||
/**
|
||||
* End-to-end OAuth test against the real {@link McpSecurityConfig} chain. A real RSA keypair signs
|
||||
* JWTs; the public key is served as JWKS over HTTP (mockwebserver) and the resource server fetches
|
||||
* and validates against it. The JDK HttpClient drives a live Jetty instance on a random port.
|
||||
*/
|
||||
@SpringBootTest(
|
||||
classes = McpOAuthIntegrationTest.TestApp.class,
|
||||
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
class McpOAuthIntegrationTest {
|
||||
|
||||
private static final String ISSUER = "https://test-issuer.example.com";
|
||||
private static final String RESOURCE_ID = "http://localhost/mcp";
|
||||
|
||||
private static MockWebServer jwksServer;
|
||||
private static RSAPrivateKey privateKey;
|
||||
private static final String KEY_ID = "mcp-test-key";
|
||||
|
||||
@LocalServerPort private int port;
|
||||
|
||||
private final HttpClient http = HttpClient.newHttpClient();
|
||||
|
||||
@BeforeAll
|
||||
static void startJwks() throws Exception {
|
||||
KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
|
||||
gen.initialize(2048);
|
||||
KeyPair kp = gen.generateKeyPair();
|
||||
privateKey = (RSAPrivateKey) kp.getPrivate();
|
||||
RSAPublicKey publicKey = (RSAPublicKey) kp.getPublic();
|
||||
|
||||
RSAKey jwk = new RSAKey.Builder(publicKey).keyID(KEY_ID).build();
|
||||
String jwksJson = new JWKSet(jwk).toString();
|
||||
|
||||
jwksServer = new MockWebServer();
|
||||
jwksServer.setDispatcher(
|
||||
new Dispatcher() {
|
||||
@Override
|
||||
public MockResponse dispatch(RecordedRequest request) {
|
||||
return new MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(jwksJson);
|
||||
}
|
||||
});
|
||||
jwksServer.start();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void stopJwks() throws Exception {
|
||||
if (jwksServer != null) {
|
||||
jwksServer.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@DynamicPropertySource
|
||||
static void mcpProperties(DynamicPropertyRegistry registry) {
|
||||
registry.add("mcp.enabled", () -> "true");
|
||||
registry.add("mcp.auth.issuer-uri", () -> ISSUER);
|
||||
registry.add("mcp.auth.jwks-uri", () -> jwksServer.url("/jwks").toString());
|
||||
registry.add("mcp.auth.resource-id", () -> RESOURCE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void noToken_returns401WithResourceMetadataHeader() throws Exception {
|
||||
HttpResponse<String> response =
|
||||
postMcp(null, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}");
|
||||
assertThat(response.statusCode()).isEqualTo(401);
|
||||
String wwwAuth = response.headers().firstValue("WWW-Authenticate").orElse("");
|
||||
assertThat(wwwAuth).contains("resource_metadata=");
|
||||
assertThat(wwwAuth).contains("/.well-known/oauth-protected-resource");
|
||||
}
|
||||
|
||||
@Test
|
||||
void validToken_callsToolsListSuccessfully() throws Exception {
|
||||
String token =
|
||||
mintToken(
|
||||
ISSUER,
|
||||
List.of(RESOURCE_ID),
|
||||
"mcp.tools.read mcp.tools.write",
|
||||
Instant.now().plusSeconds(300));
|
||||
HttpResponse<String> response =
|
||||
postMcp(token, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}");
|
||||
assertThat(response.statusCode()).isEqualTo(200);
|
||||
assertThat(response.body()).contains("stirling_describe_operation");
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrongAudience_isRejected() throws Exception {
|
||||
String token =
|
||||
mintToken(
|
||||
ISSUER,
|
||||
List.of("https://some-other-resource.example.com"),
|
||||
"mcp.tools.read",
|
||||
Instant.now().plusSeconds(300));
|
||||
HttpResponse<String> response =
|
||||
postMcp(token, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}");
|
||||
assertThat(response.statusCode()).isEqualTo(401);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredToken_isRejected() throws Exception {
|
||||
String token =
|
||||
mintToken(
|
||||
ISSUER,
|
||||
List.of(RESOURCE_ID),
|
||||
"mcp.tools.read",
|
||||
Instant.now().minusSeconds(60));
|
||||
HttpResponse<String> response =
|
||||
postMcp(token, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}");
|
||||
assertThat(response.statusCode()).isEqualTo(401);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validTokenButNoStirlingAccount_isRejectedWith403() throws Exception {
|
||||
// 'ghost-user' is not provisioned, so account-binding rejects an otherwise-valid token.
|
||||
String token =
|
||||
mintToken(
|
||||
"ghost-user",
|
||||
ISSUER,
|
||||
List.of(RESOURCE_ID),
|
||||
"mcp.tools.read mcp.tools.write",
|
||||
Instant.now().plusSeconds(300));
|
||||
HttpResponse<String> response =
|
||||
postMcp(token, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}");
|
||||
assertThat(response.statusCode()).isEqualTo(403);
|
||||
}
|
||||
|
||||
@Test
|
||||
void oversizedBody_isRejectedWith413() throws Exception {
|
||||
String token =
|
||||
mintToken(
|
||||
ISSUER,
|
||||
List.of(RESOURCE_ID),
|
||||
"mcp.tools.read mcp.tools.write",
|
||||
Instant.now().plusSeconds(300));
|
||||
StringBuilder big =
|
||||
new StringBuilder("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"x\",\"params\":\"");
|
||||
big.append("A".repeat(300 * 1024));
|
||||
big.append("\"}");
|
||||
HttpResponse<String> response = postMcp(token, big.toString());
|
||||
assertThat(response.statusCode()).isEqualTo(413);
|
||||
}
|
||||
|
||||
@Test
|
||||
void oversizedChunkedBody_isRejectedWith413() throws Exception {
|
||||
// No Content-Length (chunked transfer) exercises the streaming cap rather than the fast
|
||||
// check.
|
||||
String token =
|
||||
mintToken(
|
||||
ISSUER,
|
||||
List.of(RESOURCE_ID),
|
||||
"mcp.tools.read mcp.tools.write",
|
||||
Instant.now().plusSeconds(300));
|
||||
byte[] big =
|
||||
("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"x\",\"params\":\""
|
||||
+ "A".repeat(300 * 1024)
|
||||
+ "\"}")
|
||||
.getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
HttpRequest request =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(URI.create(base() + "/mcp"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.POST(
|
||||
HttpRequest.BodyPublishers.ofInputStream(
|
||||
() -> new java.io.ByteArrayInputStream(big)))
|
||||
.build();
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(response.statusCode()).isEqualTo(413);
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedJson_returnsJsonRpcParseErrorEnvelope() throws Exception {
|
||||
// Unparseable JSON must be wrapped as a JSON-RPC Parse error (-32700), not Spring's HTML
|
||||
// 400.
|
||||
String token =
|
||||
mintToken(
|
||||
ISSUER,
|
||||
List.of(RESOURCE_ID),
|
||||
"mcp.tools.read mcp.tools.write",
|
||||
Instant.now().plusSeconds(300));
|
||||
HttpResponse<String> response = postMcp(token, "{ this is not valid json ");
|
||||
assertThat(response.statusCode()).isEqualTo(400);
|
||||
assertThat(response.body()).contains("-32700");
|
||||
}
|
||||
|
||||
@Test
|
||||
void metadataEndpoint_isReachableWithoutToken() throws Exception {
|
||||
HttpRequest request =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(URI.create(base() + "/.well-known/oauth-protected-resource"))
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(response.statusCode()).isEqualTo(200);
|
||||
assertThat(response.body()).contains(RESOURCE_ID);
|
||||
assertThat(response.body()).contains(ISSUER);
|
||||
assertThat(response.body()).contains("mcp.tools.read");
|
||||
}
|
||||
|
||||
private HttpResponse<String> postMcp(String token, String body) throws Exception {
|
||||
HttpRequest.Builder builder =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(URI.create(base() + "/mcp"))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body));
|
||||
if (token != null) {
|
||||
builder.header("Authorization", "Bearer " + token);
|
||||
}
|
||||
return http.send(builder.build(), HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
private String base() {
|
||||
return "http://localhost:" + port;
|
||||
}
|
||||
|
||||
private static String mintToken(
|
||||
String issuer, List<String> audience, String scope, Instant expiry) {
|
||||
return mintToken("test-user", issuer, audience, scope, expiry);
|
||||
}
|
||||
|
||||
private static String mintToken(
|
||||
String subject, String issuer, List<String> audience, String scope, Instant expiry) {
|
||||
try {
|
||||
JWTClaimsSet claims =
|
||||
new JWTClaimsSet.Builder()
|
||||
.issuer(issuer)
|
||||
.subject(subject)
|
||||
.audience(audience)
|
||||
.claim("scope", scope)
|
||||
.issueTime(Date.from(Instant.now().minusSeconds(5)))
|
||||
.expirationTime(Date.from(expiry))
|
||||
.build();
|
||||
SignedJWT jwt =
|
||||
new SignedJWT(
|
||||
new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(KEY_ID).build(),
|
||||
claims);
|
||||
jwt.sign(new RSASSASigner(privateKey));
|
||||
return jwt.serialize();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@SpringBootConfiguration
|
||||
@EnableAutoConfiguration
|
||||
@Import({
|
||||
McpSecurityConfig.class,
|
||||
McpServerController.class,
|
||||
DescribeOperationTool.class,
|
||||
StirlingConvertTool.class,
|
||||
StirlingPagesTool.class,
|
||||
StirlingMiscTool.class,
|
||||
StirlingSecurityTool.class,
|
||||
StirlingAiTool.class
|
||||
})
|
||||
static class TestApp {
|
||||
|
||||
@Bean
|
||||
ApplicationProperties applicationProperties() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getMcp().setEnabled(true);
|
||||
props.getMcp().setMaxRequestBytes(256L * 1024);
|
||||
props.getMcp().getAuth().setIssuerUri(ISSUER);
|
||||
props.getMcp().getAuth().setJwksUri(jwksServer.url("/jwks").toString());
|
||||
props.getMcp().getAuth().setResourceId(RESOURCE_ID);
|
||||
props.getAutomaticallyGenerated().setAppVersion("test");
|
||||
return props;
|
||||
}
|
||||
|
||||
/** Stub UserService: only 'test-user' is a provisioned, enabled account. */
|
||||
@Bean
|
||||
UserService userService() {
|
||||
UserService mock = org.mockito.Mockito.mock(UserService.class);
|
||||
stirling.software.proprietary.security.model.User account =
|
||||
org.mockito.Mockito.mock(
|
||||
stirling.software.proprietary.security.model.User.class);
|
||||
org.mockito.Mockito.when(account.isEnabled()).thenReturn(true);
|
||||
org.mockito.Mockito.when(account.getUsername()).thenReturn("test-user");
|
||||
org.mockito.Mockito.when(
|
||||
mock.findByUsernameIgnoreCase(org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(java.util.Optional.empty());
|
||||
org.mockito.Mockito.when(mock.findByUsernameIgnoreCase("test-user"))
|
||||
.thenReturn(java.util.Optional.of(account));
|
||||
return mock;
|
||||
}
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package stirling.software.proprietary.mcp.tools;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
|
||||
import stirling.software.proprietary.mcp.McpCallContext;
|
||||
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationCategory;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationMeta;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* PDF category tools must not fake success: a bad/missing op returns the operation list, a valid
|
||||
* scoped op delegates to the executor, and a missing scope is refused.
|
||||
*/
|
||||
class CategoryToolDispatchTest {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
private OperationMeta miscOp() {
|
||||
return new OperationMeta(
|
||||
"compress-pdf",
|
||||
OperationCategory.MISC,
|
||||
"Compress a PDF",
|
||||
mapper.createObjectNode(),
|
||||
"mcp.tools.write",
|
||||
OperationMeta.Target.JAVA_ENDPOINT,
|
||||
"/api/v1/misc/compress-pdf",
|
||||
null);
|
||||
}
|
||||
|
||||
private McpOperationExecutor executorReturning(ObjectNode sentinel) {
|
||||
McpOperationExecutor executor = mock(McpOperationExecutor.class);
|
||||
when(executor.execute(any(), any())).thenReturn(sentinel);
|
||||
return executor;
|
||||
}
|
||||
|
||||
private StirlingMiscTool toolWith(McpOperationExecutor executor) {
|
||||
OperationMeta meta = miscOp();
|
||||
McpToolCatalog catalog = mock(McpToolCatalog.class);
|
||||
when(catalog.findByOperationId("compress-pdf")).thenReturn(Optional.of(meta));
|
||||
when(catalog.enabledOps(OperationCategory.MISC)).thenReturn(List.of(meta));
|
||||
@SuppressWarnings("unchecked")
|
||||
ObjectProvider<McpToolCatalog> catalogProvider = mock(ObjectProvider.class);
|
||||
when(catalogProvider.getIfAvailable()).thenReturn(catalog);
|
||||
@SuppressWarnings("unchecked")
|
||||
ObjectProvider<McpOperationExecutor> executorProvider = mock(ObjectProvider.class);
|
||||
when(executorProvider.getIfAvailable()).thenReturn(executor);
|
||||
return new StirlingMiscTool(mapper, catalogProvider, executorProvider);
|
||||
}
|
||||
|
||||
private ObjectNode args(String op) {
|
||||
ObjectNode a = mapper.createObjectNode();
|
||||
if (op != null) {
|
||||
a.put("operation", op);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
private String textOf(ObjectNode result) {
|
||||
return result.get("content").get(0).get("text").asText();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validOpWithScope_delegatesToExecutor() {
|
||||
ObjectNode sentinel = McpResponses.text(mapper, "EXECUTED");
|
||||
StirlingMiscTool tool = toolWith(executorReturning(sentinel));
|
||||
McpCallContext ctx = new McpCallContext("user", Set.of("mcp.tools.write"), true);
|
||||
|
||||
ObjectNode result = tool.call(args("compress-pdf"), ctx);
|
||||
|
||||
assertEquals("EXECUTED", textOf(result), "valid scoped op must run via the executor");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownOperation_returnsAvailableOperationList() {
|
||||
StirlingMiscTool tool = toolWith(executorReturning(mapper.createObjectNode()));
|
||||
McpCallContext ctx = new McpCallContext("user", Set.of("mcp.tools.write"), true);
|
||||
|
||||
ObjectNode result = tool.call(args("does-not-exist"), ctx);
|
||||
|
||||
assertTrue(result.path("isError").asBoolean(false));
|
||||
String text = textOf(result);
|
||||
assertTrue(text.contains("Available operations"), "should list available ops: " + text);
|
||||
assertTrue(text.contains("compress-pdf"), "should include the valid op id: " + text);
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingOperation_returnsAvailableOperationList() {
|
||||
StirlingMiscTool tool = toolWith(executorReturning(mapper.createObjectNode()));
|
||||
McpCallContext ctx = new McpCallContext("user", Set.of("mcp.tools.write"), true);
|
||||
|
||||
ObjectNode result = tool.call(args(null), ctx);
|
||||
|
||||
assertTrue(result.path("isError").asBoolean(false));
|
||||
assertTrue(textOf(result).contains("Available operations"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingScope_returnsScopeError() {
|
||||
StirlingMiscTool tool = toolWith(executorReturning(mapper.createObjectNode()));
|
||||
McpCallContext ctx = new McpCallContext("user", Set.of("mcp.tools.read"), true);
|
||||
|
||||
ObjectNode result = tool.call(args("compress-pdf"), ctx);
|
||||
|
||||
assertTrue(result.path("isError").asBoolean(false));
|
||||
assertTrue(textOf(result).toLowerCase().contains("scope"));
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package stirling.software.proprietary.mcp.tools;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.proprietary.mcp.McpCallContext;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/** stirling_upload requires write scope; stirling_download requires read scope. */
|
||||
class FileToolScopeTest {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
private McpCallContext noScopes() {
|
||||
return new McpCallContext("user", Set.of(), true);
|
||||
}
|
||||
|
||||
private String text(ObjectNode result) {
|
||||
return result.get("content").get(0).get("text").asText();
|
||||
}
|
||||
|
||||
@Test
|
||||
void upload_withoutWriteScope_isRefused() {
|
||||
StirlingUploadTool tool = new StirlingUploadTool(mapper, mock(FileStorage.class));
|
||||
ObjectNode args = mapper.createObjectNode();
|
||||
args.put("file", "YWJj");
|
||||
|
||||
ObjectNode result = tool.call(args, noScopes());
|
||||
|
||||
assertTrue(result.path("isError").asBoolean(false));
|
||||
assertTrue(text(result).toLowerCase().contains("scope"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void download_withoutReadScope_isRefused() {
|
||||
StirlingDownloadTool tool =
|
||||
new StirlingDownloadTool(
|
||||
mapper, mock(FileStorage.class), new ApplicationProperties());
|
||||
ObjectNode args = mapper.createObjectNode();
|
||||
args.put("fileId", "abc");
|
||||
|
||||
ObjectNode result = tool.call(args, noScopes());
|
||||
|
||||
assertTrue(result.path("isError").asBoolean(false));
|
||||
assertTrue(text(result).toLowerCase().contains("scope"));
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package stirling.software.proprietary.mcp.tools;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationCategory;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationMeta;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
class McpOperationExecutorTest {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
private OperationMeta compressOp() {
|
||||
return new OperationMeta(
|
||||
"compress-pdf",
|
||||
OperationCategory.MISC,
|
||||
"Compress",
|
||||
mapper.createObjectNode(),
|
||||
"mcp.tools.write",
|
||||
OperationMeta.Target.JAVA_ENDPOINT,
|
||||
"/api/v1/misc/compress-pdf",
|
||||
null);
|
||||
}
|
||||
|
||||
private ResponseEntity<Resource> pdfResponse(byte[] bytes) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_PDF);
|
||||
Resource body =
|
||||
new ByteArrayResource(bytes) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return "out.pdf";
|
||||
}
|
||||
};
|
||||
return ResponseEntity.ok().headers(headers).body(body);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
void inlineBase64Input_runsAndReturnsResultInline() throws Exception {
|
||||
InternalApiClient api = mock(InternalApiClient.class);
|
||||
FileStorage storage = mock(FileStorage.class);
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
|
||||
when(api.post(eq("/api/v1/misc/compress-pdf"), any()))
|
||||
.thenReturn(pdfResponse("RESULT".getBytes(StandardCharsets.UTF_8)));
|
||||
when(storage.storeBytes(any(), anyString())).thenReturn("result-123");
|
||||
|
||||
McpOperationExecutor executor = new McpOperationExecutor(mapper, api, storage, props);
|
||||
|
||||
ObjectNode args = mapper.createObjectNode();
|
||||
args.put("operation", "compress-pdf");
|
||||
args.put(
|
||||
"file",
|
||||
Base64.getEncoder().encodeToString("INPUT".getBytes(StandardCharsets.UTF_8)));
|
||||
args.putObject("parameters").put("optimizeLevel", 2);
|
||||
|
||||
ObjectNode result = executor.execute(compressOp(), args);
|
||||
|
||||
assertFalse(result.path("isError").asBoolean(false));
|
||||
|
||||
ArgumentCaptor<MultiValueMap> bodyCap = ArgumentCaptor.forClass(MultiValueMap.class);
|
||||
verify(api).post(eq("/api/v1/misc/compress-pdf"), bodyCap.capture());
|
||||
MultiValueMap captured = bodyCap.getValue();
|
||||
assertTrue(captured.containsKey("fileInput"), "must send fileInput");
|
||||
assertEquals("2", String.valueOf(captured.getFirst("optimizeLevel")), "must pass params");
|
||||
|
||||
String text = result.get("content").get(0).get("text").asText();
|
||||
assertTrue(text.contains("result-123"), "must report the result fileId: " + text);
|
||||
ObjectNode resBlock = (ObjectNode) result.get("content").get(1);
|
||||
assertEquals("resource", resBlock.get("type").asText());
|
||||
String blob = resBlock.get("resource").get("blob").asText();
|
||||
assertEquals(
|
||||
"RESULT", new String(Base64.getDecoder().decode(blob), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingFile_returnsError() {
|
||||
McpOperationExecutor executor =
|
||||
new McpOperationExecutor(
|
||||
mapper,
|
||||
mock(InternalApiClient.class),
|
||||
mock(FileStorage.class),
|
||||
new ApplicationProperties());
|
||||
ObjectNode args = mapper.createObjectNode();
|
||||
args.put("operation", "compress-pdf");
|
||||
|
||||
ObjectNode result = executor.execute(compressOp(), args);
|
||||
|
||||
assertTrue(result.path("isError").asBoolean(false));
|
||||
assertTrue(
|
||||
result.get("content")
|
||||
.get(0)
|
||||
.get("text")
|
||||
.asText()
|
||||
.toLowerCase()
|
||||
.contains("input file"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fileIdInput_retrievesStoredBytes() throws Exception {
|
||||
InternalApiClient api = mock(InternalApiClient.class);
|
||||
FileStorage storage = mock(FileStorage.class);
|
||||
when(storage.fileExists("abc")).thenReturn(true);
|
||||
when(storage.retrieveBytes("abc")).thenReturn("INPUT".getBytes(StandardCharsets.UTF_8));
|
||||
when(api.post(anyString(), any()))
|
||||
.thenReturn(pdfResponse("OUT".getBytes(StandardCharsets.UTF_8)));
|
||||
when(storage.storeBytes(any(), anyString())).thenReturn("res");
|
||||
|
||||
McpOperationExecutor executor =
|
||||
new McpOperationExecutor(mapper, api, storage, new ApplicationProperties());
|
||||
ObjectNode args = mapper.createObjectNode();
|
||||
args.put("operation", "compress-pdf");
|
||||
args.put("fileId", "abc");
|
||||
|
||||
ObjectNode result = executor.execute(compressOp(), args);
|
||||
|
||||
assertFalse(result.path("isError").asBoolean(false));
|
||||
verify(storage).retrieveBytes("abc");
|
||||
}
|
||||
}
|
||||
+43
@@ -84,4 +84,47 @@ class AiEngineClientTest {
|
||||
|
||||
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void allVerbsSendEngineAuthHeaderWhenSecretConfigured() throws Exception {
|
||||
// Regression for the engine shared-secret hardening: every verb that hits a non-public
|
||||
// engine route (post/delete/get) must present X-Engine-Auth, or the route 401s once the
|
||||
// secret is set. delete() backs the logout-time RAG purge, so a miss silently leaks data.
|
||||
AiEngineClient secured =
|
||||
new AiEngineClient(applicationProperties, httpClient, "top-secret");
|
||||
HttpResponse<String> ok = mock(HttpResponse.class);
|
||||
when(ok.statusCode()).thenReturn(200);
|
||||
when(ok.body()).thenReturn("{}");
|
||||
org.mockito.ArgumentCaptor<java.net.http.HttpRequest> captor =
|
||||
org.mockito.ArgumentCaptor.forClass(java.net.http.HttpRequest.class);
|
||||
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class))).thenReturn(ok);
|
||||
|
||||
secured.post("/api/v1/pdf-question", "{}", "alice");
|
||||
secured.delete("/api/v1/documents/by-owner", "alice");
|
||||
secured.get("/api/v1/x", "alice");
|
||||
|
||||
for (java.net.http.HttpRequest req : captor.getAllValues()) {
|
||||
assertEquals(
|
||||
"top-secret",
|
||||
req.headers().firstValue("X-Engine-Auth").orElse(null),
|
||||
req.method() + " must carry the engine shared secret");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void noEngineAuthHeaderWhenSecretUnset() throws Exception {
|
||||
AiEngineClient noSecret = new AiEngineClient(applicationProperties, httpClient, null);
|
||||
HttpResponse<String> ok = mock(HttpResponse.class);
|
||||
when(ok.statusCode()).thenReturn(200);
|
||||
when(ok.body()).thenReturn("{}");
|
||||
org.mockito.ArgumentCaptor<java.net.http.HttpRequest> captor =
|
||||
org.mockito.ArgumentCaptor.forClass(java.net.http.HttpRequest.class);
|
||||
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class))).thenReturn(ok);
|
||||
|
||||
noSecret.delete("/api/v1/documents/by-owner", "alice");
|
||||
|
||||
assertEquals(null, captor.getValue().headers().firstValue("X-Engine-Auth").orElse(null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Curated registry of agent capabilities the MCP server (Java side) is allowed to publish.
|
||||
|
||||
Internal sub-agents (currently only ``ExecutionPlanningAgent`` - it lives behind the orchestrator
|
||||
and has no end-user-facing API surface) are intentionally absent. The handoff spec calls for
|
||||
"user-facing" capabilities only; revisit this list when adding a new agent and ask whether MCP
|
||||
clients should be able to invoke it directly.
|
||||
|
||||
The Java side pulls ``/api/v1/agents/capabilities`` once at boot and again every few minutes; the
|
||||
manifest is the authoritative source for the ``stirling_ai`` MCP tool's operation enum.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from stirling.contracts import (
|
||||
AgentDraftRequest,
|
||||
AgentExecutionRequest,
|
||||
AgentRevisionRequest,
|
||||
Evidence,
|
||||
FolioManifest,
|
||||
PdfCommentRequest,
|
||||
PdfEditRequest,
|
||||
PdfQuestionRequest,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentCapability:
|
||||
"""One row in the curated manifest.
|
||||
|
||||
Attributes:
|
||||
id: stable capability identifier (used as the operation enum value in
|
||||
``stirling_ai``). Avoid renaming - clients persist these.
|
||||
description: one-line human-friendly summary shown inside MCP tool descriptions.
|
||||
input_model: Pydantic class whose JSON Schema becomes the capability's
|
||||
``input_schema``. Auto-derived; do not hand-write schemas.
|
||||
mode: ``"sync"`` if the capability returns content inline, ``"async"`` if it returns a
|
||||
plan that Java executes via the job pipeline.
|
||||
required_scope: coarse OAuth scope. ``mcp.tools.read`` for pure-read capabilities
|
||||
(Q&A, audits) and ``mcp.tools.write`` for anything that yields a plan / file.
|
||||
route: HTTP path Java POSTs to when invoking this capability. When a capability does
|
||||
not have a stable per-agent route yet, use the generic invoke fallback at
|
||||
``/api/v1/agents/invoke/{id}``.
|
||||
"""
|
||||
|
||||
id: str
|
||||
description: str
|
||||
input_model: type[BaseModel]
|
||||
mode: str
|
||||
required_scope: str
|
||||
route: str
|
||||
|
||||
|
||||
EXPOSED_CAPABILITIES: list[AgentCapability] = [
|
||||
AgentCapability(
|
||||
id="pdf-question-answer",
|
||||
description="Answer a natural-language question about a PDF document.",
|
||||
input_model=PdfQuestionRequest,
|
||||
mode="sync",
|
||||
required_scope="mcp.tools.read",
|
||||
route="/api/v1/pdf-question",
|
||||
),
|
||||
AgentCapability(
|
||||
id="pdf-edit-plan",
|
||||
description=(
|
||||
"Produce an edit plan (a structured sequence of PDF operations) from a"
|
||||
" natural-language edit request. The plan is executed by Java through the job"
|
||||
" pipeline; this capability does not modify files itself."
|
||||
),
|
||||
input_model=PdfEditRequest,
|
||||
mode="async",
|
||||
required_scope="mcp.tools.write",
|
||||
route="/api/v1/pdf-edit",
|
||||
),
|
||||
AgentCapability(
|
||||
id="agent-draft",
|
||||
description=(
|
||||
"Draft a structured agent specification from a free-text description of the task the user wants automated."
|
||||
),
|
||||
input_model=AgentDraftRequest,
|
||||
mode="sync",
|
||||
required_scope="mcp.tools.read",
|
||||
route="/api/v1/ai/agents/draft",
|
||||
),
|
||||
AgentCapability(
|
||||
id="agent-revise",
|
||||
description=("Revise an existing draft agent specification based on user feedback or constraint changes."),
|
||||
input_model=AgentRevisionRequest,
|
||||
mode="sync",
|
||||
required_scope="mcp.tools.read",
|
||||
route="/api/v1/ai/agents/revise",
|
||||
),
|
||||
AgentCapability(
|
||||
id="math-audit-examine",
|
||||
description=(
|
||||
"Examine a folio manifest of financial / numeric documents and surface the"
|
||||
" evidence that needs to be checked for arithmetic consistency."
|
||||
),
|
||||
input_model=FolioManifest,
|
||||
mode="sync",
|
||||
required_scope="mcp.tools.read",
|
||||
route="/api/v1/ai/math-auditor-agent/examine",
|
||||
),
|
||||
AgentCapability(
|
||||
id="math-audit-deliberate",
|
||||
description=(
|
||||
"Render a deliberated verdict on a single piece of evidence the examine step"
|
||||
" surfaced (does the arithmetic check out, with what caveats)."
|
||||
),
|
||||
input_model=Evidence,
|
||||
mode="sync",
|
||||
required_scope="mcp.tools.read",
|
||||
route="/api/v1/ai/math-auditor-agent/deliberate",
|
||||
),
|
||||
AgentCapability(
|
||||
id="pdf-comment-generate",
|
||||
description="Generate inline review comments for a PDF document.",
|
||||
input_model=PdfCommentRequest,
|
||||
mode="sync",
|
||||
required_scope="mcp.tools.read",
|
||||
route="/api/v1/pdf-comment/generate",
|
||||
),
|
||||
AgentCapability(
|
||||
id="agent-next-action",
|
||||
description=(
|
||||
"Decide the next execution step for an in-progress agent workflow. Returns a"
|
||||
" ToolCall, Completed, or CannotContinue action."
|
||||
),
|
||||
input_model=AgentExecutionRequest,
|
||||
mode="sync",
|
||||
required_scope="mcp.tools.read",
|
||||
route="/api/v1/agents/next-action",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def manifest_payload() -> dict[str, Any]:
|
||||
"""Serialize the curated registry to the wire shape consumed by Java.
|
||||
|
||||
Schema is derived from ``input_model.model_json_schema()`` so we never hand-write JSON
|
||||
Schema - the Pydantic model is the single source of truth.
|
||||
"""
|
||||
items: list[dict[str, Any]] = []
|
||||
for cap in EXPOSED_CAPABILITIES:
|
||||
items.append(
|
||||
{
|
||||
"id": cap.id,
|
||||
"description": cap.description,
|
||||
"input_schema": cap.input_model.model_json_schema(),
|
||||
"mode": cap.mode,
|
||||
"required_scope": cap.required_scope,
|
||||
"route": cap.route,
|
||||
}
|
||||
)
|
||||
return {"version": 1, "capabilities": items}
|
||||
@@ -18,8 +18,10 @@ from stirling.agents import (
|
||||
)
|
||||
from stirling.agents.ledger import MathAuditorAgent
|
||||
from stirling.agents.pdf_comment import PdfCommentAgent
|
||||
from stirling.api.engine_auth import EngineSharedSecretMiddleware
|
||||
from stirling.api.middleware import UserIdMiddleware
|
||||
from stirling.api.routes import (
|
||||
agent_capabilities_router,
|
||||
agent_draft_router,
|
||||
document_router,
|
||||
execution_router,
|
||||
@@ -115,6 +117,7 @@ async def lifespan(fast_api: FastAPI):
|
||||
|
||||
app = FastAPI(title="Stirling AI Engine", lifespan=lifespan, version="0.1.0")
|
||||
app.add_middleware(UserIdMiddleware)
|
||||
app.add_middleware(EngineSharedSecretMiddleware)
|
||||
app.include_router(orchestrator_router)
|
||||
app.include_router(pdf_edit_router)
|
||||
app.include_router(pdf_question_router)
|
||||
@@ -123,6 +126,7 @@ app.include_router(execution_router)
|
||||
app.include_router(document_router)
|
||||
app.include_router(ledger_router)
|
||||
app.include_router(pdf_comments_router)
|
||||
app.include_router(agent_capabilities_router)
|
||||
|
||||
|
||||
@app.get("/health", response_model=HealthResponse)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Shared-secret middleware that locks the engine to the trusted Java backend.
|
||||
|
||||
Config (resolved via :class:`stirling.config.AppSettings`/pydantic-settings):
|
||||
``STIRLING_ENGINE_SHARED_SECRET`` - non-public routes need ``X-Engine-Auth`` or 401.
|
||||
``STIRLING_ENGINE_REQUIRE_AUTH`` - fail closed with 503 when truthy and no secret is set.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from stirling.config import load_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_HEADER = "X-Engine-Auth"
|
||||
|
||||
# Public paths (liveness + docs); everything else needs the secret when configured.
|
||||
_PUBLIC_PREFIXES: tuple[str, ...] = (
|
||||
"/health",
|
||||
"/docs",
|
||||
"/redoc",
|
||||
"/openapi.json",
|
||||
)
|
||||
|
||||
|
||||
class EngineSharedSecretMiddleware(BaseHTTPMiddleware):
|
||||
"""Reject non-public requests lacking the shared secret.
|
||||
|
||||
Non-public path: secret set -> require matching X-Engine-Auth (else 401); else require flag
|
||||
truthy -> 503 (fail-closed); else allow through.
|
||||
|
||||
Secret/require values come from :class:`stirling.config.AppSettings` by default; tests can
|
||||
pass them explicitly to avoid touching the lru-cached settings.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app: ASGIApp,
|
||||
public_prefixes: Iterable[str] = _PUBLIC_PREFIXES,
|
||||
*,
|
||||
secret: str | None = None,
|
||||
require: bool | None = None,
|
||||
) -> None:
|
||||
super().__init__(app)
|
||||
self._public_prefixes = tuple(public_prefixes)
|
||||
if secret is None or require is None:
|
||||
settings = load_settings()
|
||||
if secret is None:
|
||||
secret = settings.engine_shared_secret
|
||||
if require is None:
|
||||
require = settings.engine_require_auth
|
||||
self._secret = secret or ""
|
||||
self._require = bool(require)
|
||||
if self._secret:
|
||||
logger.info(
|
||||
"Engine shared-secret enforcement ENABLED: non-public routes require a valid %s"
|
||||
" header (constant-time compared).",
|
||||
_HEADER,
|
||||
)
|
||||
elif self._require:
|
||||
logger.error(
|
||||
"STIRLING_ENGINE_REQUIRE_AUTH is enabled but STIRLING_ENGINE_SHARED_SECRET is not"
|
||||
" set - the engine will REFUSE every non-public request (HTTP 503, fail-closed)"
|
||||
" until a shared secret is configured.",
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"STIRLING_ENGINE_SHARED_SECRET not set - engine shared-secret enforcement is"
|
||||
" DISABLED. The AI and document routes then trust the caller-supplied X-User-Id"
|
||||
" header alone. Set this secret (and STIRLING_ENGINE_REQUIRE_AUTH=true) in any"
|
||||
" deployment that exposes the engine beyond localhost.",
|
||||
)
|
||||
|
||||
def _is_public(self, path: str) -> bool:
|
||||
return any(path == p or path.startswith(p + "/") for p in self._public_prefixes)
|
||||
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
if not self._is_public(request.url.path):
|
||||
if self._secret:
|
||||
offered = request.headers.get(_HEADER) or ""
|
||||
# Constant-time compare to avoid timing leaks.
|
||||
if not hmac.compare_digest(offered, self._secret):
|
||||
return JSONResponse({"detail": "Missing or invalid X-Engine-Auth header."}, status_code=401)
|
||||
elif self._require:
|
||||
# Fail closed: require flag set but no secret configured.
|
||||
return JSONResponse(
|
||||
{"detail": ("Engine authentication is required but no shared secret is configured.")},
|
||||
status_code=503,
|
||||
)
|
||||
return await call_next(request)
|
||||
@@ -1,3 +1,4 @@
|
||||
from .agent_capabilities import router as agent_capabilities_router
|
||||
from .agent_drafts import router as agent_draft_router
|
||||
from .documents import router as document_router
|
||||
from .execution import router as execution_router
|
||||
@@ -8,6 +9,7 @@ from .pdf_edit import router as pdf_edit_router
|
||||
from .pdf_questions import router as pdf_question_router
|
||||
|
||||
__all__ = [
|
||||
"agent_capabilities_router",
|
||||
"agent_draft_router",
|
||||
"document_router",
|
||||
"execution_router",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""GET ``/api/v1/agents/capabilities`` - the manifest the Java MCP server pulls at boot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from stirling.api.agent_capabilities import manifest_payload
|
||||
|
||||
router = APIRouter(prefix="/api/v1/agents", tags=["agents"])
|
||||
|
||||
|
||||
@router.get("/capabilities")
|
||||
def get_capabilities() -> dict[str, Any]:
|
||||
"""Return the curated agent capabilities manifest.
|
||||
|
||||
Gated by ``EngineSharedSecretMiddleware`` when the ``STIRLING_ENGINE_SHARED_SECRET`` env var
|
||||
is configured. In dev/local mode (no secret set), the endpoint is open - the engine binds to
|
||||
localhost only by default, so this is acceptable while iterating.
|
||||
"""
|
||||
return manifest_payload()
|
||||
@@ -102,6 +102,11 @@ class AppSettings(BaseSettings):
|
||||
posthog_api_key: str = Field(validation_alias="STIRLING_POSTHOG_API_KEY")
|
||||
posthog_host: str = Field(validation_alias="STIRLING_POSTHOG_HOST")
|
||||
|
||||
# Shared secret enforced by EngineSharedSecretMiddleware. Empty disables enforcement
|
||||
# unless engine_require_auth is set, in which case the engine fails closed (503).
|
||||
engine_shared_secret: str = Field(default="", validation_alias="STIRLING_ENGINE_SHARED_SECRET")
|
||||
engine_require_auth: bool = Field(default=False, validation_alias="STIRLING_ENGINE_REQUIRE_AUTH")
|
||||
|
||||
|
||||
def _configure_logging(level_name: str, log_file: str, http_debug: bool) -> None:
|
||||
"""Configure the ``stirling`` logger hierarchy."""
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Tests for the engine shared-secret middleware (EngineSharedSecretMiddleware)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from stirling.api.engine_auth import EngineSharedSecretMiddleware
|
||||
|
||||
SECRET = "s3cr3t-between-java-and-engine"
|
||||
|
||||
_TRUTHY = {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _client(*, secret: str | None = None, require: str | None = None) -> TestClient:
|
||||
# Values come from explicit kwargs rather than env so tests don't depend on the lru-cached
|
||||
# AppSettings; this mirrors how production code wires the middleware via pydantic-settings.
|
||||
require_flag = require is not None and require.strip().lower() in _TRUTHY
|
||||
app = FastAPI()
|
||||
app.add_middleware(
|
||||
EngineSharedSecretMiddleware,
|
||||
secret=secret or "",
|
||||
require=require_flag,
|
||||
)
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, bool]:
|
||||
return {"ok": True}
|
||||
|
||||
@app.post("/v1/agents/invoke")
|
||||
def invoke() -> dict[str, bool]:
|
||||
return {"ran": True}
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_dev_mode_open_when_unset():
|
||||
c = _client()
|
||||
assert c.post("/v1/agents/invoke").status_code == 200
|
||||
|
||||
|
||||
def test_health_is_public_even_with_secret():
|
||||
c = _client(secret=SECRET)
|
||||
assert c.get("/health").status_code == 200 # no header required
|
||||
|
||||
|
||||
def test_missing_header_rejected():
|
||||
c = _client(secret=SECRET)
|
||||
assert c.post("/v1/agents/invoke").status_code == 401
|
||||
|
||||
|
||||
def test_wrong_secret_rejected():
|
||||
c = _client(secret=SECRET)
|
||||
r = c.post("/v1/agents/invoke", headers={"X-Engine-Auth": "not-the-secret"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_valid_secret_allowed():
|
||||
c = _client(secret=SECRET)
|
||||
r = c.post("/v1/agents/invoke", headers={"X-Engine-Auth": SECRET})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"ran": True}
|
||||
|
||||
|
||||
def test_require_auth_fails_closed_without_secret():
|
||||
c = _client(require="true")
|
||||
# Require flag, no secret -> protected routes refused.
|
||||
assert c.post("/v1/agents/invoke").status_code == 503
|
||||
# Liveness still works for health checks.
|
||||
assert c.get("/health").status_code == 200
|
||||
|
||||
|
||||
def test_require_auth_with_secret_enforces_normally():
|
||||
c = _client(secret=SECRET, require="true")
|
||||
assert c.post("/v1/agents/invoke").status_code == 401
|
||||
assert c.post("/v1/agents/invoke", headers={"X-Engine-Auth": SECRET}).status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flag", ["true", "1", "YES", "On"])
|
||||
def test_require_flag_truthy_variants(flag: str):
|
||||
c = _client(require=flag)
|
||||
assert c.post("/v1/agents/invoke").status_code == 503
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flag", ["false", "0", "no", ""])
|
||||
def test_require_flag_falsey_variants_stay_open(flag: str):
|
||||
c = _client(require=flag)
|
||||
assert c.post("/v1/agents/invoke").status_code == 200
|
||||
@@ -1110,6 +1110,66 @@ label = "SMTP Port"
|
||||
description = "Username for SMTP authentication"
|
||||
label = "SMTP Username"
|
||||
|
||||
[admin.settings.mcp]
|
||||
apikeyNote = "Clients send a Stirling API key in the X-API-KEY header (or Authorization: Bearer <key>). The key maps to its owning Stirling user - only provisioned accounts get in, and actions are audited as that user. Manage keys under Account > API Keys."
|
||||
description = "Expose Stirling's PDF tools and AI agents to MCP clients over an OAuth-protected endpoint."
|
||||
title = "MCP Server"
|
||||
|
||||
[admin.settings.mcp.allowedOps]
|
||||
description = "If set, ONLY these operation ids are exposed (an allow-list; comma or space separated). Leave blank to expose all enabled tools except any blocked below."
|
||||
label = "Allowed tools"
|
||||
|
||||
[admin.settings.mcp.blockedOps]
|
||||
description = "Operation ids to hide from MCP (comma or space separated), e.g. add-password remove-password. Leave blank to expose all enabled tools."
|
||||
label = "Blocked tools"
|
||||
|
||||
[admin.settings.mcp.enabled]
|
||||
description = "When off (default), no /mcp endpoint, metadata, or MCP beans are loaded."
|
||||
label = "Enable MCP Server"
|
||||
|
||||
[admin.settings.mcp.guide]
|
||||
step1 = "Enter your OAuth issuer + resource ID above, Save, and restart."
|
||||
step1ApiKey = "Create an API key under Account > API Keys (each user uses their own)."
|
||||
step2 = "In your MCP client add a"
|
||||
step2b = "server pointing at:"
|
||||
step3 = "The client auto-discovers OAuth from:"
|
||||
step3ApiKey = "Send the key in an"
|
||||
step3ApiKeyb = "header (or Authorization: Bearer <key>). No OAuth or metadata discovery is used."
|
||||
step4 = "Approve the sign-in; the client retries with a token. Tools appear grouped: convert, pages, misc, security, ai."
|
||||
step4ApiKey = "Tools appear grouped: convert, pages, misc, security, ai - and every call is audited as the key's owner."
|
||||
tip = "Tip: register the resource ID as an allowed audience in your IdP. Tested with MCP Inspector and Claude Desktop."
|
||||
tipApiKey = "Tip: API-key mode needs no external IdP - simplest for self-host. The key maps to its owning Stirling user."
|
||||
title = "Connect an MCP client"
|
||||
|
||||
[admin.settings.mcp.issuerUri]
|
||||
description = "Your OAuth2 authorization server (must publish /.well-known/openid-configuration). Required when enabled."
|
||||
label = "OAuth Issuer URL"
|
||||
|
||||
[admin.settings.mcp.jwksUri]
|
||||
description = "Leave blank to discover it from the issuer. Set only if your IdP serves keys at a non-standard URL."
|
||||
label = "JWKS URL (optional)"
|
||||
placeholder = "Auto-discovered from issuer"
|
||||
|
||||
[admin.settings.mcp.mode]
|
||||
description = "OAuth needs an external IdP. API key uses a Stirling per-user API key (X-API-KEY) - simplest for self-host."
|
||||
label = "Authentication mode"
|
||||
|
||||
[admin.settings.mcp.requireAccount]
|
||||
description = "Only let tokens through if their subject maps to a provisioned, enabled Stirling user."
|
||||
label = "Require an existing Stirling account"
|
||||
|
||||
[admin.settings.mcp.resourceId]
|
||||
description = "This server's public /mcp URL. Tokens must list it in their audience (RFC 8707) or they are rejected."
|
||||
label = "Resource ID"
|
||||
|
||||
[admin.settings.mcp.scopes]
|
||||
description = "Require mcp.tools.read for read ops and mcp.tools.write for write/AI ops."
|
||||
label = "Enforce OAuth scopes"
|
||||
|
||||
[admin.settings.mcp.usernameClaim]
|
||||
description = "JWT claim matched against a Stirling username (e.g. sub, email, preferred_username)."
|
||||
label = "Username claim"
|
||||
|
||||
[admin.settings.plan.noData]
|
||||
message = "Plans data is not available at the moment."
|
||||
title = "No data available"
|
||||
@@ -7219,6 +7279,7 @@ advanced = "Advanced"
|
||||
database = "Database"
|
||||
endpoints = "Endpoints"
|
||||
features = "Features"
|
||||
mcp = "MCP Server"
|
||||
storageSharing = "File Storage & Sharing"
|
||||
systemSettings = "System Settings"
|
||||
title = "Configuration"
|
||||
|
||||
@@ -40,6 +40,7 @@ const SETTINGS_SEARCH_TRANSLATION_PREFIXES: Partial<Record<string, string[]>> =
|
||||
adminDatabase: ["admin.settings.database"],
|
||||
adminAdvanced: ["admin.settings.advanced"],
|
||||
adminSecurity: ["admin.settings.security"],
|
||||
adminMcp: ["admin.settings.mcp"],
|
||||
adminConnections: [
|
||||
"admin.settings.connections",
|
||||
"admin.settings.mail",
|
||||
|
||||
@@ -29,6 +29,7 @@ export const VALID_NAV_KEYS = [
|
||||
"adminUsage",
|
||||
"adminEndpoints",
|
||||
"adminStorageSharing",
|
||||
"adminMcp",
|
||||
"help",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import AdminLegalSection from "@app/components/shared/config/configSections/Admi
|
||||
import AdminPlanSection from "@app/components/shared/config/configSections/AdminPlanSection";
|
||||
import AdminFeaturesSection from "@app/components/shared/config/configSections/AdminFeaturesSection";
|
||||
import AdminEndpointsSection from "@app/components/shared/config/configSections/AdminEndpointsSection";
|
||||
import AdminMcpSection from "@app/components/shared/config/configSections/AdminMcpSection";
|
||||
import AdminAuditSection from "@app/components/shared/config/configSections/AdminAuditSection";
|
||||
import AdminUsageSection from "@app/components/shared/config/configSections/AdminUsageSection";
|
||||
import AdminStorageSharingSection from "@app/components/shared/config/configSections/AdminStorageSharingSection";
|
||||
@@ -136,6 +137,14 @@ export const useConfigNavSections = (
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
|
||||
},
|
||||
{
|
||||
key: "adminMcp",
|
||||
label: t("settings.configuration.mcp", "MCP Server"),
|
||||
icon: "smart-toy-rounded",
|
||||
component: <AdminMcpSection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
|
||||
},
|
||||
{
|
||||
key: "adminDatabase",
|
||||
label: t("settings.configuration.database", "Database"),
|
||||
|
||||
+551
@@ -0,0 +1,551 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TextInput,
|
||||
Textarea,
|
||||
Switch,
|
||||
Select,
|
||||
Stack,
|
||||
Paper,
|
||||
Text,
|
||||
Loader,
|
||||
Group,
|
||||
Alert,
|
||||
Code,
|
||||
List,
|
||||
} from "@mantine/core";
|
||||
import { alert } from "@app/components/toast";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
|
||||
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
|
||||
import { useAdminSettings } from "@app/hooks/useAdminSettings";
|
||||
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
|
||||
import PendingBadge from "@app/components/shared/config/PendingBadge";
|
||||
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { useLoginRequired } from "@app/hooks/useLoginRequired";
|
||||
|
||||
interface McpAuthData {
|
||||
mode?: string;
|
||||
issuerUri?: string;
|
||||
jwksUri?: string;
|
||||
resourceId?: string;
|
||||
usernameClaim?: string;
|
||||
requireExistingAccount?: boolean;
|
||||
}
|
||||
|
||||
interface McpSettingsData {
|
||||
enabled?: boolean;
|
||||
scopesEnabled?: boolean;
|
||||
allowedOperations?: string[];
|
||||
blockedOperations?: string[];
|
||||
auth?: McpAuthData;
|
||||
}
|
||||
|
||||
/** Parse a comma/space/newline separated list into a string[]. */
|
||||
function parseOpList(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\s,]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
interface ApiResponseWithPending<T> {
|
||||
_pending?: Partial<T>;
|
||||
}
|
||||
|
||||
type McpApiResponse = McpSettingsData & ApiResponseWithPending<McpSettingsData>;
|
||||
|
||||
export default function AdminMcpSection() {
|
||||
const { t } = useTranslation();
|
||||
const { loginEnabled } = useLoginRequired();
|
||||
const {
|
||||
restartModalOpened,
|
||||
showRestartModal,
|
||||
closeRestartModal,
|
||||
restartServer,
|
||||
} = useRestartServer();
|
||||
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<McpSettingsData>({
|
||||
sectionName: "mcp",
|
||||
fetchTransformer: async (): Promise<
|
||||
McpSettingsData & { _pending?: Partial<McpSettingsData> }
|
||||
> => {
|
||||
const response = await apiClient.get<McpApiResponse>(
|
||||
"/api/v1/admin/settings/section/mcp",
|
||||
);
|
||||
return response.data || {};
|
||||
},
|
||||
// Save nested auth.* keys as dot-notation through the root endpoint so siblings are preserved.
|
||||
saveTransformer: (s: McpSettingsData) => ({
|
||||
sectionData: {},
|
||||
deltaSettings: {
|
||||
"mcp.enabled": s.enabled ?? false,
|
||||
"mcp.scopesEnabled": s.scopesEnabled ?? true,
|
||||
"mcp.allowedOperations": s.allowedOperations ?? [],
|
||||
"mcp.blockedOperations": s.blockedOperations ?? [],
|
||||
"mcp.auth.mode": s.auth?.mode ?? "oauth",
|
||||
"mcp.auth.issuerUri": s.auth?.issuerUri ?? "",
|
||||
"mcp.auth.jwksUri": s.auth?.jwksUri ?? "",
|
||||
"mcp.auth.resourceId": s.auth?.resourceId ?? "",
|
||||
"mcp.auth.usernameClaim": s.auth?.usernameClaim ?? "sub",
|
||||
"mcp.auth.requireExistingAccount":
|
||||
s.auth?.requireExistingAccount ?? true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
|
||||
settings,
|
||||
loading,
|
||||
);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await saveSettings();
|
||||
markSaved();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("admin.error", "Error"),
|
||||
body: t("admin.settings.saveError", "Failed to save settings"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
setSettings(resetToSnapshot());
|
||||
}, [resetToSnapshot, setSettings]);
|
||||
|
||||
const setAuth = (patch: Partial<McpAuthData>) =>
|
||||
setSettings({ ...settings, auth: { ...(settings.auth || {}), ...patch } });
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h={200}>
|
||||
<Loader size="lg" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl =
|
||||
typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: "https://your-host";
|
||||
const mcpUrl = `${baseUrl}/mcp`;
|
||||
const metadataUrl = `${baseUrl}/.well-known/oauth-protected-resource`;
|
||||
const authMode = settings.auth?.mode || "oauth";
|
||||
|
||||
return (
|
||||
<div className="settings-section-container">
|
||||
<Stack gap="lg" className="settings-section-content">
|
||||
<div>
|
||||
<Text fw={600} size="lg">
|
||||
{t("admin.settings.mcp.title", "MCP Server")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"admin.settings.mcp.description",
|
||||
"Expose Stirling's PDF tools and AI agents to MCP clients over an OAuth-protected endpoint.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t("admin.settings.mcp.enabled.label", "Enable MCP Server")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"admin.settings.mcp.enabled.description",
|
||||
"When off (default), no /mcp endpoint, metadata, or MCP beans are loaded.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.enabled || false}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, enabled: e.target.checked })
|
||||
}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending("enabled")} />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Select
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t("admin.settings.mcp.mode.label", "Authentication mode")}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("auth.mode")} />
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.mcp.mode.description",
|
||||
"OAuth needs an external IdP. API key uses a Stirling per-user API key (X-API-KEY) - simplest for self-host.",
|
||||
)}
|
||||
data={[
|
||||
{ value: "oauth", label: "OAuth 2.1 (external IdP)" },
|
||||
{ value: "apikey", label: "API key (Stirling per-user key)" },
|
||||
]}
|
||||
value={authMode}
|
||||
onChange={(v) => setAuth({ mode: v || "oauth" })}
|
||||
allowDeselect={false}
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
|
||||
{authMode === "apikey" && (
|
||||
<Alert variant="light" color="gray">
|
||||
<Text size="xs">
|
||||
{t(
|
||||
"admin.settings.mcp.apikeyNote",
|
||||
"Clients send a Stirling API key in the X-API-KEY header (or Authorization: Bearer <key>). The key maps to its owning Stirling user - only provisioned accounts get in, and actions are audited as that user. Manage keys under Account → API Keys.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{authMode === "oauth" && (
|
||||
<>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t(
|
||||
"admin.settings.mcp.issuerUri.label",
|
||||
"OAuth Issuer URL",
|
||||
)}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("auth.issuerUri")} />
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.mcp.issuerUri.description",
|
||||
"Your OAuth2 authorization server (must publish /.well-known/openid-configuration). Required when enabled.",
|
||||
)}
|
||||
value={settings.auth?.issuerUri || ""}
|
||||
onChange={(e) => setAuth({ issuerUri: e.target.value })}
|
||||
placeholder="https://auth.example.com"
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t(
|
||||
"admin.settings.mcp.resourceId.label",
|
||||
"Resource ID",
|
||||
)}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("auth.resourceId")} />
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.mcp.resourceId.description",
|
||||
"This server's public /mcp URL. Tokens must list it in their audience (RFC 8707) or they are rejected.",
|
||||
)}
|
||||
value={settings.auth?.resourceId || ""}
|
||||
onChange={(e) => setAuth({ resourceId: e.target.value })}
|
||||
placeholder={mcpUrl}
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t(
|
||||
"admin.settings.mcp.jwksUri.label",
|
||||
"JWKS URL (optional)",
|
||||
)}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("auth.jwksUri")} />
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.mcp.jwksUri.description",
|
||||
"Leave blank to discover it from the issuer. Set only if your IdP serves keys at a non-standard URL.",
|
||||
)}
|
||||
value={settings.auth?.jwksUri || ""}
|
||||
onChange={(e) => setAuth({ jwksUri: e.target.value })}
|
||||
placeholder={t(
|
||||
"admin.settings.mcp.jwksUri.placeholder",
|
||||
"Auto-discovered from issuer",
|
||||
)}
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.mcp.scopes.label",
|
||||
"Enforce OAuth scopes",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"admin.settings.mcp.scopes.description",
|
||||
"Require mcp.tools.read for read ops and mcp.tools.write for write/AI ops.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.scopesEnabled ?? true}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
scopesEnabled: e.target.checked,
|
||||
})
|
||||
}
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending("scopesEnabled")} />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.mcp.requireAccount.label",
|
||||
"Require an existing Stirling account",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"admin.settings.mcp.requireAccount.description",
|
||||
"Only let tokens through if their subject maps to a provisioned, enabled Stirling user.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.auth?.requireExistingAccount ?? true}
|
||||
onChange={(e) =>
|
||||
setAuth({ requireExistingAccount: e.target.checked })
|
||||
}
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
<PendingBadge
|
||||
show={isFieldPending("auth.requireExistingAccount")}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t(
|
||||
"admin.settings.mcp.usernameClaim.label",
|
||||
"Username claim",
|
||||
)}
|
||||
</span>
|
||||
<PendingBadge
|
||||
show={isFieldPending("auth.usernameClaim")}
|
||||
/>
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.mcp.usernameClaim.description",
|
||||
"JWT claim matched against a Stirling username (e.g. sub, email, preferred_username).",
|
||||
)}
|
||||
value={settings.auth?.usernameClaim || ""}
|
||||
onChange={(e) => setAuth({ usernameClaim: e.target.value })}
|
||||
placeholder="sub"
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t("admin.settings.mcp.allowedOps.label", "Allowed tools")}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("allowedOperations")} />
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.mcp.allowedOps.description",
|
||||
"If set, ONLY these operation ids are exposed (an allow-list; comma or space separated). Leave blank to expose all enabled tools except any blocked below.",
|
||||
)}
|
||||
value={(settings.allowedOperations || []).join(" ")}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
allowedOperations: parseOpList(e.target.value),
|
||||
})
|
||||
}
|
||||
placeholder="merge-pdfs split-pdf rotate-pdf"
|
||||
autosize
|
||||
minRows={1}
|
||||
maxRows={3}
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t("admin.settings.mcp.blockedOps.label", "Blocked tools")}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("blockedOperations")} />
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.mcp.blockedOps.description",
|
||||
"Operation ids to hide from MCP (comma or space separated), e.g. add-password remove-password. Leave blank to expose all enabled tools.",
|
||||
)}
|
||||
value={(settings.blockedOperations || []).join(" ")}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
blockedOperations: parseOpList(e.target.value),
|
||||
})
|
||||
}
|
||||
placeholder="add-password sanitize-pdf"
|
||||
autosize
|
||||
minRows={1}
|
||||
maxRows={3}
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Compact in-page setup guide */}
|
||||
<Alert
|
||||
variant="light"
|
||||
color="blue"
|
||||
title={t("admin.settings.mcp.guide.title", "Connect an MCP client")}
|
||||
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
|
||||
>
|
||||
<Stack gap={6}>
|
||||
<List size="xs" type="ordered" spacing={4}>
|
||||
{authMode === "oauth" ? (
|
||||
<>
|
||||
<List.Item>
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step1",
|
||||
"Enter your OAuth issuer + resource ID above, Save, and restart.",
|
||||
)}
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step2",
|
||||
"In your MCP client add a",
|
||||
)}{" "}
|
||||
<b>streamable-HTTP</b>{" "}
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step2b",
|
||||
"server pointing at:",
|
||||
)}{" "}
|
||||
<Code>{mcpUrl}</Code>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step3",
|
||||
"The client auto-discovers OAuth from:",
|
||||
)}{" "}
|
||||
<Code>{metadataUrl}</Code>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step4",
|
||||
"Approve the sign-in; the client retries with a token. Tools appear grouped: convert, pages, misc, security, ai.",
|
||||
)}
|
||||
</List.Item>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<List.Item>
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step1ApiKey",
|
||||
"Create an API key under Account → API Keys (each user uses their own).",
|
||||
)}
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step2",
|
||||
"In your MCP client add a",
|
||||
)}{" "}
|
||||
<b>streamable-HTTP</b>{" "}
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step2b",
|
||||
"server pointing at:",
|
||||
)}{" "}
|
||||
<Code>{mcpUrl}</Code>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step3ApiKey",
|
||||
"Send the key in an",
|
||||
)}{" "}
|
||||
<Code>X-API-KEY</Code>{" "}
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step3ApiKeyb",
|
||||
"header (or Authorization: Bearer <key>). No OAuth or metadata discovery is used.",
|
||||
)}
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step4ApiKey",
|
||||
"Tools appear grouped: convert, pages, misc, security, ai - and every call is audited as the key's owner.",
|
||||
)}
|
||||
</List.Item>
|
||||
</>
|
||||
)}
|
||||
</List>
|
||||
<Text size="xs" c="dimmed">
|
||||
{authMode === "oauth"
|
||||
? t(
|
||||
"admin.settings.mcp.guide.tip",
|
||||
"Tip: register the resource ID as an allowed audience in your IdP. Tested with MCP Inspector and Claude Desktop.",
|
||||
)
|
||||
: t(
|
||||
"admin.settings.mcp.guide.tipApiKey",
|
||||
"Tip: API-key mode needs no external IdP - simplest for self-host. The key maps to its owning Stirling user.",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
</Stack>
|
||||
|
||||
<SettingsStickyFooter
|
||||
isDirty={isDirty}
|
||||
saving={saving}
|
||||
loginEnabled={loginEnabled}
|
||||
onSave={handleSave}
|
||||
onDiscard={handleDiscard}
|
||||
/>
|
||||
|
||||
<RestartConfirmationModal
|
||||
opened={restartModalOpened}
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
services:
|
||||
keycloak-mcp-db:
|
||||
container_name: stirling-keycloak-mcp-db
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: keycloak
|
||||
POSTGRES_USER: keycloak
|
||||
POSTGRES_PASSWORD: keycloak
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U keycloak"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
networks:
|
||||
- stirling-mcp-test
|
||||
|
||||
keycloak-mcp:
|
||||
container_name: stirling-keycloak-mcp
|
||||
image: quay.io/keycloak/keycloak:24.0
|
||||
command:
|
||||
- start-dev
|
||||
- --import-realm
|
||||
environment:
|
||||
KC_DB: postgres
|
||||
KC_DB_URL: jdbc:postgresql://keycloak-mcp-db:5432/keycloak
|
||||
KC_DB_USERNAME: keycloak
|
||||
KC_DB_PASSWORD: keycloak
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||
# Issuer hostname must resolve identically from host and Stirling container (see start-mcp-test.sh preflight).
|
||||
KC_HOSTNAME: "${KEYCLOAK_HOST:-kubernetes.docker.internal}"
|
||||
KC_HOSTNAME_PORT: 9080
|
||||
KC_HOSTNAME_STRICT: "false"
|
||||
KC_HTTP_ENABLED: "true"
|
||||
ports:
|
||||
- "9080:8080"
|
||||
volumes:
|
||||
- ./keycloak-realm-mcp.json:/opt/keycloak/data/import/realm-export.json:ro
|
||||
depends_on:
|
||||
keycloak-mcp-db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && echo -e 'GET /realms/stirling-mcp HTTP/1.1\\nHost: localhost\\nConnection: close\\n\\n' >&3 && timeout 2 cat <&3 | head -n 1 | grep -q '200'"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 30
|
||||
start_period: 60s
|
||||
networks:
|
||||
- stirling-mcp-test
|
||||
|
||||
stirling-pdf-mcp:
|
||||
container_name: stirling-pdf-mcp-test
|
||||
image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: docker/embedded/Dockerfile
|
||||
extra_hosts:
|
||||
localhost: host-gateway
|
||||
kubernetes.docker.internal: host-gateway
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 30
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ../../../stirling/keycloak-mcp-test/data:/usr/share/tessdata:rw
|
||||
- ../../../stirling/keycloak-mcp-test/config:/configs:rw
|
||||
- ../../../stirling/keycloak-mcp-test/logs:/logs:rw
|
||||
environment:
|
||||
# Security/login on so we have real Stirling accounts to bind to.
|
||||
DOCKER_ENABLE_SECURITY: "true"
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
SECURITY_LOGINMETHOD: "normal"
|
||||
SYSTEM_DEFAULTLOCALE: en-US
|
||||
SYSTEM_BACKENDURL: "http://localhost:8080"
|
||||
PREMIUM_KEY: "${PREMIUM_KEY:-00000000-0000-0000-0000-000000000000}"
|
||||
PREMIUM_ENABLED: "true"
|
||||
UI_APPNAME: Stirling-PDF MCP Test
|
||||
UI_HOMEDESCRIPTION: Keycloak MCP (OAuth resource server) Test Instance
|
||||
UI_APPNAMENAVBAR: Stirling-PDF MCP
|
||||
SYSTEM_MAXFILESIZE: "100"
|
||||
|
||||
# Seed the Stirling account the Keycloak "email" claim binds to (proves account-binding).
|
||||
SECURITY_INITIALLOGIN_USERNAME: "[email protected]"
|
||||
SECURITY_INITIALLOGIN_PASSWORD: "mcppassword"
|
||||
|
||||
# MCP server: OAuth2 resource-server mode (validates Keycloak tokens at /mcp, no web SSO).
|
||||
MCP_ENABLED: "true"
|
||||
MCP_AUTH_MODE: "${MCP_AUTH_MODE:-oauth}"
|
||||
# Lowered so the oversized-body 413 check stays fast; production default is 10MB.
|
||||
MCP_MAXREQUESTBYTES: "262144"
|
||||
# Must match the token "iss" exactly.
|
||||
MCP_AUTH_ISSUERURI: "http://${KEYCLOAK_HOST:-kubernetes.docker.internal}:9080/realms/stirling-mcp"
|
||||
# RFC 8707 audience - must equal the "aud" the realm's mapper injects.
|
||||
MCP_AUTH_RESOURCEID: "http://localhost:8080/mcp"
|
||||
# Bind the JWT to a Stirling user by the "email" claim.
|
||||
MCP_AUTH_USERNAMECLAIM: "email"
|
||||
MCP_AUTH_REQUIREEXISTINGACCOUNT: "true"
|
||||
|
||||
# Resource server only - disable the web SSO client paths.
|
||||
SECURITY_OAUTH2_ENABLED: "false"
|
||||
SECURITY_SAML2_ENABLED: "false"
|
||||
|
||||
# Debug logging
|
||||
LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_SECURITY_OAUTH2: DEBUG
|
||||
LOGGING_LEVEL_STIRLING_SOFTWARE_PROPRIETARY_MCP: DEBUG
|
||||
|
||||
# LibreOffice settings
|
||||
PROCESS_EXECUTOR_AUTO_UNO_SERVER: "true"
|
||||
PROCESS_EXECUTOR_SESSION_LIMIT_LIBRE_OFFICE_SESSION_LIMIT: "1"
|
||||
|
||||
# Permissions
|
||||
PUID: 1002
|
||||
PGID: 1002
|
||||
UMASK: "022"
|
||||
|
||||
# Features
|
||||
DISABLE_ADDITIONAL_FEATURES: "false"
|
||||
METRICS_ENABLED: "true"
|
||||
SYSTEM_GOOGLEVISIBILITY: "false"
|
||||
SHOW_SURVEY: "false"
|
||||
|
||||
depends_on:
|
||||
keycloak-mcp:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- stirling-mcp-test
|
||||
restart: on-failure:5
|
||||
|
||||
networks:
|
||||
stirling-mcp-test:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,438 @@
|
||||
{
|
||||
"id": "stirling-mcp",
|
||||
"realm": "stirling-mcp",
|
||||
"displayName": "Stirling PDF MCP Test",
|
||||
"displayNameHtml": "<div class=\"kc-logo-text\"><span>Stirling PDF MCP</span></div>",
|
||||
"enabled": true,
|
||||
"sslRequired": "none",
|
||||
"registrationAllowed": false,
|
||||
"registrationEmailAsUsername": true,
|
||||
"rememberMe": true,
|
||||
"verifyEmail": false,
|
||||
"loginWithEmailAllowed": true,
|
||||
"duplicateEmailsAllowed": false,
|
||||
"resetPasswordAllowed": true,
|
||||
"editUsernameAllowed": false,
|
||||
"bruteForceProtected": false,
|
||||
"accessTokenLifespan": 300,
|
||||
"accessTokenLifespanForImplicitFlow": 900,
|
||||
"ssoSessionIdleTimeout": 1800,
|
||||
"ssoSessionMaxLifespan": 36000,
|
||||
"offlineSessionIdleTimeout": 2592000,
|
||||
"users": [
|
||||
{
|
||||
"username": "[email protected]",
|
||||
"email": "[email protected]",
|
||||
"emailVerified": true,
|
||||
"firstName": "MCP",
|
||||
"lastName": "TestUser",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "mcppassword",
|
||||
"temporary": false
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "[email protected]",
|
||||
"email": "[email protected]",
|
||||
"emailVerified": true,
|
||||
"firstName": "Ghost",
|
||||
"lastName": "NoAccount",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "ghostpassword",
|
||||
"temporary": false
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
}
|
||||
],
|
||||
"roles": {
|
||||
"realm": [
|
||||
{
|
||||
"name": "user",
|
||||
"description": "Regular user role",
|
||||
"composite": false,
|
||||
"clientRole": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"clients": [
|
||||
{
|
||||
"clientId": "mcp-test-client",
|
||||
"name": "Stirling PDF MCP Test Client (confidential)",
|
||||
"description": "Confidential client for scripted MCP testing: ROPC (direct grant) for the validation script and standard/PKCE flow for browser-based MCP clients.",
|
||||
"rootUrl": "http://localhost:8080",
|
||||
"baseUrl": "http://localhost:8080",
|
||||
"enabled": true,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"secret": "mcp-test-secret",
|
||||
"redirectUris": [
|
||||
"http://localhost:8080/*",
|
||||
"http://localhost:6274/*",
|
||||
"http://127.0.0.1:6274/*"
|
||||
],
|
||||
"webOrigins": ["*"],
|
||||
"bearerOnly": false,
|
||||
"consentRequired": false,
|
||||
"standardFlowEnabled": true,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": true,
|
||||
"serviceAccountsEnabled": false,
|
||||
"publicClient": false,
|
||||
"frontchannelLogout": false,
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"oidc.ciba.grant.enabled": "false",
|
||||
"backchannel.logout.session.required": "true",
|
||||
"oauth2.device.authorization.grant.enabled": "false",
|
||||
"display.on.consent.screen": "false",
|
||||
"backchannel.logout.revoke.offline.tokens": "false",
|
||||
"pkce.code.challenge.method": "S256"
|
||||
},
|
||||
"fullScopeAllowed": true,
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "email",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"userinfo.token.claim": "true",
|
||||
"user.attribute": "email",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "email",
|
||||
"jsonType.label": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "preferred_username",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"userinfo.token.claim": "true",
|
||||
"user.attribute": "username",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "preferred_username",
|
||||
"jsonType.label": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "mcp-audience",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.custom.audience": "http://localhost:8080/mcp",
|
||||
"id.token.claim": "false",
|
||||
"access.token.claim": "true"
|
||||
}
|
||||
}
|
||||
],
|
||||
"defaultClientScopes": [
|
||||
"web-origins",
|
||||
"acr",
|
||||
"profile",
|
||||
"roles",
|
||||
"email",
|
||||
"mcp.tools.read",
|
||||
"mcp.tools.write"
|
||||
],
|
||||
"optionalClientScopes": ["offline_access", "microprofile-jwt"]
|
||||
},
|
||||
{
|
||||
"clientId": "mcp-client",
|
||||
"name": "MCP public client (authorization-code + PKCE)",
|
||||
"description": "Generic public client for any browser-based MCP client. Authorization-code + PKCE, no secret. Broad localhost redirect URIs so any local MCP client callback port is accepted.",
|
||||
"rootUrl": "http://localhost",
|
||||
"baseUrl": "http://localhost",
|
||||
"enabled": true,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"redirectUris": [
|
||||
"http://localhost:*",
|
||||
"http://127.0.0.1:*",
|
||||
"https://localhost:*",
|
||||
"https://127.0.0.1:*"
|
||||
],
|
||||
"webOrigins": ["*"],
|
||||
"bearerOnly": false,
|
||||
"consentRequired": false,
|
||||
"standardFlowEnabled": true,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"serviceAccountsEnabled": false,
|
||||
"publicClient": true,
|
||||
"frontchannelLogout": false,
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"oidc.ciba.grant.enabled": "false",
|
||||
"oauth2.device.authorization.grant.enabled": "false",
|
||||
"display.on.consent.screen": "false",
|
||||
"pkce.code.challenge.method": "S256"
|
||||
},
|
||||
"fullScopeAllowed": true,
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "email",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"userinfo.token.claim": "true",
|
||||
"user.attribute": "email",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "email",
|
||||
"jsonType.label": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "preferred_username",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"userinfo.token.claim": "true",
|
||||
"user.attribute": "username",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "preferred_username",
|
||||
"jsonType.label": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "mcp-audience",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.custom.audience": "http://localhost:8080/mcp",
|
||||
"id.token.claim": "false",
|
||||
"access.token.claim": "true"
|
||||
}
|
||||
}
|
||||
],
|
||||
"defaultClientScopes": [
|
||||
"web-origins",
|
||||
"acr",
|
||||
"profile",
|
||||
"roles",
|
||||
"email",
|
||||
"mcp.tools.read",
|
||||
"mcp.tools.write"
|
||||
],
|
||||
"optionalClientScopes": ["offline_access", "microprofile-jwt"]
|
||||
},
|
||||
{
|
||||
"clientId": "other-client",
|
||||
"name": "Unrelated client (negative test)",
|
||||
"description": "Issues VALID tokens (good signature + issuer) that do NOT carry the MCP audience and have no mcp.tools.* scopes - used to prove the resource server rejects tokens minted for a different audience (RFC 8707).",
|
||||
"enabled": true,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"publicClient": true,
|
||||
"bearerOnly": false,
|
||||
"standardFlowEnabled": false,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": true,
|
||||
"serviceAccountsEnabled": false,
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"pkce.code.challenge.method": "S256"
|
||||
},
|
||||
"fullScopeAllowed": false,
|
||||
"defaultClientScopes": ["web-origins", "acr", "profile", "roles", "email"],
|
||||
"optionalClientScopes": ["offline_access", "microprofile-jwt"]
|
||||
}
|
||||
],
|
||||
"clientScopes": [
|
||||
{
|
||||
"name": "mcp.tools.read",
|
||||
"description": "MCP: read-only tool access (list / describe / inspect PDF operations)",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "mcp.tools.write",
|
||||
"description": "MCP: write tool access (run PDF operations that produce output)",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "mcp-audience",
|
||||
"description": "Injects the MCP resource-server audience (RFC 8707) into access tokens. A default scope so even dynamically-registered clients produce tokens Stirling accepts.",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "false",
|
||||
"display.on.consent.screen": "false"
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "mcp-audience",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.custom.audience": "http://localhost:8080/mcp",
|
||||
"id.token.claim": "false",
|
||||
"access.token.claim": "true"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "email",
|
||||
"description": "OpenID Connect built-in scope: email",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true"
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "email",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"userinfo.token.claim": "true",
|
||||
"user.attribute": "email",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "email",
|
||||
"jsonType.label": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "email verified",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"userinfo.token.claim": "true",
|
||||
"user.attribute": "emailVerified",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "email_verified",
|
||||
"jsonType.label": "boolean"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "profile",
|
||||
"description": "OpenID Connect built-in scope: profile",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true"
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "username",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"userinfo.token.claim": "true",
|
||||
"user.attribute": "username",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "preferred_username",
|
||||
"jsonType.label": "String"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "roles",
|
||||
"description": "OpenID Connect scope for user roles",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "false",
|
||||
"display.on.consent.screen": "true"
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "realm roles",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-realm-role-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"userinfo.token.claim": "true",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "roles",
|
||||
"jsonType.label": "String",
|
||||
"multivalued": "true"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"defaultDefaultClientScopes": [
|
||||
"profile",
|
||||
"email",
|
||||
"roles",
|
||||
"web-origins",
|
||||
"acr",
|
||||
"mcp.tools.read",
|
||||
"mcp.tools.write",
|
||||
"mcp-audience"
|
||||
],
|
||||
"defaultOptionalClientScopes": ["offline_access", "microprofile-jwt"],
|
||||
"browserSecurityHeaders": {
|
||||
"contentSecurityPolicyReportOnly": "",
|
||||
"xContentTypeOptions": "nosniff",
|
||||
"referrerPolicy": "no-referrer",
|
||||
"xRobotsTag": "none",
|
||||
"xFrameOptions": "SAMEORIGIN",
|
||||
"contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';",
|
||||
"xXSSProtection": "1; mode=block",
|
||||
"strictTransportSecurity": "max-age=31536000; includeSubDomains"
|
||||
},
|
||||
"eventsEnabled": false,
|
||||
"eventsListeners": ["jboss-logging"],
|
||||
"enabledEventTypes": [],
|
||||
"adminEventsEnabled": false,
|
||||
"adminEventsDetailsEnabled": false,
|
||||
"internationalizationEnabled": false,
|
||||
"supportedLocales": [],
|
||||
"components": {
|
||||
"org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [
|
||||
{
|
||||
"name": "Trusted Hosts",
|
||||
"providerId": "trusted-hosts",
|
||||
"subType": "anonymous",
|
||||
"config": {
|
||||
"trusted-hosts": ["localhost", "127.0.0.1"],
|
||||
"host-sending-registration-request-must-match": ["false"],
|
||||
"client-uris-must-match": ["true"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Allowed Client Scopes",
|
||||
"providerId": "allowed-client-templates",
|
||||
"subType": "anonymous",
|
||||
"config": {
|
||||
"allow-default-scopes": ["true"]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"keycloakVersion": "24.0.0"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
package-lock.json
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Real MCP client validation via the official @modelcontextprotocol/sdk over streamable HTTP.
|
||||
*
|
||||
* Auth header (non-interactive):
|
||||
* MCP_BEARER=<jwt> -> Authorization: Bearer <jwt> (oauth)
|
||||
* MCP_APIKEY=<key> -> X-API-KEY: <key> (apikey)
|
||||
* Env: MCP_URL (default http://localhost:8080/mcp), MODE (output label). Exit 0 = passed.
|
||||
*/
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
|
||||
const url = process.env.MCP_URL || "http://localhost:8080/mcp";
|
||||
const mode = process.env.MODE || "oauth";
|
||||
const bearer = process.env.MCP_BEARER;
|
||||
const apikey = process.env.MCP_APIKEY;
|
||||
|
||||
const headers = {};
|
||||
if (bearer) headers["Authorization"] = `Bearer ${bearer}`;
|
||||
if (apikey) headers["X-API-KEY"] = apikey;
|
||||
|
||||
function fail(msg) {
|
||||
console.error(`[${mode}] FAIL: ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const REQUIRED = [
|
||||
"stirling_describe_operation",
|
||||
"stirling_convert",
|
||||
"stirling_pages",
|
||||
"stirling_misc",
|
||||
"stirling_security",
|
||||
"stirling_upload",
|
||||
"stirling_download",
|
||||
];
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(url), {
|
||||
requestInit: { headers },
|
||||
});
|
||||
const client = new Client(
|
||||
{ name: "stirling-mcp-validator", version: "1.0.0" },
|
||||
{ capabilities: {} },
|
||||
);
|
||||
|
||||
try {
|
||||
// connect() performs the initialize handshake (version negotiation + capabilities)
|
||||
await client.connect(transport);
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
const names = tools.map((t) => t.name).sort();
|
||||
for (const r of REQUIRED) {
|
||||
if (!names.includes(r)) fail(`tools/list missing ${r} (got: ${names.join(", ")})`);
|
||||
}
|
||||
|
||||
const res = await client.callTool({
|
||||
name: "stirling_describe_operation",
|
||||
arguments: { operation: "add-password" },
|
||||
});
|
||||
if (!JSON.stringify(res).includes("parametersSchema")) {
|
||||
fail(`describe_operation returned no parametersSchema: ${JSON.stringify(res).slice(0, 300)}`);
|
||||
}
|
||||
|
||||
// File I/O round-trip: upload bytes, then download them back unchanged.
|
||||
const original = "hello mcp round-trip";
|
||||
const up = await client.callTool({
|
||||
name: "stirling_upload",
|
||||
arguments: { file: Buffer.from(original, "utf8").toString("base64"), fileName: "hello.txt" },
|
||||
});
|
||||
const upMatch = JSON.stringify(up).match(/fileId=([A-Za-z0-9_-]+)/);
|
||||
if (!upMatch) fail(`stirling_upload returned no fileId: ${JSON.stringify(up).slice(0, 200)}`);
|
||||
const fileId = upMatch[1];
|
||||
|
||||
const down = await client.callTool({ name: "stirling_download", arguments: { fileId } });
|
||||
const resBlock = (down.content || []).find((b) => b.type === "resource");
|
||||
if (!resBlock?.resource?.blob) {
|
||||
fail(`stirling_download returned no resource blob: ${JSON.stringify(down).slice(0, 200)}`);
|
||||
}
|
||||
const roundTripped = Buffer.from(resBlock.resource.blob, "base64").toString("utf8");
|
||||
if (roundTripped !== original) {
|
||||
fail(`upload/download round-trip mismatch: got "${roundTripped}"`);
|
||||
}
|
||||
|
||||
// A category tool with no file must surface an honest error, not a fake success.
|
||||
const cat = await client.callTool({
|
||||
name: "stirling_security",
|
||||
arguments: { operation: "add-password" },
|
||||
});
|
||||
if (cat.isError !== true) {
|
||||
fail(`stirling_security with no file should report isError, got: ${JSON.stringify(cat).slice(0, 200)}`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[${mode}] OK: real MCP SDK client connected, negotiated protocol, listed ${names.length} tools, ` +
|
||||
`describe_operation returned a schema, upload/download round-trip matched, category tool returned isError.`,
|
||||
);
|
||||
console.log(`[${mode}] tools: ${names.join(", ")}`);
|
||||
await client.close();
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
fail(`${e?.stack || e?.message || e}`);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "stirling-mcp-client-check",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"description": "Real MCP client (official @modelcontextprotocol/sdk) used to validate the Stirling MCP server end-to-end over streamable HTTP.",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.12.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Manual-testing panel: generic "connect your MCP client" settings.
|
||||
print_manual_panel() {
|
||||
echo ""
|
||||
echo -e "${BLUE}╔════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}║ MCP MANUAL TESTING - connect a client ║${NC}"
|
||||
echo -e "${BLUE}╚════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo -e "${GREEN}Add an MCP server in your client with these settings:${NC}"
|
||||
echo ""
|
||||
echo -e " Server URL ${YELLOW}http://localhost:8080/mcp${NC}"
|
||||
echo -e " Connection type ${YELLOW}HTTP${NC} (streamable HTTP)"
|
||||
echo -e " Authentication ${YELLOW}OAuth 2.0${NC}"
|
||||
echo -e " OAuth scopes ${YELLOW}mcp.tools.read mcp.tools.write${NC}"
|
||||
echo -e " OAuth client id ${YELLOW}mcp-client${NC} (public - leave the client secret blank)"
|
||||
echo ""
|
||||
echo -e " ${BLUE}The client redirects you to a sign-in page; log in with:${NC}"
|
||||
echo -e " ${YELLOW}[email protected]${NC} / ${YELLOW}mcppassword${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}Stop:${NC} docker-compose -f docker-compose-keycloak-mcp.yml down -v"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Manual API-key panel: mint a Stirling per-user key and print header-based client settings.
|
||||
print_manual_panel_apikey() {
|
||||
local jwt key
|
||||
jwt=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"[email protected]","password":"mcppassword"}' \
|
||||
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
|
||||
# Reuse the user's existing key if any; else create one.
|
||||
key=$(curl -s -X POST http://localhost:8080/api/v1/user/get-api-key \
|
||||
-H "Authorization: Bearer $jwt" \
|
||||
| sed -n 's/.*"apiKey":"\([^"]*\)".*/\1/p')
|
||||
if [ -z "$key" ]; then
|
||||
key=$(curl -s -X POST http://localhost:8080/api/v1/user/update-api-key \
|
||||
-H "Authorization: Bearer $jwt" \
|
||||
| sed -n 's/.*"apiKey":"\([^"]*\)".*/\1/p')
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}╔════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}║ MCP MANUAL TESTING - API KEY (no OAuth / no IdP) ║${NC}"
|
||||
echo -e "${BLUE}╚════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo -e "${GREEN}Add an MCP server in your client with these settings:${NC}"
|
||||
echo ""
|
||||
echo -e " Server URL ${YELLOW}http://localhost:8080/mcp${NC}"
|
||||
echo -e " Connection type ${YELLOW}HTTP${NC} (streamable HTTP)"
|
||||
echo -e " Authentication ${YELLOW}None${NC} (no OAuth - just a header)"
|
||||
if [ -n "$key" ]; then
|
||||
echo -e " Custom header ${YELLOW}X-API-KEY: $key${NC}"
|
||||
else
|
||||
echo -e " ${RED}(could not mint a key automatically; is the stack up in apikey mode?)${NC}"
|
||||
fi
|
||||
echo ""
|
||||
echo -e " ${BLUE}No browser redirect, no IdP. The key maps to ${YELLOW}[email protected]${BLUE} and${NC}"
|
||||
echo -e " ${BLUE}every call is audited as that user. Use this when a client's OAuth can't reach localhost.${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}Stop:${NC} docker-compose -f docker-compose-keycloak-mcp.yml down -v"
|
||||
echo ""
|
||||
}
|
||||
|
||||
echo -e "${BLUE}╔════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}║ Stirling PDF + Keycloak MCP Test Environment ║${NC}"
|
||||
echo -e "${BLUE}╚════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
COMPOSE_UP_ARGS=(-d --build)
|
||||
RUN_VALIDATE=false
|
||||
MANUAL=false
|
||||
APIKEY_MODE=false
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--nobuild)
|
||||
COMPOSE_UP_ARGS=(-d)
|
||||
shift
|
||||
;;
|
||||
--validate)
|
||||
RUN_VALIDATE=true
|
||||
shift
|
||||
;;
|
||||
--manual)
|
||||
MANUAL=true
|
||||
shift
|
||||
;;
|
||||
--apikey)
|
||||
# API-key manual testing mode (no OAuth/IdP), with a freshly minted key.
|
||||
APIKEY_MODE=true
|
||||
MANUAL=true
|
||||
export MCP_AUTH_MODE=apikey
|
||||
shift
|
||||
;;
|
||||
--license-key)
|
||||
if [[ -z "${2:-}" ]]; then
|
||||
echo -e "${RED}Missing value for --license-key${NC}"
|
||||
exit 1
|
||||
fi
|
||||
export PREMIUM_KEY="$2"
|
||||
shift 2
|
||||
;;
|
||||
--license-key=*)
|
||||
export PREMIUM_KEY="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
-k)
|
||||
if [[ -z "${2:-}" ]]; then
|
||||
echo -e "${RED}Missing value for -k${NC}"
|
||||
exit 1
|
||||
fi
|
||||
export PREMIUM_KEY="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: $0 [--nobuild] [--validate] [--manual] [--apikey] [--license-key <KEY>]"
|
||||
echo ""
|
||||
echo " --nobuild Skip building images (use existing images)"
|
||||
echo " --validate Run validate-mcp-test.sh after the stack is up"
|
||||
echo " --manual Manual testing mode (OAuth): bring the stack up and print"
|
||||
echo " copy-paste client settings for the OAuth flow."
|
||||
echo " --apikey Manual testing mode (API key): bring Stirling up in apikey"
|
||||
echo " mode (no OAuth/IdP), mint a key, and print client settings."
|
||||
echo " Ideal for clients whose OAuth can't reach localhost."
|
||||
echo " --license-key <KEY> Premium license key (skips the interactive prompt)"
|
||||
echo " Equivalent to setting PREMIUM_KEY in the environment."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Unknown option: $1${NC}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ! docker info > /dev/null 2>&1; then
|
||||
echo -e "${RED}✗ Docker is not running${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Keycloak issuer hostname (must resolve on host + containers)
|
||||
KEYCLOAK_HOST="${KEYCLOAK_HOST:-kubernetes.docker.internal}"
|
||||
export KEYCLOAK_HOST
|
||||
|
||||
# Preflight: the host must resolve the issuer hostname.
|
||||
if [ "${SKIP_MCP_PREFLIGHT:-false}" != "true" ]; then
|
||||
if ! curl -sf --connect-timeout 2 --max-time 3 "http://${KEYCLOAK_HOST}:9080/realms/stirling-mcp" >/dev/null 2>&1; then
|
||||
echo -e "${YELLOW}⚠ Cannot reach http://${KEYCLOAK_HOST}:9080 from this machine yet.${NC}"
|
||||
echo -e "${YELLOW} That is expected before the stack is up. If validation later fails to${NC}"
|
||||
echo -e "${YELLOW} resolve the host, add a hosts entry pointing ${KEYCLOAK_HOST} to 127.0.0.1:${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}Windows:${NC} C:\\Windows\\System32\\drivers\\etc\\hosts"
|
||||
echo -e "${BLUE}macOS/Linux:${NC} /etc/hosts"
|
||||
echo ""
|
||||
echo -e "${GREEN}127.0.0.1 ${KEYCLOAK_HOST}${NC}"
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
||||
# Prompt for license key (optional).
|
||||
if [ -z "$PREMIUM_KEY" ]; then
|
||||
echo -e "${YELLOW}Enter license key (press Enter to use default test key):${NC}"
|
||||
read -r LICENSE_INPUT
|
||||
if [ -n "$LICENSE_INPUT" ]; then
|
||||
export PREMIUM_KEY="$LICENSE_INPUT"
|
||||
echo -e "${GREEN}✓ Using provided license key${NC}"
|
||||
else
|
||||
echo -e "${BLUE}Using default test license key${NC}"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}▶ Starting Keycloak (MCP) containers...${NC}"
|
||||
docker-compose -f docker-compose-keycloak-mcp.yml up "${COMPOSE_UP_ARGS[@]}" keycloak-mcp-db keycloak-mcp
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}▶ Waiting for Keycloak (MCP)...${NC}"
|
||||
MAX_WAIT=180
|
||||
WAITED=0
|
||||
while [ $WAITED -lt $MAX_WAIT ]; do
|
||||
if curl -sf http://localhost:9080/realms/stirling-mcp > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✓ Keycloak is ready${NC}"
|
||||
break
|
||||
fi
|
||||
echo -n "."
|
||||
sleep 2
|
||||
WAITED=$((WAITED + 2))
|
||||
done
|
||||
|
||||
if [ $WAITED -ge $MAX_WAIT ]; then
|
||||
echo -e "${RED}✗ Keycloak failed to start${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}▶ Starting Stirling PDF (MCP resource server)...${NC}"
|
||||
docker-compose -f docker-compose-keycloak-mcp.yml up "${COMPOSE_UP_ARGS[@]}" stirling-pdf-mcp
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}▶ Waiting for Stirling PDF...${NC}"
|
||||
WAITED=0
|
||||
while [ $WAITED -lt $MAX_WAIT ]; do
|
||||
if curl -sf http://localhost:8080/api/v1/info/status 2>/dev/null | grep -q "UP"; then
|
||||
echo -e "${GREEN}✓ Stirling PDF is ready${NC}"
|
||||
break
|
||||
fi
|
||||
echo -n "."
|
||||
sleep 2
|
||||
WAITED=$((WAITED + 2))
|
||||
done
|
||||
|
||||
if [ $WAITED -ge $MAX_WAIT ]; then
|
||||
echo -e "${RED}✗ Stirling PDF failed to start${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}╔════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ MCP Test Environment Ready! ✓ ║${NC}"
|
||||
echo -e "${GREEN}╚════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}🔑 Auth mode:${NC} ${GREEN}${MCP_AUTH_MODE:-oauth}${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}📍 Services:${NC}"
|
||||
echo -e " Stirling PDF: ${GREEN}http://localhost:8080${NC}"
|
||||
echo -e " MCP endpoint: ${GREEN}http://localhost:8080/mcp${NC}"
|
||||
if [ "$APIKEY_MODE" != true ]; then
|
||||
echo -e " PRM metadata: ${GREEN}http://localhost:8080/.well-known/oauth-protected-resource${NC}"
|
||||
fi
|
||||
echo -e " Keycloak Admin: ${GREEN}http://${KEYCLOAK_HOST}:9080/admin${NC} (admin / admin)"
|
||||
echo ""
|
||||
echo -e "${BLUE}👤 Test user (exists in Keycloak AND as a Stirling account):${NC}"
|
||||
echo -e " Email: ${GREEN}[email protected]${NC}"
|
||||
echo -e " Password: ${GREEN}mcppassword${NC}"
|
||||
echo ""
|
||||
if [ "$APIKEY_MODE" != true ]; then
|
||||
echo -e "${BLUE}👻 Negative-test user (valid Keycloak login, NO Stirling account):${NC}"
|
||||
echo -e " Email: ${GREEN}[email protected]${NC}"
|
||||
echo -e " Password: ${GREEN}ghostpassword${NC} (expect HTTP 403 at /mcp)"
|
||||
echo ""
|
||||
echo -e "${BLUE}🔐 OAuth clients:${NC}"
|
||||
echo -e " public (for MCP clients): ${GREEN}mcp-client${NC} (authcode + PKCE, no secret)"
|
||||
echo -e " confidential (scripts): ${GREEN}mcp-test-client${NC} / ${GREEN}mcp-test-secret${NC}"
|
||||
echo ""
|
||||
fi
|
||||
if [ "$APIKEY_MODE" = true ]; then
|
||||
echo -e "${BLUE}🧪 Automated validation (apikey):${NC}"
|
||||
echo -e " bash testing/compose/validate-mcp-apikey.sh"
|
||||
else
|
||||
echo -e "${BLUE}🧪 Automated validation (no token 401, valid token 200, ghost 403):${NC}"
|
||||
echo -e " bash testing/compose/validate-mcp-test.sh"
|
||||
fi
|
||||
echo ""
|
||||
echo -e "${BLUE}🔌 Connect an MCP client: re-run with ${GREEN}--manual${NC} (OAuth) or ${GREEN}--apikey${NC} (header).${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}📊 View logs:${NC}"
|
||||
echo -e " docker-compose -f docker-compose-keycloak-mcp.yml logs -f"
|
||||
echo ""
|
||||
echo -e "${BLUE}⏹ Stop:${NC}"
|
||||
echo -e " docker-compose -f docker-compose-keycloak-mcp.yml down -v"
|
||||
echo ""
|
||||
|
||||
if [ "$RUN_VALIDATE" = true ]; then
|
||||
echo -e "${YELLOW}▶ Running validation...${NC}"
|
||||
echo ""
|
||||
if [ "$APIKEY_MODE" = true ]; then
|
||||
bash "$SCRIPT_DIR/validate-mcp-apikey.sh"
|
||||
else
|
||||
bash "$SCRIPT_DIR/validate-mcp-test.sh"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$APIKEY_MODE" = true ]; then
|
||||
print_manual_panel_apikey
|
||||
elif [ "$MANUAL" = true ]; then
|
||||
print_manual_panel
|
||||
else
|
||||
echo -e "${BLUE}💡 Tip:${NC} re-run with ${GREEN}--manual${NC} (OAuth) or ${GREEN}--apikey${NC} (API-key header) for client setup."
|
||||
echo ""
|
||||
fi
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/bin/bash
|
||||
# Validate the MCP server in apikey auth mode (curl + real MCP SDK client), then restore oauth.
|
||||
# Runs every check and exits non-zero on any failure.
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
COMPOSE="$SCRIPT_DIR/docker-compose-keycloak-mcp.yml"
|
||||
CHECK_DIR="$SCRIPT_DIR/mcp-client-check"
|
||||
MCP_URL="http://localhost:8080/mcp"
|
||||
RPC_LIST='{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
|
||||
|
||||
PASS=0; FAIL=0
|
||||
pass() { echo -e "${GREEN}✓${NC} $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo -e "${RED}✗ $1${NC}"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
# Wait on the container healthcheck. Returns 0 once healthy, 1 after the timeout.
|
||||
wait_up() {
|
||||
local w=0
|
||||
while [ $w -lt 360 ]; do
|
||||
if [ "$(docker inspect -f '{{.State.Health.Status}}' stirling-pdf-mcp-test 2>/dev/null)" = "healthy" ]; then
|
||||
echo ""
|
||||
return 0
|
||||
fi
|
||||
sleep 5; w=$((w + 5)); echo -n "."
|
||||
done
|
||||
echo ""
|
||||
return 1
|
||||
}
|
||||
|
||||
echo -e "${BLUE}╔════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}║ Validating MCP - API-KEY mode (+ real client) ║${NC}"
|
||||
echo -e "${BLUE}╚════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
echo -e "${YELLOW}▶ Recreating Stirling in apikey mode...${NC}"
|
||||
MCP_AUTH_MODE=apikey PREMIUM_KEY="${PREMIUM_KEY:-}" \
|
||||
docker compose -f "$COMPOSE" up -d --no-build --force-recreate stirling-pdf-mcp >/dev/null 2>&1
|
||||
if ! wait_up; then
|
||||
fail "Stirling did not become healthy in apikey mode"
|
||||
echo -e "${RED}Aborting.${NC}"; exit 1
|
||||
fi
|
||||
# In apikey mode the OAuth metadata is not served (404, or 401 from the app chain).
|
||||
META_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/.well-known/oauth-protected-resource)
|
||||
if [ "$META_CODE" = "404" ] || [ "$META_CODE" = "401" ]; then
|
||||
pass "apikey mode active (OAuth metadata not served -> $META_CODE)"
|
||||
else
|
||||
fail "expected OAuth metadata absent in apikey mode, got $META_CODE"
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}▶ Minting a Stirling API key for mcpuser...${NC}"
|
||||
JWT=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"[email protected]","password":"mcppassword"}' \
|
||||
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
|
||||
if [ -n "$JWT" ]; then
|
||||
pass "logged in as mcpuser (got a session token)"
|
||||
else
|
||||
fail "could not log in as mcpuser - cannot mint an API key"
|
||||
fi
|
||||
APIKEY=$(curl -s -X POST http://localhost:8080/api/v1/user/get-api-key \
|
||||
-H "Authorization: Bearer $JWT" | sed -n 's/.*"apiKey":"\([^"]*\)".*/\1/p')
|
||||
if [ -z "$APIKEY" ]; then
|
||||
APIKEY=$(curl -s -X POST http://localhost:8080/api/v1/user/update-api-key \
|
||||
-H "Authorization: Bearer $JWT" | sed -n 's/.*"apiKey":"\([^"]*\)".*/\1/p')
|
||||
fi
|
||||
if [ -n "$APIKEY" ]; then
|
||||
pass "minted an API key for mcpuser"
|
||||
else
|
||||
fail "could not mint an API key"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo -e "${YELLOW}[1] curl checks (apikey)${NC}"
|
||||
NO_KEY=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" -H "Content-Type: application/json" -d "$RPC_LIST")
|
||||
[ "$NO_KEY" = "401" ] && pass "no key -> 401" || fail "no key -> $NO_KEY (expected 401)"
|
||||
BAD_KEY=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" -H "Content-Type: application/json" -H "X-API-KEY: not-a-real-key" -d "$RPC_LIST")
|
||||
[ "$BAD_KEY" = "401" ] && pass "bad key -> 401" || fail "bad key -> $BAD_KEY (expected 401)"
|
||||
if [ -n "$APIKEY" ]; then
|
||||
OK_CODE=$(curl -s -o /tmp/mcp_apikey_list.json -w "%{http_code}" -X POST "$MCP_URL" -H "Content-Type: application/json" -H "X-API-KEY: $APIKEY" -d "$RPC_LIST")
|
||||
if [ "$OK_CODE" = "200" ] && grep -q "stirling_describe_operation" /tmp/mcp_apikey_list.json; then
|
||||
pass "valid X-API-KEY -> 200 + tools listed"
|
||||
else
|
||||
fail "valid X-API-KEY -> $OK_CODE (tools listed? check body)"
|
||||
fi
|
||||
rm -f /tmp/mcp_apikey_list.json
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo -e "${YELLOW}[2] Real MCP SDK client (apikey / X-API-KEY)${NC}"
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo -e " ${YELLOW}(node not installed - skipping real-client check)${NC}"
|
||||
elif [ -z "$APIKEY" ]; then
|
||||
fail "skipping real-client check - no API key"
|
||||
else
|
||||
[ -d "$CHECK_DIR/node_modules" ] || (cd "$CHECK_DIR" && npm install --silent --no-fund --no-audit >/dev/null 2>&1)
|
||||
if MODE=apikey MCP_URL="$MCP_URL" MCP_APIKEY="$APIKEY" node "$CHECK_DIR/check.mjs"; then
|
||||
pass "official MCP SDK client connected + validated (apikey)"
|
||||
else
|
||||
fail "official MCP SDK client could not validate the server (apikey)"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo -e "${YELLOW}[3] Real operation execution (rotate-pdf, inline file)${NC}"
|
||||
SAMPLE_PDF="$SCRIPT_DIR/../../app/common/src/test/resources/example.pdf"
|
||||
if [ -z "$APIKEY" ]; then
|
||||
fail "skipping execution check - no API key"
|
||||
elif [ ! -f "$SAMPLE_PDF" ]; then
|
||||
fail "sample PDF not found at $SAMPLE_PDF"
|
||||
else
|
||||
B64=$(base64 -w0 "$SAMPLE_PDF" 2>/dev/null || base64 "$SAMPLE_PDF" | tr -d '\n')
|
||||
EXEC_RPC=$(printf '{"jsonrpc":"2.0","id":91,"method":"tools/call","params":{"name":"stirling_pages","arguments":{"operation":"rotate-pdf","fileName":"example.pdf","parameters":{"angle":90},"file":"%s"}}}' "$B64")
|
||||
EXEC=$(curl -s -X POST "$MCP_URL" -H "Content-Type: application/json" -H "X-API-KEY: $APIKEY" -d "$EXEC_RPC")
|
||||
if echo "$EXEC" | grep -q '"isError":true'; then
|
||||
fail "rotate-pdf returned isError: $(printf '%s' "$EXEC" | head -c 300)"
|
||||
elif echo "$EXEC" | grep -q 'fileId='; then
|
||||
pass "rotate-pdf processed a real PDF over MCP and returned a result fileId"
|
||||
else
|
||||
fail "rotate-pdf returned no result fileId: $(printf '%s' "$EXEC" | head -c 300)"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo -e "${YELLOW}▶ Restoring oauth mode...${NC}"
|
||||
PREMIUM_KEY="${PREMIUM_KEY:-}" docker compose -f "$COMPOSE" up -d --no-build --force-recreate stirling-pdf-mcp >/dev/null 2>&1
|
||||
wait_up && pass "restored oauth mode" || fail "Stirling not healthy after restoring oauth"
|
||||
echo ""
|
||||
|
||||
echo -e "${BLUE}────────────────────────────────────────────────────${NC}"
|
||||
if [ "$FAIL" -eq 0 ]; then
|
||||
echo -e "${GREEN}API-key mode checks passed! ($PASS passed)${NC}"; exit 0
|
||||
else
|
||||
echo -e "${RED}API-key mode validation failed: $FAIL failed, $PASS passed.${NC}"; exit 1
|
||||
fi
|
||||
@@ -0,0 +1,397 @@
|
||||
#!/bin/bash
|
||||
# Validate the Keycloak MCP OAuth2 resource-server path end-to-end against a real Keycloak.
|
||||
# Runs every check (no set -e) and exits non-zero if anything failed.
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
KEYCLOAK_HOST="${KEYCLOAK_HOST:-kubernetes.docker.internal}"
|
||||
REALM="stirling-mcp"
|
||||
CLIENT_ID="mcp-test-client"
|
||||
CLIENT_SECRET="mcp-test-secret"
|
||||
MCP_URL="http://localhost:8080/mcp"
|
||||
PRM_URL="http://localhost:8080/.well-known/oauth-protected-resource"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
pass() { echo -e "${GREEN}✓${NC} $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo -e "${RED}✗ $1${NC}"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
echo -e "${BLUE}╔════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}║ Validating MCP Test Environment ║${NC}"
|
||||
echo -e "${BLUE}╚════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
# Pick a Keycloak base URL that resolves from this host (both share KC_HOSTNAME issuer).
|
||||
KC_BASE="http://${KEYCLOAK_HOST}:9080"
|
||||
if ! curl -sf --max-time 3 "$KC_BASE/realms/$REALM" >/dev/null 2>&1; then
|
||||
KC_BASE="http://localhost:9080"
|
||||
fi
|
||||
TOKEN_URL="$KC_BASE/realms/$REALM/protocol/openid-connect/token"
|
||||
echo -e "${BLUE}Using Keycloak base:${NC} $KC_BASE"
|
||||
echo ""
|
||||
|
||||
# liveness
|
||||
echo -e "${YELLOW}[0] Liveness${NC}"
|
||||
if curl -sf "$KC_BASE/realms/$REALM" > /dev/null 2>&1; then
|
||||
pass "Keycloak realm '$REALM' reachable"
|
||||
else
|
||||
fail "Keycloak realm '$REALM' not reachable - is the stack up?"
|
||||
fi
|
||||
if curl -sf "$KC_BASE/realms/$REALM/.well-known/openid-configuration" > /dev/null 2>&1; then
|
||||
pass "Keycloak OIDC discovery reachable"
|
||||
else
|
||||
fail "Keycloak OIDC discovery not reachable"
|
||||
fi
|
||||
if curl -sf http://localhost:8080/api/v1/info/status 2>/dev/null | grep -q "UP"; then
|
||||
pass "Stirling PDF is UP"
|
||||
else
|
||||
fail "Stirling PDF is not UP"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# protected-resource metadata (RFC 9728)
|
||||
echo -e "${YELLOW}[1] Protected-resource metadata (RFC 9728)${NC}"
|
||||
PRM=$(curl -s -o /tmp/mcp_prm.json -w "%{http_code}" "$PRM_URL")
|
||||
PRM_BODY=$(cat /tmp/mcp_prm.json 2>/dev/null)
|
||||
if [ "$PRM" = "200" ]; then
|
||||
pass "GET $PRM_URL -> 200 (publicly discoverable)"
|
||||
else
|
||||
fail "GET $PRM_URL -> $PRM (expected 200)"
|
||||
fi
|
||||
if echo "$PRM_BODY" | grep -q '"resource"'; then
|
||||
pass "metadata advertises a 'resource' id"
|
||||
else
|
||||
fail "metadata missing 'resource' id"
|
||||
fi
|
||||
if echo "$PRM_BODY" | grep -q "realms/$REALM"; then
|
||||
pass "metadata points clients at the Keycloak authorization server"
|
||||
else
|
||||
fail "metadata missing the Keycloak authorization server (realms/$REALM)"
|
||||
fi
|
||||
if echo "$PRM_BODY" | grep -q "mcp.tools"; then
|
||||
pass "metadata advertises mcp.tools scopes"
|
||||
else
|
||||
fail "metadata missing mcp.tools scopes"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# unauthenticated access is rejected
|
||||
echo -e "${YELLOW}[2] Unauthenticated requests are rejected${NC}"
|
||||
RPC_LIST='{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
|
||||
NO_TOKEN=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" \
|
||||
-H "Content-Type: application/json" -d "$RPC_LIST")
|
||||
if [ "$NO_TOKEN" = "401" ]; then
|
||||
pass "POST /mcp with no token -> 401"
|
||||
else
|
||||
fail "POST /mcp with no token -> $NO_TOKEN (expected 401)"
|
||||
fi
|
||||
BAD_TOKEN=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer not-a-real-jwt" -d "$RPC_LIST")
|
||||
if [ "$BAD_TOKEN" = "401" ]; then
|
||||
pass "POST /mcp with garbage token -> 401"
|
||||
else
|
||||
fail "POST /mcp with garbage token -> $BAD_TOKEN (expected 401)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# fetch a real user token from Keycloak (password grant)
|
||||
echo -e "${YELLOW}[3] Fetch a real access token from Keycloak (mcpuser)${NC}"
|
||||
get_token() {
|
||||
# $1 username, $2 password -> access_token (empty on failure)
|
||||
curl -s -X POST "$TOKEN_URL" \
|
||||
--data-urlencode "grant_type=password" \
|
||||
--data-urlencode "client_id=$CLIENT_ID" \
|
||||
--data-urlencode "client_secret=$CLIENT_SECRET" \
|
||||
--data-urlencode "username=$1" \
|
||||
--data-urlencode "password=$2" \
|
||||
--data-urlencode "scope=openid email mcp.tools.read mcp.tools.write" \
|
||||
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p'
|
||||
}
|
||||
USER_TOKEN=$(get_token "[email protected]" "mcppassword")
|
||||
if [ -n "$USER_TOKEN" ]; then
|
||||
pass "Obtained access token for [email protected]"
|
||||
else
|
||||
fail "Could not obtain access token for [email protected] (check Keycloak/client)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# authenticated tools/list (account-binding succeeds)
|
||||
echo -e "${YELLOW}[4] Authenticated MCP access (provisioned user)${NC}"
|
||||
if [ -n "$USER_TOKEN" ]; then
|
||||
LIST_CODE=$(curl -s -o /tmp/mcp_list.json -w "%{http_code}" -X POST "$MCP_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $USER_TOKEN" -d "$RPC_LIST")
|
||||
LIST_BODY=$(cat /tmp/mcp_list.json 2>/dev/null)
|
||||
if [ "$LIST_CODE" = "200" ]; then
|
||||
pass "POST /mcp tools/list with valid token -> 200"
|
||||
else
|
||||
fail "POST /mcp tools/list with valid token -> $LIST_CODE (expected 200)"
|
||||
fi
|
||||
for t in stirling_describe_operation stirling_convert stirling_pages stirling_misc stirling_security; do
|
||||
if echo "$LIST_BODY" | grep -q "\"$t\""; then
|
||||
pass "tools/list advertises $t"
|
||||
else
|
||||
fail "tools/list missing $t"
|
||||
fi
|
||||
done
|
||||
else
|
||||
fail "Skipping authenticated checks - no user token"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# tools/call stirling_describe_operation
|
||||
echo -e "${YELLOW}[5] Deep schema via stirling_describe_operation${NC}"
|
||||
if [ -n "$USER_TOKEN" ]; then
|
||||
OP=$(echo "$LIST_BODY" | grep -oE '"enum":\[[^]]+\]' | head -1 | grep -oE '"[^"]+"' | sed -n '2p' | tr -d '"')
|
||||
if [ -n "$OP" ]; then
|
||||
echo -e " ${BLUE}using operation:${NC} $OP"
|
||||
DESC_RPC="{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"stirling_describe_operation\",\"arguments\":{\"operation\":\"$OP\"}}}"
|
||||
DESC_CODE=$(curl -s -o /tmp/mcp_desc.json -w "%{http_code}" -X POST "$MCP_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $USER_TOKEN" -d "$DESC_RPC")
|
||||
DESC_BODY=$(cat /tmp/mcp_desc.json 2>/dev/null)
|
||||
if [ "$DESC_CODE" = "200" ] && echo "$DESC_BODY" | grep -q "parametersSchema"; then
|
||||
pass "describe_operation('$OP') returned a parameters schema"
|
||||
else
|
||||
fail "describe_operation('$OP') -> $DESC_CODE (no parametersSchema in body)"
|
||||
fi
|
||||
else
|
||||
fail "Could not extract an operation id from tools/list (no enabled ops?)"
|
||||
fi
|
||||
else
|
||||
fail "Skipping describe check - no user token"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# account-binding: valid Keycloak user with no Stirling account -> 403
|
||||
echo -e "${YELLOW}[6] Account-binding rejects users without a Stirling account${NC}"
|
||||
GHOST_TOKEN=$(get_token "[email protected]" "ghostpassword")
|
||||
if [ -n "$GHOST_TOKEN" ]; then
|
||||
pass "Obtained a valid Keycloak token for [email protected]"
|
||||
GHOST_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $GHOST_TOKEN" -d "$RPC_LIST")
|
||||
if [ "$GHOST_CODE" = "403" ]; then
|
||||
pass "Valid token, no Stirling account -> 403 (account-binding enforced)"
|
||||
else
|
||||
fail "ghost user -> $GHOST_CODE (expected 403)"
|
||||
fi
|
||||
else
|
||||
fail "Could not obtain a token for [email protected] (cannot test account-binding)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# hardening: bad tokens & endpoint isolation
|
||||
echo -e "${YELLOW}[7] Hardening - bad tokens & endpoint isolation${NC}"
|
||||
|
||||
b64url() { printf '%s' "$1" | base64 | tr '+/' '-_' | tr -d '='; }
|
||||
|
||||
# unsigned alg:none token must be rejected
|
||||
NONE_JWT="$(b64url '{"alg":"none","typ":"JWT"}').$(b64url '{"sub":"mcpuser","iss":"http://kubernetes.docker.internal:9080/realms/stirling-mcp","aud":"http://localhost:8080/mcp","email":"[email protected]","scope":"mcp.tools.read mcp.tools.write","exp":9999999999}')."
|
||||
NONE_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" \
|
||||
-H "Content-Type: application/json" -H "Authorization: Bearer $NONE_JWT" -d "$RPC_LIST")
|
||||
if [ "$NONE_CODE" = "401" ] || [ "$NONE_CODE" = "400" ]; then
|
||||
pass "Unsigned alg:none token -> $NONE_CODE (rejected; a real signature is required)"
|
||||
else
|
||||
fail "alg:none token -> $NONE_CODE (expected 400/401)"
|
||||
fi
|
||||
|
||||
# valid token minted for a different audience must be rejected (RFC 8707)
|
||||
WRONG_AUD=$(curl -s -X POST "$TOKEN_URL" \
|
||||
--data-urlencode "grant_type=password" \
|
||||
--data-urlencode "client_id=other-client" \
|
||||
--data-urlencode "[email protected]" \
|
||||
--data-urlencode "password=mcppassword" \
|
||||
--data-urlencode "scope=openid email" \
|
||||
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
|
||||
if [ -n "$WRONG_AUD" ]; then
|
||||
WA_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" \
|
||||
-H "Content-Type: application/json" -H "Authorization: Bearer $WRONG_AUD" -d "$RPC_LIST")
|
||||
if [ "$WA_CODE" = "401" ]; then
|
||||
pass "Valid token, wrong audience -> 401 (RFC 8707 audience binding)"
|
||||
else
|
||||
fail "wrong-audience token -> $WA_CODE (expected 401)"
|
||||
fi
|
||||
else
|
||||
fail "Could not mint a wrong-audience token from other-client"
|
||||
fi
|
||||
|
||||
if [ -n "$USER_TOKEN" ]; then
|
||||
# the /mcp guard covers every method: GET without a token -> 401
|
||||
GET_NOAUTH=$(curl -s -o /dev/null -w "%{http_code}" -X GET "$MCP_URL")
|
||||
if [ "$GET_NOAUTH" = "401" ]; then
|
||||
pass "GET /mcp with no token -> 401 (chain guards all methods, not just POST)"
|
||||
else
|
||||
fail "GET /mcp no token -> $GET_NOAUTH (expected 401)"
|
||||
fi
|
||||
|
||||
# unknown JSON-RPC method is refused with -32601
|
||||
UNK=$(curl -s -X POST "$MCP_URL" -H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $USER_TOKEN" \
|
||||
-d '{"jsonrpc":"2.0","id":9,"method":"admin/deleteEverything"}')
|
||||
if echo "$UNK" | grep -q "32601"; then
|
||||
pass "Unknown JSON-RPC method -> error -32601 (not dispatched)"
|
||||
else
|
||||
fail "Unknown method not rejected as expected: $UNK"
|
||||
fi
|
||||
|
||||
# isolation: the MCP token must not unlock admin endpoints
|
||||
ADM_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X GET \
|
||||
"http://localhost:8080/api/v1/admin/settings" \
|
||||
-H "Authorization: Bearer $USER_TOKEN")
|
||||
if [ "$ADM_CODE" = "401" ] || [ "$ADM_CODE" = "403" ] || [ "$ADM_CODE" = "302" ]; then
|
||||
pass "MCP token on /api/v1/admin/settings -> $ADM_CODE (no cross-endpoint access)"
|
||||
else
|
||||
fail "MCP token reached an admin endpoint -> $ADM_CODE (expected 401/403/302)"
|
||||
fi
|
||||
else
|
||||
fail "Skipping method/isolation checks - no user token"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# regression checks
|
||||
echo -e "${YELLOW}[8] Regression checks${NC}"
|
||||
if [ -n "$USER_TOKEN" ]; then
|
||||
# a PDF category tool must return an honest error (isError), not a fake success
|
||||
CAT=$(curl -s -X POST "$MCP_URL" -H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $USER_TOKEN" \
|
||||
-d '{"jsonrpc":"2.0","id":81,"method":"tools/call","params":{"name":"stirling_security","arguments":{"operation":"add-password"}}}')
|
||||
if echo "$CAT" | grep -q '"isError":true'; then
|
||||
pass "category tool returns isError, not a fake success"
|
||||
else
|
||||
fail "category tool did not return isError: $CAT"
|
||||
fi
|
||||
|
||||
# malformed JSON -> JSON-RPC parse error (-32700) envelope
|
||||
MAL=$(curl -s -X POST "$MCP_URL" -H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $USER_TOKEN" -d '{ not valid json ')
|
||||
if echo "$MAL" | grep -q "32700"; then
|
||||
pass "malformed JSON -> -32700 envelope"
|
||||
else
|
||||
fail "malformed JSON not enveloped as -32700: $MAL"
|
||||
fi
|
||||
|
||||
# valid JSON but wrong shape -> invalid request (-32600)
|
||||
WS=$(curl -s -X POST "$MCP_URL" -H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $USER_TOKEN" -d '{"hello":"world"}')
|
||||
if echo "$WS" | grep -q "32600"; then
|
||||
pass "wrong-shape JSON -> -32600"
|
||||
else
|
||||
fail "wrong-shape JSON wrong error code: $WS"
|
||||
fi
|
||||
|
||||
# initialize echoes the client protocolVersion (negotiation)
|
||||
INIT=$(curl -s -X POST "$MCP_URL" -H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $USER_TOKEN" \
|
||||
-d '{"jsonrpc":"2.0","id":82,"method":"initialize","params":{"protocolVersion":"2025-03-26"}}')
|
||||
if echo "$INIT" | grep -q '"protocolVersion":"2025-03-26"'; then
|
||||
pass "initialize negotiates the client's protocolVersion"
|
||||
else
|
||||
fail "initialize did not echo client protocolVersion: $INIT"
|
||||
fi
|
||||
else
|
||||
fail "Skipping regression checks - no user token"
|
||||
fi
|
||||
|
||||
# chunked body (no Content-Length) over the cap -> 413, pre-auth
|
||||
CHUNK=$( { printf '{"jsonrpc":"2.0","id":1,"method":"x","params":"'; head -c 300000 /dev/zero | tr '\0' A; printf '"}'; } \
|
||||
| curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" \
|
||||
-H "Content-Type: application/json" -H "Transfer-Encoding: chunked" --data-binary @- )
|
||||
if [ "$CHUNK" = "413" ]; then
|
||||
pass "chunked oversized body -> 413"
|
||||
else
|
||||
fail "chunked oversized body -> $CHUNK (expected 413)"
|
||||
fi
|
||||
|
||||
# behind a proxy, the 401 resource_metadata URL must honour X-Forwarded-*
|
||||
WWW=$(curl -s -D - -o /dev/null -X POST "$MCP_URL" -H "Content-Type: application/json" \
|
||||
-H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: mcp.example.com" -d "$RPC_LIST" \
|
||||
| tr -d '\r' | grep -i "^www-authenticate:")
|
||||
if echo "$WWW" | grep -q "https://mcp.example.com/.well-known/oauth-protected-resource"; then
|
||||
pass "401 resource_metadata honours X-Forwarded-*"
|
||||
else
|
||||
fail "resource_metadata ignored forwarded headers: $WWW"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# CORS for browser-based MCP clients
|
||||
echo -e "${YELLOW}[9] CORS (browser MCP clients)${NC}"
|
||||
ORIGIN="http://127.0.0.1:6274"
|
||||
PF=$(curl -s -D - -o /dev/null -X OPTIONS "$MCP_URL" \
|
||||
-H "Origin: $ORIGIN" -H "Access-Control-Request-Method: POST" \
|
||||
-H "Access-Control-Request-Headers: authorization,content-type" | tr -d '\r')
|
||||
PF_CODE=$(printf '%s' "$PF" | sed -n 's#^HTTP/[0-9.]* \([0-9][0-9][0-9]\).*#\1#p' | head -1)
|
||||
if printf '%s' "$PF_CODE" | grep -qE '^2'; then
|
||||
pass "OPTIONS /mcp preflight -> $PF_CODE (not 401 - browsers can call /mcp)"
|
||||
else
|
||||
fail "OPTIONS /mcp preflight -> $PF_CODE (expected 2xx)"
|
||||
fi
|
||||
if printf '%s' "$PF" | grep -qi "access-control-allow-origin"; then
|
||||
pass "preflight advertises Access-Control-Allow-Origin"
|
||||
else
|
||||
fail "preflight missing Access-Control-Allow-Origin"
|
||||
fi
|
||||
if curl -s -D - -o /dev/null -H "Origin: $ORIGIN" "$PRM_URL" | tr -d '\r' | grep -qi "access-control-allow-origin"; then
|
||||
pass "protected-resource metadata sends Access-Control-Allow-Origin (browser-readable)"
|
||||
else
|
||||
fail "metadata missing Access-Control-Allow-Origin (browser can't read discovery)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# real MCP client (official @modelcontextprotocol/sdk)
|
||||
echo -e "${YELLOW}[10] Real MCP SDK client (not just curl)${NC}"
|
||||
CHECK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/mcp-client-check"
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo -e " ${YELLOW}(node not installed - skipping real-client check)${NC}"
|
||||
elif [ -z "$USER_TOKEN" ]; then
|
||||
fail "skipping real-client check - no user token"
|
||||
else
|
||||
if [ ! -d "$CHECK_DIR/node_modules" ]; then
|
||||
echo " installing MCP SDK (first run)..."
|
||||
(cd "$CHECK_DIR" && npm install --silent --no-fund --no-audit >/dev/null 2>&1)
|
||||
fi
|
||||
if MODE=oauth MCP_URL="$MCP_URL" MCP_BEARER="$USER_TOKEN" node "$CHECK_DIR/check.mjs"; then
|
||||
pass "official MCP SDK client connected + validated over streamable HTTP (oauth)"
|
||||
else
|
||||
fail "official MCP SDK client could not validate the server (oauth)"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# real operation execution (rotate a real PDF, inline)
|
||||
echo -e "${YELLOW}[11] Real operation execution (rotate-pdf, inline file)${NC}"
|
||||
SAMPLE_PDF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../app/common/src/test/resources/example.pdf"
|
||||
if [ -z "$USER_TOKEN" ]; then
|
||||
fail "skipping execution check - no user token"
|
||||
elif [ ! -f "$SAMPLE_PDF" ]; then
|
||||
fail "sample PDF not found at $SAMPLE_PDF"
|
||||
else
|
||||
B64=$(base64 -w0 "$SAMPLE_PDF" 2>/dev/null || base64 "$SAMPLE_PDF" | tr -d '\n')
|
||||
EXEC_RPC=$(printf '{"jsonrpc":"2.0","id":91,"method":"tools/call","params":{"name":"stirling_pages","arguments":{"operation":"rotate-pdf","fileName":"example.pdf","parameters":{"angle":90},"file":"%s"}}}' "$B64")
|
||||
EXEC=$(curl -s -X POST "$MCP_URL" -H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $USER_TOKEN" -d "$EXEC_RPC")
|
||||
if echo "$EXEC" | grep -q '"isError":true'; then
|
||||
fail "rotate-pdf returned isError: $(printf '%s' "$EXEC" | head -c 300)"
|
||||
elif echo "$EXEC" | grep -q 'fileId='; then
|
||||
pass "rotate-pdf processed a real PDF over MCP and returned a result fileId"
|
||||
else
|
||||
fail "rotate-pdf returned no result fileId: $(printf '%s' "$EXEC" | head -c 300)"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# summary
|
||||
rm -f /tmp/mcp_prm.json /tmp/mcp_list.json /tmp/mcp_desc.json 2>/dev/null
|
||||
echo -e "${BLUE}────────────────────────────────────────────────────${NC}"
|
||||
if [ "$FAIL" -eq 0 ]; then
|
||||
echo -e "${GREEN}All MCP environment checks passed! ($PASS passed)${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}MCP validation failed: $FAIL failed, $PASS passed.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user