From 3ecd95b77975f50aa96aa8bcac409130b3a89b7c Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:46:25 +0100 Subject: [PATCH] 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. --- .gitleaksignore | 9 + .taskfiles/e2e.yml | 52 ++ .../software/common/cluster/FileStore.java | 38 +- .../cluster/inprocess/LocalDiskFileStore.java | 123 +++- .../common/model/ApplicationProperties.java | 89 +++ .../software/common/service/FileStorage.java | 62 +- .../common/service/JobExecutorService.java | 7 + .../software/common/util/JobContext.java | 12 +- .../inprocess/LocalDiskFileStoreTest.java | 44 ++ .../service/FileStorageDelegationTest.java | 4 +- .../service/FileStorageOwnershipTest.java | 107 ++++ .../common/service/FileStorageTest.java | 23 +- .../src/main/resources/settings.yml.template | 18 + app/proprietary/build.gradle | 4 + .../proprietary/cluster/s3/S3FileStore.java | 46 +- .../proprietary/mcp/McpCallContext.java | 15 + .../proprietary/mcp/McpServerController.java | 217 +++++++ .../software/proprietary/mcp/McpTool.java | 18 + .../mcp/catalog/McpToolCatalog.java | 266 +++++++++ .../mcp/catalog/OperationCategory.java | 38 ++ .../mcp/catalog/OperationMeta.java | 22 + .../mcp/catalog/SimpleSchemaGenerator.java | 178 ++++++ .../mcp/engine/EngineCapabilityClient.java | 204 +++++++ .../proprietary/mcp/jsonrpc/JsonRpcError.java | 36 ++ .../mcp/jsonrpc/JsonRpcRequest.java | 14 + .../mcp/jsonrpc/JsonRpcResponse.java | 18 + .../mcp/security/McpApiKeyAuthFilter.java | 85 +++ .../mcp/security/McpAudienceValidator.java | 44 ++ .../security/McpAuthenticationEntryPoint.java | 76 +++ .../mcp/security/McpRequestSizeFilter.java | 128 ++++ .../mcp/security/McpSecurityConfig.java | 262 +++++++++ .../mcp/security/McpUserBindingFilter.java | 115 ++++ .../mcp/tools/AbstractCategoryTool.java | 154 +++++ .../mcp/tools/DescribeOperationTool.java | 84 +++ .../mcp/tools/McpOperationExecutor.java | 255 ++++++++ .../proprietary/mcp/tools/McpResponses.java | 80 +++ .../proprietary/mcp/tools/McpToolSupport.java | 43 ++ .../proprietary/mcp/tools/StirlingAiTool.java | 149 +++++ .../mcp/tools/StirlingConvertTool.java | 41 ++ .../mcp/tools/StirlingDownloadTool.java | 113 ++++ .../mcp/tools/StirlingMiscTool.java | 40 ++ .../mcp/tools/StirlingPagesTool.java | 40 ++ .../mcp/tools/StirlingSecurityTool.java | 41 ++ .../mcp/tools/StirlingUploadTool.java | 96 +++ .../api/AdminSettingsController.java | 7 +- .../proprietary/service/AiEngineClient.java | 25 +- .../cluster/s3/S3FileStoreTest.java | 23 + .../proprietary/mcp/McpConditionalTest.java | 99 ++++ .../mcp/McpServerControllerTest.java | 238 ++++++++ .../mcp/catalog/McpToolCatalogTest.java | 185 ++++++ .../catalog/SimpleSchemaGeneratorTest.java | 59 ++ .../mcp/engine/EngineCapabilityParseTest.java | 138 +++++ .../security/McpApiKeyIntegrationTest.java | 148 +++++ .../security/McpAudienceValidatorTest.java | 64 ++ .../McpAuthenticationEntryPointTest.java | 61 ++ .../mcp/security/McpOAuthIntegrationTest.java | 337 +++++++++++ .../mcp/tools/CategoryToolDispatchTest.java | 121 ++++ .../mcp/tools/FileToolScopeTest.java | 55 ++ .../mcp/tools/McpOperationExecutorTest.java | 146 +++++ .../service/AiEngineClientTest.java | 43 ++ engine/src/stirling/api/agent_capabilities.py | 160 +++++ engine/src/stirling/api/app.py | 4 + engine/src/stirling/api/engine_auth.py | 99 ++++ engine/src/stirling/api/routes/__init__.py | 2 + .../stirling/api/routes/agent_capabilities.py | 22 + engine/src/stirling/config/settings.py | 5 + engine/tests/test_engine_auth.py | 89 +++ .../public/locales/en-GB/translation.toml | 61 ++ .../shared/config/SettingsSearchBar.tsx | 1 + .../core/components/shared/config/types.ts | 1 + .../shared/config/configNavSections.tsx | 9 + .../config/configSections/AdminMcpSection.tsx | 551 ++++++++++++++++++ .../compose/docker-compose-keycloak-mcp.yml | 134 +++++ testing/compose/keycloak-realm-mcp.json | 438 ++++++++++++++ testing/compose/mcp-client-check/.gitignore | 2 + testing/compose/mcp-client-check/check.mjs | 100 ++++ testing/compose/mcp-client-check/package.json | 10 + testing/compose/start-mcp-test.sh | 290 +++++++++ testing/compose/validate-mcp-apikey.sh | 134 +++++ testing/compose/validate-mcp-test.sh | 397 +++++++++++++ 80 files changed, 7712 insertions(+), 56 deletions(-) create mode 100644 app/common/src/test/java/stirling/software/common/service/FileStorageOwnershipTest.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mcp/McpCallContext.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mcp/McpServerController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mcp/McpTool.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/McpToolCatalog.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/OperationCategory.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/OperationMeta.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/SimpleSchemaGenerator.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mcp/engine/EngineCapabilityClient.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mcp/jsonrpc/JsonRpcError.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mcp/jsonrpc/JsonRpcRequest.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mcp/jsonrpc/JsonRpcResponse.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpApiKeyAuthFilter.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAudienceValidator.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAuthenticationEntryPoint.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpRequestSizeFilter.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpSecurityConfig.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpUserBindingFilter.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/AbstractCategoryTool.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/DescribeOperationTool.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/McpOperationExecutor.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/McpResponses.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/McpToolSupport.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingAiTool.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingConvertTool.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingDownloadTool.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingMiscTool.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingPagesTool.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingSecurityTool.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingUploadTool.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/mcp/McpConditionalTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/mcp/McpServerControllerTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/mcp/catalog/McpToolCatalogTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/mcp/catalog/SimpleSchemaGeneratorTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/mcp/engine/EngineCapabilityParseTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpApiKeyIntegrationTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpAudienceValidatorTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpAuthenticationEntryPointTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpOAuthIntegrationTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/mcp/tools/CategoryToolDispatchTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/mcp/tools/FileToolScopeTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/mcp/tools/McpOperationExecutorTest.java create mode 100644 engine/src/stirling/api/agent_capabilities.py create mode 100644 engine/src/stirling/api/engine_auth.py create mode 100644 engine/src/stirling/api/routes/agent_capabilities.py create mode 100644 engine/tests/test_engine_auth.py create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/AdminMcpSection.tsx create mode 100644 testing/compose/docker-compose-keycloak-mcp.yml create mode 100644 testing/compose/keycloak-realm-mcp.json create mode 100644 testing/compose/mcp-client-check/.gitignore create mode 100644 testing/compose/mcp-client-check/check.mjs create mode 100644 testing/compose/mcp-client-check/package.json create mode 100644 testing/compose/start-mcp-test.sh create mode 100644 testing/compose/validate-mcp-apikey.sh create mode 100644 testing/compose/validate-mcp-test.sh diff --git a/.gitleaksignore b/.gitleaksignore index a5f4b02f9..f363dd114 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -3,3 +3,12 @@ # intentionally in #6150 so engine/.env has a working default, with real # credentials overridden via engine/.env.local. engine/.env:generic-api-key:41 + +# MCP test fixtures / harness - no real secrets: +# - test-only API key constant in an integration test +# - JDBC URL + throwaway Keycloak creds in the local test compose +# - placeholder / shell-variable Bearer headers in curl-based validation scripts +app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpApiKeyIntegrationTest.java:generic-api-key:40 +testing/compose/docker-compose-keycloak-mcp.yml:generic-api-key:25 +testing/compose/validate-mcp-apikey.sh:curl-auth-header:73 +testing/compose/validate-mcp-test.sh:curl-auth-header:92 diff --git a/.taskfiles/e2e.yml b/.taskfiles/e2e.yml index 825b7cb69..618041807 100644 --- a/.taskfiles/e2e.yml +++ b/.taskfiles/e2e.yml @@ -212,3 +212,55 @@ tasks: desc: "Stop the SAML keycloak test environment" cmds: - docker compose -f testing/compose/docker-compose-keycloak-saml.yml down -v + + mcp:up: + desc: "Start the MCP keycloak test environment (Stirling as OAuth resource server)" + summary: | + Brings up Keycloak (OAuth authorization server) + Stirling configured as an + MCP resource server, then you can exercise /mcp with real Keycloak tokens. + Set LICENSE_KEY= to skip the interactive license prompt: + task e2e:mcp:up LICENSE_KEY=abc123 + Pass extra flags via -- : + task e2e:mcp:up -- --validate --nobuild + ignore_error: true + cmds: + - bash testing/compose/start-mcp-test.sh {{if .LICENSE_KEY}}--license-key "{{.LICENSE_KEY}}"{{end}} {{.CLI_ARGS}} + + mcp:manual: + desc: "Start the MCP keycloak test env in manual mode (prints URLs + a live token for your client)" + summary: | + Brings the stack up and prints copy-paste URLs/commands plus a freshly minted + access token so you can drive your own MCP client (Inspector, curl, ...). + task e2e:mcp:manual LICENSE_KEY= + Add --nobuild if the images are already built: + task e2e:mcp:manual LICENSE_KEY= -- --nobuild + ignore_error: true + cmds: + - bash testing/compose/start-mcp-test.sh --manual {{if .LICENSE_KEY}}--license-key "{{.LICENSE_KEY}}"{{end}} {{.CLI_ARGS}} + + mcp:apikey: + desc: "Start the MCP test env in API-KEY manual mode (no OAuth/IdP): mints a key + prints client settings" + summary: | + Brings Stirling up in apikey auth mode and prints copy-paste client settings with a freshly + minted X-API-KEY - ideal for clients whose OAuth layer can't reach localhost. + task e2e:mcp:apikey LICENSE_KEY= + Add --nobuild if images are already built: + task e2e:mcp:apikey LICENSE_KEY= -- --nobuild + ignore_error: true + cmds: + - bash testing/compose/start-mcp-test.sh --apikey {{if .LICENSE_KEY}}--license-key "{{.LICENSE_KEY}}"{{end}} {{.CLI_ARGS}} + + mcp:validate: + desc: "Validate the running MCP keycloak test environment end-to-end (oauth mode + real MCP SDK client)" + cmds: + - bash testing/compose/validate-mcp-test.sh + + mcp:validate-apikey: + desc: "Validate the MCP server in API-KEY auth mode (mints a key + real MCP SDK client), then restore oauth" + cmds: + - bash testing/compose/validate-mcp-apikey.sh + + mcp:down: + desc: "Stop the MCP keycloak test environment" + cmds: + - docker compose -f testing/compose/docker-compose-keycloak-mcp.yml down -v diff --git a/app/common/src/main/java/stirling/software/common/cluster/FileStore.java b/app/common/src/main/java/stirling/software/common/cluster/FileStore.java index f576e1e97..33e48eb40 100644 --- a/app/common/src/main/java/stirling/software/common/cluster/FileStore.java +++ b/app/common/src/main/java/stirling/software/common/cluster/FileStore.java @@ -11,23 +11,39 @@ public interface FileStore { /** Stored file record. */ record Stored(String fileId, long size) {} - /** Store the given stream and return a generated file id and total bytes written. */ - Stored store(InputStream in, String originalName) throws IOException; + /** + * Store the given stream and return a generated file id and total bytes written. {@code owner} + * may be null to indicate the file has no associated user (anonymous / desktop / async job with + * no propagated security context); a non-null value is persisted alongside the data so {@link + * #getOwner(String)} can return it later for authorization checks. + */ + Stored store(InputStream in, String originalName, String owner) throws IOException; + + /** Store with no owner. Equivalent to {@link #store(InputStream, String, String)} with null. */ + default Stored store(InputStream in, String originalName) throws IOException { + return store(in, originalName, null); + } /** * Store the file at {@code source} and return a generated file id and total bytes written. * *

Default implementation opens {@code source} as a stream and delegates to {@link - * #store(InputStream, String)}. Local-disk implementations should override to use a direct - * file-to-file copy ({@code Files.copy(source, dest)} can use {@code sendfile(2)} on Linux), - * which avoids the two-memory-copy hit of streaming a disk-backed upload through the JVM heap. + * #store(InputStream, String, String)}. Local-disk implementations should override to use a + * direct file-to-file copy ({@code Files.copy(source, dest)} can use {@code sendfile(2)} on + * Linux), which avoids the two-memory-copy hit of streaming a disk-backed upload through the + * JVM heap. */ - default Stored store(Path source, String originalName) throws IOException { + default Stored store(Path source, String originalName, String owner) throws IOException { try (InputStream in = Files.newInputStream(source)) { - return store(in, originalName); + return store(in, originalName, owner); } } + /** Store with no owner. Equivalent to {@link #store(Path, String, String)} with null. */ + default Stored store(Path source, String originalName) throws IOException { + return store(source, originalName, null); + } + /** Open the stored file for streaming reads. Caller closes. */ InputStream retrieve(String fileId) throws IOException; @@ -42,4 +58,12 @@ public interface FileStore { /** Whether the file id exists in the store. */ boolean exists(String fileId); + + /** + * Returns the owner identifier recorded at store time, or {@code null} if the file does not + * exist or was stored without an owner. Implementations must not throw when the file is missing + * or when the owner record is absent; they should return null so callers can treat "no owner" + * as a non-authoritative case. + */ + String getOwner(String fileId) throws IOException; } diff --git a/app/common/src/main/java/stirling/software/common/cluster/inprocess/LocalDiskFileStore.java b/app/common/src/main/java/stirling/software/common/cluster/inprocess/LocalDiskFileStore.java index 6d8d0d91f..67ba48b4a 100644 --- a/app/common/src/main/java/stirling/software/common/cluster/inprocess/LocalDiskFileStore.java +++ b/app/common/src/main/java/stirling/software/common/cluster/inprocess/LocalDiskFileStore.java @@ -3,9 +3,12 @@ package stirling.software.common.cluster.inprocess; import java.io.BufferedInputStream; import java.io.IOException; import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.UUID; +import java.util.concurrent.locks.ReentrantLock; +import java.util.regex.Pattern; import lombok.extern.slf4j.Slf4j; @@ -15,33 +18,47 @@ import stirling.software.common.cluster.FileStore; @Slf4j public class LocalDiskFileStore implements FileStore { + private static final String OWNER_SUFFIX = ".owner"; + + // File ids are generated as random UUIDs; reject anything else so a tainted id can never reach + // Files.* APIs (defence in depth on top of the resolve() prefix check, and silences CodeQL's + // path-injection finding on the resolveOwner sidecar lookup). + private static final Pattern UUID_PATTERN = + Pattern.compile( + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); + private final String baseDirPath; + // Fixed-size lock stripes so concurrent store/delete on the same (or colliding) fileId + // serialise the data-file + owner-sidecar pair as one critical section. Striped (not + // per-id) so the map never has to be cleaned up; collisions across unrelated ids are + // harmless contention. + private static final int LOCK_STRIPES = 64; + private final ReentrantLock[] stripes = new ReentrantLock[LOCK_STRIPES]; public LocalDiskFileStore(String baseDirPath) { this.baseDirPath = baseDirPath; + for (int i = 0; i < LOCK_STRIPES; i++) { + stripes[i] = new ReentrantLock(); + } } @Override - public Stored store(InputStream in, String originalName) throws IOException { + public Stored store(InputStream in, String originalName, String owner) throws IOException { String fileId = UUID.randomUUID().toString(); Path filePath = resolve(fileId); Files.createDirectories(filePath.getParent()); + ReentrantLock lock = acquire(fileId); boolean success = false; try { long size = Files.copy(in, filePath); + writeOwner(fileId, owner); success = true; return new Stored(fileId, size); } finally { if (!success) { - try { - Files.deleteIfExists(filePath); - } catch (IOException cleanupEx) { - log.warn( - "Failed to clean up partial file {} after store failure", - filePath, - cleanupEx); - } + cleanupAfterFailedStore(fileId, filePath); } + release(fileId, lock); } } @@ -52,27 +69,44 @@ public class LocalDiskFileStore implements FileStore { * the source size before copying so the post-copy stat is unnecessary. */ @Override - public Stored store(Path source, String originalName) throws IOException { + public Stored store(Path source, String originalName, String owner) throws IOException { String fileId = UUID.randomUUID().toString(); Path filePath = resolve(fileId); Files.createDirectories(filePath.getParent()); long size = Files.size(source); + ReentrantLock lock = acquire(fileId); boolean success = false; try { Files.copy(source, filePath); + writeOwner(fileId, owner); success = true; return new Stored(fileId, size); } finally { if (!success) { - try { - Files.deleteIfExists(filePath); - } catch (IOException cleanupEx) { - log.warn( - "Failed to clean up partial file {} after store failure", - filePath, - cleanupEx); - } + cleanupAfterFailedStore(fileId, filePath); } + release(fileId, lock); + } + } + + private void writeOwner(String fileId, String owner) throws IOException { + if (owner == null || owner.isBlank()) { + return; + } + Path ownerPath = resolveOwner(fileId); + Files.write(ownerPath, owner.getBytes(StandardCharsets.UTF_8)); + } + + private void cleanupAfterFailedStore(String fileId, Path filePath) { + try { + Files.deleteIfExists(filePath); + } catch (IOException cleanupEx) { + log.warn("Failed to clean up partial file {} after store failure", filePath, cleanupEx); + } + try { + Files.deleteIfExists(resolveOwner(fileId)); + } catch (IOException cleanupEx) { + log.warn("Failed to clean up owner sidecar for {} after store failure", fileId); } } @@ -101,11 +135,26 @@ public class LocalDiskFileStore implements FileStore { @Override public boolean delete(String fileId) { + ReentrantLock lock = acquire(fileId); try { - return Files.deleteIfExists(resolve(fileId)); - } catch (IOException e) { - log.error("Error deleting file with ID: {}", fileId, e); - return false; + // Data first, owner second: a concurrent retrieve that observes the transient + // (data-gone, owner-still-present) window simply fails with IOException; the inverse + // order would briefly look like an unowned file and could grant cross-user access. + boolean removed; + try { + removed = Files.deleteIfExists(resolve(fileId)); + } catch (IOException e) { + log.error("Error deleting file with ID: {}", fileId, e); + return false; + } + try { + Files.deleteIfExists(resolveOwner(fileId)); + } catch (IOException e) { + log.warn("Error deleting owner sidecar for file ID: {}", fileId, e); + } + return removed; + } finally { + release(fileId, lock); } } @@ -114,8 +163,21 @@ public class LocalDiskFileStore implements FileStore { return Files.exists(resolve(fileId)); } + @Override + public String getOwner(String fileId) throws IOException { + Path ownerPath = resolveOwner(fileId); + if (!Files.exists(ownerPath)) { + return null; + } + byte[] bytes = Files.readAllBytes(ownerPath); + if (bytes.length == 0) { + return null; + } + return new String(bytes, StandardCharsets.UTF_8); + } + public Path resolve(String fileId) { - if (fileId.contains("..") || fileId.contains("/") || fileId.contains("\\")) { + if (fileId == null || !UUID_PATTERN.matcher(fileId).matches()) { throw new IllegalArgumentException("Invalid file ID"); } Path basePath = Path.of(baseDirPath).normalize().toAbsolutePath(); @@ -125,4 +187,19 @@ public class LocalDiskFileStore implements FileStore { } return resolvedPath; } + + private Path resolveOwner(String fileId) { + Path data = resolve(fileId); + return data.resolveSibling(data.getFileName().toString() + OWNER_SUFFIX); + } + + private ReentrantLock acquire(String fileId) { + ReentrantLock lock = stripes[(fileId.hashCode() & Integer.MAX_VALUE) % LOCK_STRIPES]; + lock.lock(); + return lock; + } + + private void release(String fileId, ReentrantLock lock) { + lock.unlock(); + } } diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index bb2eb8167..00e26ec64 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -77,6 +77,7 @@ public class ApplicationProperties { private ProcessExecutor processExecutor = new ProcessExecutor(); private PdfEditor pdfEditor = new PdfEditor(); private AiEngine aiEngine = new AiEngine(); + private Mcp mcp = new Mcp(); private InternalApi internalApi = new InternalApi(); private Cluster cluster = new Cluster(); @@ -256,6 +257,94 @@ public class ApplicationProperties { private int longRunningTimeoutSeconds = 600; } + /** + * Model Context Protocol (MCP) server configuration. All keys live under the top-level {@code + * mcp.*} prefix. {@link #enabled} defaults to {@code false}: when off, no MCP beans are wired, + * no /mcp endpoint exists, and no protected-resource metadata is published. + */ + @Data + public static class Mcp { + + /** Master switch. When {@code false} (default), no MCP beans are wired. */ + private boolean enabled = false; + + /** + * When {@code true} (default), invocations require an OAuth scope: {@code mcp.tools.read} + * for read-style operations and {@code mcp.tools.write} for write/destructive ones. When + * {@code false}, scope checks are skipped (use only if your IdP issues a single coarse + * scope). + */ + private boolean scopesEnabled = true; + + /** How often to refresh the AI capabilities manifest from the engine. */ + private int engineCapabilityRefreshMinutes = 5; + + /** + * Tool allow-list (operation ids, e.g. {@code compress-pdf}). When non-empty, ONLY these + * operations are exposed over MCP; everything else is hidden, undescribable, and + * uninvocable - on top of the global endpoint enable/disable config. Empty = allow all. + */ + private List allowedOperations = new ArrayList<>(); + + /** + * Tool deny-list (operation ids). Any operation listed here is removed from MCP even if it + * would otherwise be allowed. Applied after {@link #allowedOperations}. + */ + private List blockedOperations = new ArrayList<>(); + + /** Max MCP request body size in bytes; inline file uploads ride in the JSON-RPC body. */ + private long maxRequestBytes = 10L * 1024 * 1024; + + /** Results up to this size return inline as base64; larger ones return a fileId only. */ + private long maxInlineResponseBytes = 10L * 1024 * 1024; + + private Auth auth = new Auth(); + + @Data + public static class Auth { + /** + * Authentication mode for the MCP endpoint. {@code oauth} (default) runs a full OAuth2 + * resource server (JWT, RFC 8707 audience, RFC 9728 metadata). {@code apikey} accepts a + * Stirling per-user API key via the {@code X-API-KEY} header (or {@code Authorization: + * Bearer }) and binds the request to that user - the low-friction self-host path, + * no external IdP required. + */ + private String mode = "oauth"; + + /** OAuth2 issuer URI, e.g. {@code http://localhost:9000}. Required when MCP is on. */ + private String issuerUri = ""; + + /** + * JWKS URI. When blank, derived from the issuer's {@code + * /.well-known/openid-configuration} document. + */ + private String jwksUri = ""; + + /** + * RFC 8707 resource identifier of THIS MCP server, e.g. {@code + * http://localhost:8080/mcp}. Tokens that do not list this id in their {@code aud} + * claim are rejected with HTTP 401. + */ + private String resourceId = ""; + + /** + * JWT claim whose value is matched against a provisioned Stirling username. Defaults to + * {@code sub}; set to {@code email} or {@code preferred_username} to match how your IdP + * maps users to Stirling accounts. + */ + private String usernameClaim = "sub"; + + /** + * When {@code true} (default), a validated token is accepted only if its {@link + * #usernameClaim} value resolves to an existing, enabled Stirling user account. Tokens + * whose subject has no Stirling account (or a disabled one) are rejected with HTTP 403. + * Set to {@code false} only if you intentionally want any IdP-valid token to use MCP + * without a local account. + */ + private boolean requireExistingAccount = true; + } + } + /** * Cluster backplane configuration. All keys live under the top-level {@code cluster.*} prefix * (e.g. env var {@code CLUSTER_ENABLED}). The master switch is {@link #enabled} and defaults to diff --git a/app/common/src/main/java/stirling/software/common/service/FileStorage.java b/app/common/src/main/java/stirling/software/common/service/FileStorage.java index e77e6d6fc..c5c1ddb5d 100644 --- a/app/common/src/main/java/stirling/software/common/service/FileStorage.java +++ b/app/common/src/main/java/stirling/software/common/service/FileStorage.java @@ -5,6 +5,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.PipedInputStream; import java.io.PipedOutputStream; +import java.util.Optional; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicReference; @@ -17,6 +18,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.cluster.FileStore; +import stirling.software.common.util.JobContext; /** * Service for storing and retrieving files with unique file IDs. Used by the AutoJobPostMapping @@ -32,8 +34,10 @@ public class FileStorage { private final FileOrUploadService fileOrUploadService; private final FileStore fileStore; + private final Optional jobOwnershipService; public String storeFile(MultipartFile file) throws IOException { + String owner = resolveOwner(); // Fast path: when Spring buffered the multipart to disk (typical for large uploads), the // backing Resource exposes a real File. Hand the Path to the FileStore so it can do a // file-to-file copy (Linux sendfile, no copy through Java heap) rather than streaming @@ -48,7 +52,7 @@ public class FileStorage { if (res != null && res.isFile()) { try { FileStore.Stored stored = - fileStore.store(res.getFile().toPath(), file.getOriginalFilename()); + fileStore.store(res.getFile().toPath(), file.getOriginalFilename(), owner); log.debug("Stored file with ID: {} (fast path)", stored.fileId()); return stored.fileId(); } catch (IOException ex) { @@ -57,40 +61,45 @@ public class FileStorage { } } try (InputStream in = file.getInputStream()) { - FileStore.Stored stored = fileStore.store(in, file.getOriginalFilename()); + FileStore.Stored stored = fileStore.store(in, file.getOriginalFilename(), owner); log.debug("Stored file with ID: {}", stored.fileId()); return stored.fileId(); } } public String storeBytes(byte[] bytes, String originalName) throws IOException { - FileStore.Stored stored = fileStore.store(new ByteArrayInputStream(bytes), originalName); + FileStore.Stored stored = + fileStore.store(new ByteArrayInputStream(bytes), originalName, resolveOwner()); log.debug("Stored byte array with ID: {}", stored.fileId()); return stored.fileId(); } public MultipartFile retrieveFile(String fileId) throws IOException { + enforceOwnership(fileId); byte[] fileData = fileStore.retrieveBytes(fileId); return fileOrUploadService.toMockMultipartFile(fileId, fileData); } public byte[] retrieveBytes(String fileId) throws IOException { + enforceOwnership(fileId); return fileStore.retrieveBytes(fileId); } public InputStream retrieveInputStream(String fileId) throws IOException { + enforceOwnership(fileId); return fileStore.retrieve(fileId); } public StoredFile storeInputStream(InputStream inputStream, String originalName) throws IOException { - FileStore.Stored stored = fileStore.store(inputStream, originalName); + FileStore.Stored stored = fileStore.store(inputStream, originalName, resolveOwner()); log.debug("Stored input stream with ID: {}", stored.fileId()); return new StoredFile(stored.fileId(), stored.size()); } public String storeFromStreamingBody(StreamingResponseBody body, String originalName) throws IOException { + String owner = resolveOwner(); // Hold Throwable not IOException: an unchecked failure (NPE, IllegalState, OOM, etc.) // from the body writer would otherwise close the pipe with EOF and the consumer would // return a truncated file with no error surfaced to the caller. @@ -115,7 +124,7 @@ public class FileStorage { } } }); - FileStore.Stored stored = fileStore.store(in, originalName); + FileStore.Stored stored = fileStore.store(in, originalName, owner); Throwable writerErr = bodyError.get(); if (writerErr != null) { // Body failed mid-write: the FileStore persisted a truncated entry. @@ -159,21 +168,62 @@ public class FileStorage { public String storeFromResource(Resource resource, String originalName) throws IOException { try (InputStream in = resource.getInputStream()) { - FileStore.Stored stored = fileStore.store(in, originalName); + FileStore.Stored stored = fileStore.store(in, originalName, resolveOwner()); log.debug("Stored Resource with ID: {}", stored.fileId()); return stored.fileId(); } } public boolean deleteFile(String fileId) { + enforceOwnership(fileId); return fileStore.delete(fileId); } public boolean fileExists(String fileId) { + enforceOwnership(fileId); return fileStore.exists(fileId); } public long getFileSize(String fileId) throws IOException { + enforceOwnership(fileId); return fileStore.size(fileId); } + + private String resolveOwner() { + String propagated = JobContext.getOwner(); + if (propagated != null) { + return propagated; + } + return jobOwnershipService.flatMap(JobOwnershipService::getCurrentUserId).orElse(null); + } + + private void enforceOwnership(String fileId) { + if (jobOwnershipService.isEmpty()) { + return; + } + Optional currentUser = jobOwnershipService.get().getCurrentUserId(); + if (currentUser.isEmpty()) { + return; + } + String owner; + try { + owner = fileStore.getOwner(fileId); + } catch (IOException e) { + log.warn("Failed to read owner for file {}: {}", fileId, e.getMessage()); + throw new SecurityException( + "Access denied: could not verify ownership of the requested file"); + } + if (owner == null) { + return; + } + if (!owner.equals(currentUser.get())) { + log.warn( + "Access denied: user {} attempted to access file {} owned by {}", + currentUser.get(), + fileId, + owner); + throw new SecurityException( + "Access denied: you do not have permission to access this file"); + } + } } diff --git a/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java b/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java index 629d28ba6..23a23e868 100644 --- a/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java +++ b/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java @@ -89,6 +89,11 @@ public class JobExecutorService { String jobId = scopedJobKey; + final String jobOwner = + jobOwnershipService != null + ? jobOwnershipService.getCurrentUserId().orElse(null) + : null; + long timeoutToUse = customTimeoutMs > 0 ? customTimeoutMs : effectiveTimeoutMs; log.debug( @@ -119,6 +124,7 @@ public class JobExecutorService { try { stirling.software.common.util.JobContext.setJobId( capturedJobIdForQueue); + stirling.software.common.util.JobContext.setOwner(jobOwner); Object result = work.get(); processJobResult(capturedJobIdForQueue, result); return result; @@ -153,6 +159,7 @@ public class JobExecutorService { timeoutToUse); stirling.software.common.util.JobContext.setJobId(capturedJobId); + stirling.software.common.util.JobContext.setOwner(jobOwner); Object result = executeWithTimeout(() -> work.get(), timeoutToUse); processJobResult(capturedJobId, result); } catch (TimeoutException te) { diff --git a/app/common/src/main/java/stirling/software/common/util/JobContext.java b/app/common/src/main/java/stirling/software/common/util/JobContext.java index a41394914..f7016b8f7 100644 --- a/app/common/src/main/java/stirling/software/common/util/JobContext.java +++ b/app/common/src/main/java/stirling/software/common/util/JobContext.java @@ -1,8 +1,9 @@ package stirling.software.common.util; -/** Thread-local context for passing job ID across async boundaries */ +/** Thread-local context for passing job ID and owner across async boundaries */ public class JobContext { private static final ThreadLocal CURRENT_JOB_ID = new ThreadLocal<>(); + private static final ThreadLocal CURRENT_OWNER = new ThreadLocal<>(); public static void setJobId(String jobId) { CURRENT_JOB_ID.set(jobId); @@ -12,7 +13,16 @@ public class JobContext { return CURRENT_JOB_ID.get(); } + public static void setOwner(String owner) { + CURRENT_OWNER.set(owner); + } + + public static String getOwner() { + return CURRENT_OWNER.get(); + } + public static void clear() { CURRENT_JOB_ID.remove(); + CURRENT_OWNER.remove(); } } diff --git a/app/common/src/test/java/stirling/software/common/cluster/inprocess/LocalDiskFileStoreTest.java b/app/common/src/test/java/stirling/software/common/cluster/inprocess/LocalDiskFileStoreTest.java index 296d012c8..df8516851 100644 --- a/app/common/src/test/java/stirling/software/common/cluster/inprocess/LocalDiskFileStoreTest.java +++ b/app/common/src/test/java/stirling/software/common/cluster/inprocess/LocalDiskFileStoreTest.java @@ -3,11 +3,13 @@ package stirling.software.common.cluster.inprocess; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import org.junit.jupiter.api.Test; @@ -39,4 +41,46 @@ class LocalDiskFileStoreTest { assertThrows(IllegalArgumentException.class, () -> store.resolve("a/b")); assertThrows(IllegalArgumentException.class, () -> store.resolve("a\\b")); } + + @Test + void ownerSidecarCannotBeReadAsFileId(@TempDir Path dir) throws IOException { + LocalDiskFileStore store = new LocalDiskFileStore(dir.toString()); + FileStore.Stored stored = + store.store(new ByteArrayInputStream("hi".getBytes()), "f.bin", "alice"); + String sidecarId = stored.fileId() + ".owner"; + assertThrows(IllegalArgumentException.class, () -> store.resolve(sidecarId)); + assertThrows(IllegalArgumentException.class, () -> store.retrieveBytes(sidecarId)); + } + + @Test + void ownerIsPersistedAndReturnedByGetOwner(@TempDir Path dir) throws IOException { + LocalDiskFileStore store = new LocalDiskFileStore(dir.toString()); + FileStore.Stored stored = + store.store(new ByteArrayInputStream("hi".getBytes()), "f.bin", "alice"); + assertEquals("alice", store.getOwner(stored.fileId())); + } + + @Test + void getOwnerReturnsNullWhenNoOwnerWasRecorded(@TempDir Path dir) throws IOException { + LocalDiskFileStore store = new LocalDiskFileStore(dir.toString()); + FileStore.Stored stored = + store.store(new ByteArrayInputStream("hi".getBytes()), "f.bin", null); + assertNull(store.getOwner(stored.fileId())); + } + + @Test + void getOwnerReturnsNullForUnknownFileId(@TempDir Path dir) throws IOException { + LocalDiskFileStore store = new LocalDiskFileStore(dir.toString()); + assertNull(store.getOwner("00000000-0000-0000-0000-000000000000")); + } + + @Test + void deleteRemovesOwnerSidecar(@TempDir Path dir) throws IOException { + LocalDiskFileStore store = new LocalDiskFileStore(dir.toString()); + FileStore.Stored stored = + store.store(new ByteArrayInputStream("hi".getBytes()), "f.bin", "alice"); + assertTrue(store.delete(stored.fileId())); + assertFalse(Files.exists(dir.resolve(stored.fileId() + ".owner"))); + assertNull(store.getOwner(stored.fileId())); + } } diff --git a/app/common/src/test/java/stirling/software/common/service/FileStorageDelegationTest.java b/app/common/src/test/java/stirling/software/common/service/FileStorageDelegationTest.java index a929a15af..b013a2692 100644 --- a/app/common/src/test/java/stirling/software/common/service/FileStorageDelegationTest.java +++ b/app/common/src/test/java/stirling/software/common/service/FileStorageDelegationTest.java @@ -5,6 +5,7 @@ import static org.mockito.Mockito.mock; import java.io.IOException; import java.nio.file.Path; +import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -19,7 +20,8 @@ class FileStorageDelegationTest { FileStorage fs = new FileStorage( mock(FileOrUploadService.class), - new LocalDiskFileStore(tempDir.toString())); + new LocalDiskFileStore(tempDir.toString()), + Optional.empty()); byte[] payload = "round-trip".getBytes(); String id = fs.storeBytes(payload, "x.bin"); assertArrayEquals(payload, fs.retrieveBytes(id)); diff --git a/app/common/src/test/java/stirling/software/common/service/FileStorageOwnershipTest.java b/app/common/src/test/java/stirling/software/common/service/FileStorageOwnershipTest.java new file mode 100644 index 000000000..861efa850 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/service/FileStorageOwnershipTest.java @@ -0,0 +1,107 @@ +package stirling.software.common.service; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import stirling.software.common.cluster.inprocess.LocalDiskFileStore; +import stirling.software.common.util.JobContext; + +class FileStorageOwnershipTest { + + private FileStorage newStorageWithoutSecurity(Path tempDir) { + return new FileStorage( + mock(FileOrUploadService.class), + new LocalDiskFileStore(tempDir.toString()), + Optional.empty()); + } + + private FileStorage newStorageWithCurrentUser(Path tempDir, AtomicReference userRef) { + JobOwnershipService svc = mock(JobOwnershipService.class); + when(svc.getCurrentUserId()).thenAnswer(invocation -> Optional.ofNullable(userRef.get())); + return new FileStorage( + mock(FileOrUploadService.class), + new LocalDiskFileStore(tempDir.toString()), + Optional.of(svc)); + } + + @Test + void desktopMode_noOwnershipService_storesAndRetrievesWithoutChecks(@TempDir Path tempDir) + throws IOException { + FileStorage fs = newStorageWithoutSecurity(tempDir); + byte[] payload = "desktop".getBytes(); + String id = fs.storeBytes(payload, "x.bin"); + assertArrayEquals(payload, fs.retrieveBytes(id)); + } + + @Test + void sameUserStoresAndRetrieves_allowed(@TempDir Path tempDir) throws IOException { + AtomicReference user = new AtomicReference<>("alice"); + FileStorage fs = newStorageWithCurrentUser(tempDir, user); + byte[] payload = "alice's file".getBytes(); + String id = fs.storeBytes(payload, "x.bin"); + assertArrayEquals(payload, fs.retrieveBytes(id)); + } + + @Test + void differentUserRetrieves_throwsSecurityException(@TempDir Path tempDir) throws IOException { + AtomicReference user = new AtomicReference<>("alice"); + FileStorage fs = newStorageWithCurrentUser(tempDir, user); + String id = fs.storeBytes("alice's file".getBytes(), "x.bin"); + user.set("bob"); + assertThrows(SecurityException.class, () -> fs.retrieveBytes(id)); + assertThrows(SecurityException.class, () -> fs.retrieveInputStream(id)); + assertThrows(SecurityException.class, () -> fs.getFileSize(id)); + assertThrows(SecurityException.class, () -> fs.fileExists(id)); + assertThrows(SecurityException.class, () -> fs.deleteFile(id)); + } + + @Test + void anonymousRetrieveOfOwnedFile_allowed_noCurrentUserMeansNoCompare(@TempDir Path tempDir) + throws IOException { + AtomicReference user = new AtomicReference<>("alice"); + FileStorage fs = newStorageWithCurrentUser(tempDir, user); + byte[] payload = "alice's file".getBytes(); + String id = fs.storeBytes(payload, "x.bin"); + user.set(null); + assertArrayEquals(payload, fs.retrieveBytes(id)); + } + + @Test + void authedRetrieveOfAnonymousFile_allowed_noOwnerOnFile(@TempDir Path tempDir) + throws IOException { + AtomicReference user = new AtomicReference<>(null); + FileStorage fs = newStorageWithCurrentUser(tempDir, user); + byte[] payload = "no-owner".getBytes(); + String id = fs.storeBytes(payload, "x.bin"); + user.set("alice"); + assertArrayEquals(payload, fs.retrieveBytes(id)); + } + + @Test + void propagatedOwner_scopesAsyncWriteWithNoLiveUser(@TempDir Path tempDir) throws IOException { + AtomicReference user = new AtomicReference<>(null); + FileStorage fs = newStorageWithCurrentUser(tempDir, user); + byte[] payload = "alice's async result".getBytes(); + String id; + try { + JobContext.setOwner("alice"); + id = fs.storeBytes(payload, "x.bin"); + } finally { + JobContext.clear(); + } + user.set("alice"); + assertArrayEquals(payload, fs.retrieveBytes(id)); + user.set("bob"); + assertThrows(SecurityException.class, () -> fs.retrieveBytes(id)); + } +} diff --git a/app/common/src/test/java/stirling/software/common/service/FileStorageTest.java b/app/common/src/test/java/stirling/software/common/service/FileStorageTest.java index ace0dfa56..32a3cb65d 100644 --- a/app/common/src/test/java/stirling/software/common/service/FileStorageTest.java +++ b/app/common/src/test/java/stirling/software/common/service/FileStorageTest.java @@ -9,6 +9,8 @@ import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Optional; +import java.util.UUID; import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; @@ -37,7 +39,10 @@ class FileStorageTest { void setUp() throws IOException { MockitoAnnotations.openMocks(this); fileStorage = - new FileStorage(fileOrUploadService, new LocalDiskFileStore(tempDir.toString())); + new FileStorage( + fileOrUploadService, + new LocalDiskFileStore(tempDir.toString()), + Optional.empty()); // Create a mock MultipartFile mockFile = mock(MultipartFile.class); @@ -79,7 +84,7 @@ class FileStorageTest { void testRetrieveFile() throws IOException { // Arrange byte[] fileContent = "Test PDF content".getBytes(); - String fileId = "test-file-1"; + String fileId = UUID.randomUUID().toString(); Path filePath = tempDir.resolve(fileId); Files.write(filePath, fileContent); @@ -99,7 +104,7 @@ class FileStorageTest { void testRetrieveBytes() throws IOException { // Arrange byte[] fileContent = "Test PDF content".getBytes(); - String fileId = "test-file-2"; + String fileId = UUID.randomUUID().toString(); Path filePath = tempDir.resolve(fileId); Files.write(filePath, fileContent); @@ -113,7 +118,7 @@ class FileStorageTest { @Test void testRetrieveFile_FileNotFound() { // Arrange - String nonExistentFileId = "non-existent-file"; + String nonExistentFileId = UUID.randomUUID().toString(); // Act & Assert assertThrows(IOException.class, () -> fileStorage.retrieveFile(nonExistentFileId)); @@ -122,7 +127,7 @@ class FileStorageTest { @Test void testRetrieveBytes_FileNotFound() { // Arrange - String nonExistentFileId = "non-existent-file"; + String nonExistentFileId = UUID.randomUUID().toString(); // Act & Assert assertThrows(IOException.class, () -> fileStorage.retrieveBytes(nonExistentFileId)); @@ -132,7 +137,7 @@ class FileStorageTest { void testDeleteFile() throws IOException { // Arrange byte[] fileContent = "Test PDF content".getBytes(); - String fileId = "test-file-3"; + String fileId = UUID.randomUUID().toString(); Path filePath = tempDir.resolve(fileId); Files.write(filePath, fileContent); @@ -147,7 +152,7 @@ class FileStorageTest { @Test void testDeleteFile_FileNotFound() { // Arrange - String nonExistentFileId = "non-existent-file"; + String nonExistentFileId = UUID.randomUUID().toString(); // Act boolean result = fileStorage.deleteFile(nonExistentFileId); @@ -160,7 +165,7 @@ class FileStorageTest { void testFileExists() throws IOException { // Arrange byte[] fileContent = "Test PDF content".getBytes(); - String fileId = "test-file-4"; + String fileId = UUID.randomUUID().toString(); Path filePath = tempDir.resolve(fileId); Files.write(filePath, fileContent); @@ -174,7 +179,7 @@ class FileStorageTest { @Test void testFileExists_FileNotFound() { // Arrange - String nonExistentFileId = "non-existent-file"; + String nonExistentFileId = UUID.randomUUID().toString(); // Act boolean result = fileStorage.fileExists(nonExistentFileId); diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index d3684b4da..d4ecb35f7 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -364,6 +364,24 @@ aiEngine: url: http://localhost:5001 # URL of the Python AI engine timeoutSeconds: 120 # Timeout in seconds for AI engine requests +# Model Context Protocol (MCP) server. Exposes Stirling's PDF tools (grouped by namespace) +# plus the AI agents to MCP clients (Inspector, Claude Desktop, custom). OAuth-protected. +# Disabled by default - enable explicitly per deployment after configuring mcp.auth. +mcp: + enabled: false # Master switch. 'false' (default) means no /mcp endpoint, no metadata, no beans wired. + scopesEnabled: true # Enforce mcp.tools.read / mcp.tools.write scopes derived from operation category + allowedOperations: [] # Tool allow-list (operation ids, e.g. ['compress-pdf']). Empty = all. When set, ONLY these are exposed over MCP. + blockedOperations: [] # Tool deny-list (operation ids). Always removed from MCP even if otherwise allowed. + auth: + mode: oauth # 'oauth' (full OAuth2 resource server) or 'apikey' (Stirling per-user API key via X-API-KEY header; no external IdP needed - the low-friction self-host option) + issuerUri: "" # OAuth2 issuer URI (e.g. http://localhost:9000). Required when mode=oauth. + jwksUri: "" # JWKS URI. Blank -> derived from issuer's /.well-known/openid-configuration. + resourceId: "" # RFC 8707 resource identifier of THIS MCP server (e.g. http://localhost:8080/mcp). + # Required: tokens must list this id in `aud` or the request is rejected. + usernameClaim: sub # JWT claim matched against a Stirling username (e.g. 'sub', 'email', 'preferred_username') + requireExistingAccount: true # Reject tokens whose subject has no enabled Stirling account (recommended) + engineCapabilityRefreshMinutes: 5 # How often to refresh the AI capabilities manifest from the engine + # Cluster configuration. NOT YET ENABLED - scaffolding for later work. Leave at defaults. cluster: enabled: false # Master switch. 'false' (default) wires the in-process backplane and skips all cluster checks. Single-instance installs do not need to change anything here. diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle index 6f744c6c6..ceb44153d 100644 --- a/app/proprietary/build.gradle +++ b/app/proprietary/build.gradle @@ -52,6 +52,10 @@ dependencies { api 'org.springframework.boot:spring-boot-starter-security' api 'org.springframework.boot:spring-boot-starter-data-jpa' api 'org.springframework.boot:spring-boot-starter-security-oauth2-client' + // MCP server (RFC 8707 audience binding + RFC 9728 metadata) - resource-server side only. + // Brings nimbus-jose-jwt onto the proprietary classpath if not already transitive via + // oauth2-client; on Boot 4.0.6 the delta is around 130KB because nimbus is already pulled. + api 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' api 'org.springframework.boot:spring-boot-starter-mail' api 'org.springframework.boot:spring-boot-starter-cache' api 'com.github.ben-manes.caffeine:caffeine' diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3FileStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3FileStore.java index 1cff20e6a..68cd72ded 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3FileStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3FileStore.java @@ -6,6 +6,8 @@ import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.util.Collections; +import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -35,6 +37,7 @@ import software.amazon.awssdk.services.s3.model.S3Exception; public class S3FileStore implements FileStore, AutoCloseable { public static final String DEFAULT_KEY_PREFIX = "transient/"; + static final String OWNER_METADATA_KEY = "owner"; private final S3Client s3Client; private final String bucket; @@ -64,7 +67,7 @@ public class S3FileStore implements FileStore, AutoCloseable { } @Override - public Stored store(InputStream in, String originalName) throws IOException { + public Stored store(InputStream in, String originalName, String owner) throws IOException { String fileId = UUID.randomUUID().toString(); // S3 PUT requires a known content-length; spool to a temp file first so memory stays // bounded for large payloads, then stream the file to S3 via RequestBody.fromFile. @@ -75,10 +78,13 @@ public class S3FileStore implements FileStore, AutoCloseable { Files.copy(src, tempFile, StandardCopyOption.REPLACE_EXISTING); } size = Files.size(tempFile); - PutObjectRequest request = - PutObjectRequest.builder().bucket(bucket).key(resolveKey(fileId)).build(); + PutObjectRequest.Builder builder = + PutObjectRequest.builder().bucket(bucket).key(resolveKey(fileId)); + if (owner != null && !owner.isBlank()) { + builder.metadata(Map.of(OWNER_METADATA_KEY, owner)); + } try { - s3Client.putObject(request, RequestBody.fromFile(tempFile)); + s3Client.putObject(builder.build(), RequestBody.fromFile(tempFile)); } catch (SdkException e) { throw new IOException("Failed to upload object to S3", e); } @@ -185,6 +191,36 @@ public class S3FileStore implements FileStore, AutoCloseable { } } + @Override + public String getOwner(String fileId) throws IOException { + try { + validateFileId(fileId); + } catch (IllegalArgumentException e) { + return null; + } + HeadObjectRequest request = + HeadObjectRequest.builder().bucket(bucket).key(resolveKey(fileId)).build(); + try { + HeadObjectResponse response = s3Client.headObject(request); + Map metadata = + Optional.ofNullable(response.metadata()).orElse(Collections.emptyMap()); + String owner = metadata.get(OWNER_METADATA_KEY); + if (owner != null && !owner.isBlank()) { + return owner; + } + return null; + } catch (NoSuchKeyException e) { + return null; + } catch (S3Exception e) { + if (e.statusCode() == 404) { + return null; + } + throw new IOException("Failed to read owner metadata from S3", e); + } catch (SdkException e) { + throw new IOException("Failed to read owner metadata from S3", e); + } + } + @Override public void close() { if (!ownsClient) { @@ -205,7 +241,7 @@ public class S3FileStore implements FileStore, AutoCloseable { if (fileId == null || fileId.isBlank()) { throw new IllegalArgumentException("File ID must not be blank"); } - if (fileId.contains("..") || fileId.contains("/") || fileId.contains("\\")) { + if (fileId.contains(".") || fileId.contains("/") || fileId.contains("\\")) { throw new IllegalArgumentException("Invalid file ID"); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/McpCallContext.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/McpCallContext.java new file mode 100644 index 000000000..7bbf46db3 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/McpCallContext.java @@ -0,0 +1,15 @@ +package stirling.software.proprietary.mcp; + +import java.util.Set; + +/** Per-call context: resolved Stirling identity and granted scopes for an {@link McpTool#call}. */ +public record McpCallContext( + String stirlingUserId, Set grantedScopes, boolean scopesEnabled) { + + public boolean hasScope(String required) { + if (!scopesEnabled) { + return true; + } + return required == null || required.isBlank() || grantedScopes.contains(required); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/McpServerController.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/McpServerController.java new file mode 100644 index 000000000..d3103b8d4 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/McpServerController.java @@ -0,0 +1,217 @@ +package stirling.software.proprietary.mcp; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.mcp.jsonrpc.JsonRpcError; +import stirling.software.proprietary.mcp.jsonrpc.JsonRpcRequest; +import stirling.software.proprietary.mcp.jsonrpc.JsonRpcResponse; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; + +/** Streamable-HTTP MCP server endpoint serving JSON-RPC 2.0 frames on {@code POST /mcp}. */ +@Slf4j +@RestController +@RequestMapping +@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") +public class McpServerController { + + private static final String PREFERRED_PROTOCOL_VERSION = "2025-06-18"; + private static final Set SUPPORTED_PROTOCOL_VERSIONS = + Set.of("2025-06-18", "2025-03-26", "2024-11-05"); + private static final String SERVER_NAME = "stirling-pdf-mcp"; + + private final ObjectMapper mapper; + private final ApplicationProperties applicationProperties; + private final Map toolsByName; + + public McpServerController( + ObjectMapper mapper, ApplicationProperties applicationProperties, List tools) { + this.mapper = mapper; + this.applicationProperties = applicationProperties; + this.toolsByName = new HashMap<>(); + for (McpTool tool : tools) { + this.toolsByName.put(tool.name(), tool); + } + log.info( + "MCP server controller wired with {} tool(s): {}", + toolsByName.size(), + toolsByName.keySet()); + } + + @PostMapping( + path = "/mcp", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity handle(@RequestBody JsonNode body) { + JsonRpcRequest request = decode(body); + if (request == null) { + // Valid JSON but not a JSON-RPC request -> Invalid Request, not Parse error. + return ResponseEntity.badRequest() + .body( + JsonRpcResponse.failure( + null, + JsonRpcError.invalidRequest( + "Body is not a valid JSON-RPC 2.0 request"))); + } + if (request.isNotification()) { + log.debug("Notification received: {}", sanitizeForLog(request.method())); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + JsonRpcResponse response; + try { + response = dispatch(request); + } catch (RuntimeException e) { + log.warn( + "MCP dispatch failed for method {}: {}", + sanitizeForLog(request.method()), + e.getMessage(), + e); + response = + JsonRpcResponse.failure( + request.id(), + JsonRpcError.internalError( + "Internal error handling " + request.method())); + } + return ResponseEntity.ok(response); + } + + /** Wrap malformed-JSON failures (caught before {@link #handle}) as a JSON-RPC Parse error. */ + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleUnreadable(HttpMessageNotReadableException ex) { + return ResponseEntity.badRequest() + .contentType(MediaType.APPLICATION_JSON) + .body( + JsonRpcResponse.failure( + null, JsonRpcError.parseError("Request body is not valid JSON"))); + } + + private static String sanitizeForLog(String value) { + return value == null ? null : value.replace('\r', ' ').replace('\n', ' '); + } + + private JsonRpcRequest decode(JsonNode body) { + if (body == null || !body.isObject()) { + return null; + } + JsonNode jsonrpc = body.get("jsonrpc"); + JsonNode method = body.get("method"); + if (jsonrpc == null || !"2.0".equals(jsonrpc.asText())) { + return null; + } + if (method == null || !method.isTextual()) { + return null; + } + return new JsonRpcRequest( + jsonrpc.asText(), body.get("id"), method.asText(), body.get("params")); + } + + private JsonRpcResponse dispatch(JsonRpcRequest request) { + return switch (request.method()) { + case "initialize" -> + JsonRpcResponse.success(request.id(), initializeResult(request.params())); + case "tools/list" -> JsonRpcResponse.success(request.id(), toolsListResult()); + case "tools/call" -> handleToolsCall(request); + case "ping" -> JsonRpcResponse.success(request.id(), mapper.createObjectNode()); + case "notifications/initialized" -> + JsonRpcResponse.success(request.id(), mapper.createObjectNode()); + default -> + JsonRpcResponse.failure( + request.id(), JsonRpcError.methodNotFound(request.method())); + }; + } + + private ObjectNode initializeResult(JsonNode params) { + ObjectNode result = mapper.createObjectNode(); + // Echo the client's requested protocolVersion when supported, else advertise our preferred. + String requested = + params != null && params.hasNonNull("protocolVersion") + ? params.get("protocolVersion").asText() + : null; + String negotiated = + requested != null && SUPPORTED_PROTOCOL_VERSIONS.contains(requested) + ? requested + : PREFERRED_PROTOCOL_VERSION; + result.put("protocolVersion", negotiated); + ObjectNode caps = result.putObject("capabilities"); + caps.putObject("tools"); + ObjectNode info = result.putObject("serverInfo"); + info.put("name", SERVER_NAME); + info.put("version", applicationProperties.getAutomaticallyGenerated().getAppVersion()); + return result; + } + + private ObjectNode toolsListResult() { + ObjectNode result = mapper.createObjectNode(); + ArrayNode tools = result.putArray("tools"); + for (McpTool t : toolsByName.values()) { + ObjectNode entry = mapper.createObjectNode(); + entry.put("name", t.name()); + entry.put("description", t.description()); + entry.set("inputSchema", t.inputSchema()); + tools.add(entry); + } + return result; + } + + private JsonRpcResponse handleToolsCall(JsonRpcRequest request) { + JsonNode params = request.params(); + if (params == null || !params.isObject()) { + return JsonRpcResponse.failure( + request.id(), JsonRpcError.invalidParams("Missing params for tools/call")); + } + JsonNode nameNode = params.get("name"); + if (nameNode == null || !nameNode.isTextual()) { + return JsonRpcResponse.failure( + request.id(), JsonRpcError.invalidParams("Missing tool name")); + } + McpTool tool = toolsByName.get(nameNode.asText()); + if (tool == null) { + return JsonRpcResponse.failure( + request.id(), JsonRpcError.invalidParams("Unknown tool: " + nameNode.asText())); + } + JsonNode args = params.get("arguments"); + McpCallContext context = resolveContext(); + ObjectNode toolResult = tool.call(args == null ? mapper.createObjectNode() : args, context); + return JsonRpcResponse.success(request.id(), toolResult); + } + + private McpCallContext resolveContext() { + boolean scopesEnabled = applicationProperties.getMcp().isScopesEnabled(); + org.springframework.security.core.Authentication auth = + org.springframework.security.core.context.SecurityContextHolder.getContext() + .getAuthentication(); + // Fail closed: no/unauthenticated principal yields an empty context so scoped ops are + // refused. + if (auth == null || !auth.isAuthenticated() || auth.getName() == null) { + return new McpCallContext(null, Set.of(), scopesEnabled); + } + java.util.Set scopes = new java.util.HashSet<>(); + for (org.springframework.security.core.GrantedAuthority ga : auth.getAuthorities()) { + String authority = ga.getAuthority(); + if (authority != null && authority.startsWith("SCOPE_")) { + scopes.add(authority.substring("SCOPE_".length())); + } + } + return new McpCallContext(auth.getName(), scopes, scopesEnabled); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/McpTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/McpTool.java new file mode 100644 index 000000000..271ed9858 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/McpTool.java @@ -0,0 +1,18 @@ +package stirling.software.proprietary.mcp; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.node.ObjectNode; + +/** Contract every MCP tool registered with the server must satisfy. */ +public interface McpTool { + + String name(); + + String description(); + + /** The tool's {@code inputSchema} (an object JSON Schema) published in {@code tools/list}. */ + ObjectNode inputSchema(); + + /** Execute the tool; the controller wraps any thrown exception as an MCP internal error. */ + ObjectNode call(JsonNode arguments, McpCallContext context); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/McpToolCatalog.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/McpToolCatalog.java new file mode 100644 index 000000000..2821c9181 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/McpToolCatalog.java @@ -0,0 +1,266 @@ +package stirling.software.proprietary.mcp.catalog; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.ApplicationContext; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +import io.swagger.v3.oas.annotations.Operation; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.config.EndpointConfiguration; +import stirling.software.common.model.ApplicationProperties; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +/** + * Discovers MCP-exposable operations and caches a per-op {@link OperationMeta}. Refreshed on {@link + * ContextRefreshedEvent} and filtered on read by {@link + * EndpointConfiguration#isEndpointEnabledForUri}. AI capabilities are fed in via {@link + * #replaceAiCapabilities}. + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") +public class McpToolCatalog { + + private static final String WRITE_SCOPE = "mcp.tools.write"; + + private final ApplicationContext applicationContext; + private final EndpointConfiguration endpointConfiguration; + private final ApplicationProperties applicationProperties; + private final SimpleSchemaGenerator schemaGenerator; + private final ObjectMapper objectMapper; + + // Concurrent: written on the boot thread, read on request threads, AI map replaced at runtime. + private final Map pdfOps = new ConcurrentHashMap<>(); + + // Engine-driven AI capabilities. Replaced wholesale by the scheduled refresh task on a + // background thread while request threads read via findByOperationId/enabledOps. The volatile + // reference makes the swap publication-safe; readers either see the old or the new snapshot, + // never a partially-merged one. + private volatile Map aiOps = new ConcurrentHashMap<>(); + + public McpToolCatalog( + ApplicationContext applicationContext, + EndpointConfiguration endpointConfiguration, + ApplicationProperties applicationProperties, + ObjectMapper objectMapper) { + this.applicationContext = applicationContext; + this.endpointConfiguration = endpointConfiguration; + this.applicationProperties = applicationProperties; + this.schemaGenerator = new SimpleSchemaGenerator(objectMapper); + this.objectMapper = objectMapper; + } + + /** Admin tool filter: non-empty allow list is a whitelist; block list always removes. */ + private boolean isOperationAllowed(String id) { + ApplicationProperties.Mcp mcp = applicationProperties.getMcp(); + List allowed = mcp.getAllowedOperations(); + List blocked = mcp.getBlockedOperations(); + if (blocked != null && blocked.contains(id)) { + return false; + } + if (allowed != null && !allowed.isEmpty()) { + return allowed.contains(id); + } + return true; + } + + @EventListener(ContextRefreshedEvent.class) + public void discover() { + pdfOps.clear(); + for (RequestMappingHandlerMapping mapping : + applicationContext.getBeansOfType(RequestMappingHandlerMapping.class).values()) { + for (Map.Entry e : + mapping.getHandlerMethods().entrySet()) { + indexOne(e.getKey(), e.getValue()); + } + } + log.info("MCP tool catalog discovered {} PDF operation(s)", pdfOps.size()); + } + + private void indexOne(RequestMappingInfo info, HandlerMethod handler) { + Set patterns = extractPatterns(info); + if (patterns.isEmpty()) { + return; + } + Set methods = info.getMethodsCondition().getMethods(); + if (!isInvocableMethod(methods)) { + return; + } + for (String pattern : patterns) { + OperationCategory category = OperationCategory.fromUrl(pattern); + if (category == null) { + continue; + } + String opId = extractOpId(pattern, category); + if (opId == null) { + continue; + } + OperationMeta meta = buildMeta(opId, category, pattern, handler); + // First handler wins on duplicate URLs. + pdfOps.putIfAbsent(opId, meta); + } + } + + private OperationMeta buildMeta( + String opId, OperationCategory category, String url, HandlerMethod handler) { + Method method = handler.getMethod(); + Operation opAnno = method.getAnnotation(Operation.class); + String summary = + opAnno != null && !opAnno.summary().isBlank() + ? opAnno.summary() + : prettifyOpId(opId); + ObjectNode schema = paramSchemaFor(handler); + // Every mutating endpoint requires the write scope. + return new OperationMeta( + opId, + category, + summary, + schema, + WRITE_SCOPE, + OperationMeta.Target.JAVA_ENDPOINT, + url, + handler); + } + + private ObjectNode paramSchemaFor(HandlerMethod handler) { + Optional> bodyType = firstComplexParamType(handler); + return bodyType.map(schemaGenerator::toSchema).orElseGet(() -> emptyObjectSchema()); + } + + private ObjectNode emptyObjectSchema() { + ObjectNode out = objectMapper.createObjectNode(); + out.put("type", "object"); + out.put("additionalProperties", true); + return out; + } + + private Optional> firstComplexParamType(HandlerMethod handler) { + for (MethodParameter p : handler.getMethodParameters()) { + Class type = p.getParameterType(); + if (type.isPrimitive() || type == String.class || type.getName().startsWith("java.")) { + continue; + } + // Skip Spring-managed parameter types (HttpServletRequest, Principal, etc.). + String pkg = type.getPackageName(); + if (pkg.startsWith("jakarta.") || pkg.startsWith("org.springframework.")) { + continue; + } + return Optional.of(type); + } + return Optional.empty(); + } + + public List enabledOps(OperationCategory category) { + if (category == OperationCategory.AI) { + List ai = new ArrayList<>(); + for (OperationMeta m : aiOps.values()) { + if (isOperationAllowed(m.id())) { + ai.add(m); + } + } + return ai; + } + List out = new ArrayList<>(); + for (OperationMeta m : pdfOps.values()) { + if (m.category() == category + && isOperationAllowed(m.id()) + && endpointConfiguration.isEndpointEnabledForUri(m.endpointPath())) { + out.add(m); + } + } + out.sort((a, b) -> a.id().compareTo(b.id())); + return out; + } + + public Optional findByOperationId(String id) { + if (!isOperationAllowed(id)) { + return Optional.empty(); + } + // A disabled PDF op returns empty rather than falling through to a same-id AI capability. + OperationMeta meta = pdfOps.get(id); + if (meta != null) { + boolean enabled = + meta.target() != OperationMeta.Target.JAVA_ENDPOINT + || endpointConfiguration.isEndpointEnabledForUri(meta.endpointPath()); + return enabled ? Optional.of(meta) : Optional.empty(); + } + return Optional.ofNullable(aiOps.get(id)); + } + + /** Replace the AI capabilities snapshot. Called by the engine refresh task. */ + public void replaceAiCapabilities(Map updated) { + // Build a fresh map then swap atomically via the volatile reference. The previous + // implementation did putAll-then-retainAll on a shared ConcurrentHashMap, which left a + // transient window where readers could observe stale entries that should have been + // removed (race between the two structural updates). + Map next = new ConcurrentHashMap<>(updated); + this.aiOps = next; + log.info("MCP tool catalog AI capabilities replaced: {} entries", next.size()); + } + + /** Only POST/PUT endpoints are exposed as tools; DELETE and GET are excluded. */ + static boolean isInvocableMethod(Set methods) { + return methods.contains(RequestMethod.POST) || methods.contains(RequestMethod.PUT); + } + + private static String extractOpId(String pattern, OperationCategory category) { + if (category.urlPrefix() == null || !pattern.startsWith(category.urlPrefix())) { + return null; + } + String tail = pattern.substring(category.urlPrefix().length()); + if (tail.isBlank() || tail.contains("/") || tail.contains("{")) { + // Skip nested paths and path-variable templates. + return null; + } + return tail; + } + + private static String prettifyOpId(String id) { + return id.replace('-', ' '); + } + + private static Set extractPatterns(RequestMappingInfo info) { + try { + Method getDirectPaths = info.getClass().getMethod("getDirectPaths"); + Object result = getDirectPaths.invoke(info); + if (result instanceof Set set) { + Set patterns = new TreeSet<>(); + for (Object v : set) { + if (v instanceof String s) { + patterns.add(s); + } + } + return patterns; + } + } catch (Exception e) { + log.trace("getDirectPaths unavailable on RequestMappingInfo", e); + } + return Collections.emptySet(); + } + + public Map snapshotPdfOps() { + return new LinkedHashMap<>(pdfOps); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/OperationCategory.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/OperationCategory.java new file mode 100644 index 000000000..7be79b796 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/OperationCategory.java @@ -0,0 +1,38 @@ +package stirling.software.proprietary.mcp.catalog; + +/** MCP tool categories; {@link #urlPrefix} maps a {@code /api/v1/} namespace to a category. */ +public enum OperationCategory { + CONVERT("/api/v1/convert/", "stirling_convert"), + PAGES("/api/v1/general/", "stirling_pages"), + MISC("/api/v1/misc/", "stirling_misc"), + SECURITY("/api/v1/security/", "stirling_security"), + AI(null, "stirling_ai"); + + private final String urlPrefix; + private final String toolName; + + OperationCategory(String urlPrefix, String toolName) { + this.urlPrefix = urlPrefix; + this.toolName = toolName; + } + + public String urlPrefix() { + return urlPrefix; + } + + public String toolName() { + return toolName; + } + + public static OperationCategory fromUrl(String url) { + if (url == null) { + return null; + } + for (OperationCategory c : values()) { + if (c.urlPrefix != null && url.startsWith(c.urlPrefix)) { + return c; + } + } + return null; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/OperationMeta.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/OperationMeta.java new file mode 100644 index 000000000..a9d8b2a9a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/OperationMeta.java @@ -0,0 +1,22 @@ +package stirling.software.proprietary.mcp.catalog; + +import org.springframework.web.method.HandlerMethod; + +import tools.jackson.databind.node.ObjectNode; + +/** Metadata for one MCP-exposed operation (PDF endpoint or AI capability). */ +public record OperationMeta( + String id, + OperationCategory category, + String summary, + ObjectNode paramSchema, + String requiredScope, + Target target, + String endpointPath, + HandlerMethod handlerMethod) { + + public enum Target { + JAVA_ENDPOINT, + ENGINE_CAPABILITY + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/SimpleSchemaGenerator.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/SimpleSchemaGenerator.java new file mode 100644 index 000000000..0cef8d32b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/SimpleSchemaGenerator.java @@ -0,0 +1,178 @@ +package stirling.software.proprietary.mcp.catalog; + +import java.lang.reflect.Field; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.springframework.web.multipart.MultipartFile; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; + +/** + * Reflection-based JSON Schema generator for controller request-body classes. {@link MultipartFile} + * fields are emitted as {@code "type":"string"} with a {@code "format":"file-id"} hint. + */ +public final class SimpleSchemaGenerator { + + private final ObjectMapper mapper; + + public SimpleSchemaGenerator(ObjectMapper mapper) { + this.mapper = mapper; + } + + public ObjectNode toSchema(Class type) { + return toSchema(type, new HashSet<>()); + } + + private ObjectNode toSchema(Class type, Set> visited) { + ObjectNode schema = mapper.createObjectNode(); + schema.put("type", "object"); + schema.put("additionalProperties", false); + ObjectNode properties = schema.putObject("properties"); + ArrayNode required = mapper.createArrayNode(); + if (!visited.add(type)) { + // Cycle: emit a loose object and bail. + schema.put("additionalProperties", true); + return schema; + } + + Set seen = new HashSet<>(); + for (Field field : collectFields(type)) { + if (java.lang.reflect.Modifier.isStatic(field.getModifiers()) + || java.lang.reflect.Modifier.isTransient(field.getModifiers())) { + continue; + } + // Skip fields Jackson won't (de)serialize. + if (field.isAnnotationPresent(JsonIgnore.class)) { + continue; + } + String name = jsonPropertyName(field); + if (!seen.add(name)) { + continue; + } + properties.set(name, typeSchema(field.getGenericType(), visited)); + if (isRequired(field)) { + required.add(name); + } + } + + if (!required.isEmpty()) { + schema.set("required", required); + } + return schema; + } + + private List collectFields(Class type) { + List all = new ArrayList<>(); + for (Class c = type; c != null && c != Object.class; c = c.getSuperclass()) { + for (Field f : c.getDeclaredFields()) { + all.add(f); + } + } + return all; + } + + private static String jsonPropertyName(Field field) { + JsonProperty ann = field.getAnnotation(JsonProperty.class); + if (ann != null && !ann.value().isEmpty()) { + return ann.value(); + } + return field.getName(); + } + + private boolean isRequired(Field field) { + JsonProperty json = field.getAnnotation(JsonProperty.class); + if (json != null && json.required()) { + return true; + } + return field.isAnnotationPresent(jakarta.validation.constraints.NotNull.class) + || field.isAnnotationPresent(jakarta.validation.constraints.NotBlank.class) + || field.isAnnotationPresent(jakarta.validation.constraints.NotEmpty.class); + } + + private ObjectNode typeSchema(Type t, Set> visited) { + ObjectNode out = mapper.createObjectNode(); + if (t instanceof Class c) { + populatePrimitive(out, c, visited); + } else if (t instanceof ParameterizedType pt) { + Type raw = pt.getRawType(); + if (raw instanceof Class rawClass) { + if (java.util.Collection.class.isAssignableFrom(rawClass)) { + out.put("type", "array"); + Type[] args = pt.getActualTypeArguments(); + if (args.length == 1) { + out.set("items", typeSchema(args[0], visited)); + } + } else if (java.util.Map.class.isAssignableFrom(rawClass)) { + out.put("type", "object"); + out.put("additionalProperties", true); + } else { + populatePrimitive(out, rawClass, visited); + } + } else { + out.put("type", "object"); + } + } else { + out.put("type", "object"); + } + return out; + } + + private void populatePrimitive(ObjectNode out, Class c, Set> visited) { + if (MultipartFile.class.isAssignableFrom(c)) { + out.put("type", "string"); + out.put("format", "file-id"); + out.put( + "description", + "Reference to a previously-uploaded file in Stirling's job store."); + return; + } + if (c.isArray()) { + out.put("type", "array"); + out.set("items", typeSchema(c.getComponentType(), visited)); + return; + } + if (c == String.class) { + out.put("type", "string"); + } else if (c == boolean.class || c == Boolean.class) { + out.put("type", "boolean"); + } else if (c == int.class + || c == Integer.class + || c == long.class + || c == Long.class + || c == short.class + || c == Short.class + || c == byte.class + || c == Byte.class) { + out.put("type", "integer"); + } else if (c == float.class || c == Float.class || c == double.class || c == Double.class) { + out.put("type", "number"); + } else if (c.isEnum()) { + out.put("type", "string"); + ArrayNode values = out.putArray("enum"); + for (Object constant : c.getEnumConstants()) { + values.add(constant.toString()); + } + } else if (c == java.util.UUID.class) { + out.put("type", "string"); + out.put("format", "uuid"); + } else if (java.time.temporal.Temporal.class.isAssignableFrom(c) + || c == java.util.Date.class) { + out.put("type", "string"); + out.put("format", "date-time"); + } else { + // Complex bean: recurse with the shared visited set. + ObjectNode nested = toSchema(c, visited); + out.setAll(nested); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/engine/EngineCapabilityClient.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/engine/EngineCapabilityClient.java new file mode 100644 index 000000000..ed023bd25 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/engine/EngineCapabilityClient.java @@ -0,0 +1,204 @@ +package stirling.software.proprietary.mcp.engine; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.mcp.catalog.McpToolCatalog; +import stirling.software.proprietary.mcp.catalog.OperationCategory; +import stirling.software.proprietary.mcp.catalog.OperationMeta; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +/** + * Pulls the engine's capabilities manifest at boot and on a schedule, feeding it into the shared + * {@link McpToolCatalog}. + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") +public class EngineCapabilityClient { + + private final ApplicationProperties applicationProperties; + private final McpToolCatalog catalog; + private final ObjectMapper mapper; + private final HttpClient httpClient; + private final String sharedSecret; + + private ScheduledExecutorService scheduler; + + public EngineCapabilityClient( + ApplicationProperties applicationProperties, + McpToolCatalog catalog, + ObjectMapper mapper) { + this.applicationProperties = applicationProperties; + this.catalog = catalog; + this.mapper = mapper; + this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + this.sharedSecret = System.getenv("STIRLING_ENGINE_SHARED_SECRET"); + } + + @PostConstruct + void start() { + scheduler = + Executors.newSingleThreadScheduledExecutor( + r -> { + Thread t = new Thread(r, "mcp-engine-capability-refresh"); + t.setDaemon(true); + return t; + }); + } + + @EventListener(ApplicationReadyEvent.class) + public void onReady() { + long minutes = + Math.max(1, applicationProperties.getMcp().getEngineCapabilityRefreshMinutes()); + // First refresh immediately, then on the configured cadence. + scheduler.schedule(this::refreshSafely, 0, TimeUnit.SECONDS); + scheduler.scheduleAtFixedRate(this::refreshSafely, minutes, minutes, TimeUnit.MINUTES); + log.info("MCP engine capability refresh scheduled every {} minute(s)", minutes); + } + + @PreDestroy + void stop() { + if (scheduler != null) { + scheduler.shutdownNow(); + } + } + + private void refreshSafely() { + try { + refresh(); + } catch (Exception e) { + log.warn( + "MCP engine capability refresh failed ({}). AI tool enum stays at the last" + + " known state until the next successful pull.", + e.getMessage()); + } + } + + /** Visible for testing. */ + public void refresh() throws IOException, InterruptedException { + if (!applicationProperties.getAiEngine().isEnabled()) { + log.debug("AI engine disabled; skipping MCP capability refresh"); + catalog.replaceAiCapabilities(Map.of()); + return; + } + // Trim whitespace and any trailing slash to avoid a malformed URI. + String base = applicationProperties.getAiEngine().getUrl().strip().replaceAll("/+$", ""); + URI uri = URI.create(base + "/api/v1/agents/capabilities"); + HttpRequest.Builder reqBuilder = + HttpRequest.newBuilder() + .uri(uri) + .timeout(Duration.ofSeconds(10)) + .header("Accept", "application/json") + .GET(); + if (sharedSecret != null && !sharedSecret.isBlank()) { + reqBuilder.header("X-Engine-Auth", sharedSecret); + } + HttpResponse response = + httpClient.send(reqBuilder.build(), HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new IOException( + "Engine capabilities endpoint returned HTTP " + response.statusCode()); + } + Map parsed = parseManifest(response.body()); + catalog.replaceAiCapabilities(parsed); + } + + private Map parseManifest(String body) throws IOException { + JsonNode root = mapper.readTree(body); + JsonNode capabilities = root.get("capabilities"); + if (capabilities == null || !capabilities.isArray()) { + throw new IOException("Manifest missing 'capabilities' array"); + } + Map out = new LinkedHashMap<>(); + for (JsonNode entry : capabilities) { + JsonNode id = entry.get("id"); + JsonNode desc = entry.get("description"); + JsonNode schema = entry.get("input_schema"); + JsonNode scope = entry.get("required_scope"); + JsonNode route = entry.get("route"); + if (id == null || !id.isTextual() || schema == null || !schema.isObject()) { + log.warn("Skipping malformed capability entry: {}", entry); + continue; + } + String routeValue = route == null || !route.isTextual() ? null : route.asText(); + if (routeValue != null && !isSafeRelativeRoute(routeValue)) { + // Defence in depth: a tampered manifest must not steer Java at an arbitrary + // host/path. + log.warn( + "Skipping capability '{}' with unsafe route '{}' (must be a server-relative" + + " /api path with no scheme, authority, or '..')", + id.asText(), + routeValue); + continue; + } + // Fail safe: default to the stricter write scope when the manifest omits one. + String requiredScope = + scope != null && scope.isTextual() && !scope.asText().isBlank() + ? scope.asText() + : WRITE_SCOPE; + ObjectNode schemaCopy = (ObjectNode) schema.deepCopy(); + out.put( + id.asText(), + new OperationMeta( + id.asText(), + OperationCategory.AI, + desc == null ? id.asText() : desc.asText(), + schemaCopy, + requiredScope, + OperationMeta.Target.ENGINE_CAPABILITY, + routeValue, + null)); + } + return out; + } + + private static final String WRITE_SCOPE = "mcp.tools.write"; + + /** + * True only for a server-relative {@code /api/} path with no scheme, authority, {@code ..}, or + * control chars (blocks SSRF / path escape). + */ + static boolean isSafeRelativeRoute(String route) { + if (route == null || route.isBlank() || !route.startsWith("/api/")) { + return false; + } + if (route.startsWith("//") + || route.contains("..") + || route.contains("@") + || route.contains("\\") + || route.contains(":")) { + return false; + } + for (int i = 0; i < route.length(); i++) { + char c = route.charAt(i); + if (c <= ' ') { + return false; + } + } + return true; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/jsonrpc/JsonRpcError.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/jsonrpc/JsonRpcError.java new file mode 100644 index 000000000..e383605a5 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/jsonrpc/JsonRpcError.java @@ -0,0 +1,36 @@ +package stirling.software.proprietary.mcp.jsonrpc; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import tools.jackson.databind.JsonNode; + +/** JSON-RPC 2.0 error object. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record JsonRpcError(int code, String message, JsonNode data) { + + public static final int PARSE_ERROR = -32700; + public static final int INVALID_REQUEST = -32600; + public static final int METHOD_NOT_FOUND = -32601; + public static final int INVALID_PARAMS = -32602; + public static final int INTERNAL_ERROR = -32603; + + public static JsonRpcError parseError(String message) { + return new JsonRpcError(PARSE_ERROR, message, null); + } + + public static JsonRpcError invalidRequest(String message) { + return new JsonRpcError(INVALID_REQUEST, message, null); + } + + public static JsonRpcError methodNotFound(String method) { + return new JsonRpcError(METHOD_NOT_FOUND, "Method not found: " + method, null); + } + + public static JsonRpcError invalidParams(String message) { + return new JsonRpcError(INVALID_PARAMS, message, null); + } + + public static JsonRpcError internalError(String message) { + return new JsonRpcError(INTERNAL_ERROR, message, null); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/jsonrpc/JsonRpcRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/jsonrpc/JsonRpcRequest.java new file mode 100644 index 000000000..01466746e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/jsonrpc/JsonRpcRequest.java @@ -0,0 +1,14 @@ +package stirling.software.proprietary.mcp.jsonrpc; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import tools.jackson.databind.JsonNode; + +/** JSON-RPC 2.0 request frame; a null {@code id} marks a notification. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record JsonRpcRequest(String jsonrpc, JsonNode id, String method, JsonNode params) { + + public boolean isNotification() { + return id == null || id.isNull(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/jsonrpc/JsonRpcResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/jsonrpc/JsonRpcResponse.java new file mode 100644 index 000000000..e01898bde --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/jsonrpc/JsonRpcResponse.java @@ -0,0 +1,18 @@ +package stirling.software.proprietary.mcp.jsonrpc; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import tools.jackson.databind.JsonNode; + +/** JSON-RPC 2.0 response; exactly one of {@code result} or {@code error} is non-null. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record JsonRpcResponse(String jsonrpc, JsonNode id, Object result, JsonRpcError error) { + + public static JsonRpcResponse success(JsonNode id, Object result) { + return new JsonRpcResponse("2.0", id, result, null); + } + + public static JsonRpcResponse failure(JsonNode id, JsonRpcError error) { + return new JsonRpcResponse("2.0", id, null, error); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpApiKeyAuthFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpApiKeyAuthFilter.java new file mode 100644 index 000000000..e45dadb0c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpApiKeyAuthFilter.java @@ -0,0 +1,85 @@ +package stirling.software.proprietary.mcp.security; + +import java.io.IOException; +import java.util.List; +import java.util.Optional; + +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.UserService; + +/** + * API-key auth for the MCP endpoint: validates a Stirling per-user API key and binds the request to + * that user with the MCP scopes. + */ +@Slf4j +public class McpApiKeyAuthFilter extends OncePerRequestFilter { + + private static final List MCP_SCOPES = + List.of( + new SimpleGrantedAuthority("SCOPE_mcp.tools.read"), + new SimpleGrantedAuthority("SCOPE_mcp.tools.write")); + + private final UserService userService; + + public McpApiKeyAuthFilter(UserService userService) { + this.userService = userService; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + Authentication existing = SecurityContextHolder.getContext().getAuthentication(); + // Treat an anonymous token as not authenticated so the key is still processed. + boolean unauthenticated = + existing == null + || existing instanceof AnonymousAuthenticationToken + || !existing.isAuthenticated(); + if (unauthenticated) { + String apiKey = extractKey(request); + if (apiKey != null && !apiKey.isBlank()) { + Optional user = userService.getUserByApiKey(apiKey); + if (user.isPresent() && user.get().isEnabled()) { + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken( + user.get().getUsername(), null, MCP_SCOPES); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(auth); + SecurityContextHolder.setContext(context); + } else { + log.warn( + "MCP access denied: presented API key did not match an active account"); + } + } + } + filterChain.doFilter(request, response); + } + + private String extractKey(HttpServletRequest request) { + String headerKey = request.getHeader("X-API-KEY"); + if (headerKey != null && !headerKey.isBlank()) { + return headerKey.trim(); + } + String authz = request.getHeader("Authorization"); + if (authz != null && authz.regionMatches(true, 0, "Bearer ", 0, 7)) { + return authz.substring(7).trim(); + } + return null; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAudienceValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAudienceValidator.java new file mode 100644 index 000000000..7430776de --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAudienceValidator.java @@ -0,0 +1,44 @@ +package stirling.software.proprietary.mcp.security; + +import java.util.List; + +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; + +/** + * RFC 8707 audience binding: a JWT at the MCP endpoint must list this server's resource id in its + * {@code aud} claim. Fails closed when the resource id is unset. + */ +public class McpAudienceValidator implements OAuth2TokenValidator { + + private final String expectedResourceId; + + public McpAudienceValidator(String expectedResourceId) { + this.expectedResourceId = expectedResourceId == null ? "" : expectedResourceId; + } + + @Override + public OAuth2TokenValidatorResult validate(Jwt token) { + if (expectedResourceId.isBlank()) { + return OAuth2TokenValidatorResult.failure( + new OAuth2Error( + "invalid_token", + "MCP server has no resource id configured; rejecting all tokens" + + " until mcp.auth.resource-id is set.", + null)); + } + List aud = token.getAudience(); + if (aud == null || !aud.contains(expectedResourceId)) { + return OAuth2TokenValidatorResult.failure( + new OAuth2Error( + "invalid_token", + "Token audience does not include this server's resource id (" + + expectedResourceId + + ").", + null)); + } + return OAuth2TokenValidatorResult.success(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAuthenticationEntryPoint.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAuthenticationEntryPoint.java new file mode 100644 index 000000000..5139ea162 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAuthenticationEntryPoint.java @@ -0,0 +1,76 @@ +package stirling.software.proprietary.mcp.security; + +import java.io.IOException; + +import org.springframework.http.HttpStatus; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * Emits 401 + {@code WWW-Authenticate: Bearer resource_metadata="..."} (RFC 9728), preferring + * X-Forwarded-* headers to build the public-facing metadata URL. + */ +public class McpAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final String metadataPath; + + public McpAuthenticationEntryPoint(String metadataPath) { + this.metadataPath = + metadataPath == null ? "/.well-known/oauth-protected-resource" : metadataPath; + } + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) + throws IOException { + String scheme = firstForwarded(request, "X-Forwarded-Proto", request.getScheme()); + String authority = forwardedHost(request, scheme); + String metadataUrl = scheme + "://" + authority + metadataPath; + response.setHeader( + "WWW-Authenticate", + "Bearer error=\"invalid_token\", resource_metadata=\"" + metadataUrl + "\""); + response.sendError(HttpStatus.UNAUTHORIZED.value(), "Unauthorized"); + } + + /** host[:port] from forwarded headers when present, else the servlet host/port. */ + private static String forwardedHost(HttpServletRequest request, String scheme) { + String host = firstForwarded(request, "X-Forwarded-Host", null); + if (host != null && !host.isBlank()) { + // X-Forwarded-Host may already carry a port. + if (host.contains(":")) { + return host; + } + String fwdPort = firstForwarded(request, "X-Forwarded-Port", null); + if (fwdPort != null && !isDefaultPort(scheme, fwdPort)) { + return host + ":" + fwdPort; + } + return host; + } + String authority = request.getServerName(); + int port = request.getServerPort(); + if (port > 0 && !isDefaultPort(scheme, Integer.toString(port))) { + authority = authority + ":" + port; + } + return authority; + } + + /** First (client-most) value of a possibly comma-listed forwarded header, trimmed. */ + private static String firstForwarded(HttpServletRequest request, String name, String fallback) { + String value = request.getHeader(name); + if (value == null || value.isBlank()) { + return fallback; + } + int comma = value.indexOf(','); + return (comma >= 0 ? value.substring(0, comma) : value).trim(); + } + + private static boolean isDefaultPort(String scheme, String port) { + return ("http".equals(scheme) && "80".equals(port)) + || ("https".equals(scheme) && "443".equals(port)); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpRequestSizeFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpRequestSizeFilter.java new file mode 100644 index 000000000..b6c410a30 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpRequestSizeFilter.java @@ -0,0 +1,128 @@ +package stirling.software.proprietary.mcp.security; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import jakarta.servlet.http.HttpServletResponse; + +/** + * Caps MCP request body size (via Content-Length and by buffering up to the cap) and rejects + * oversized bodies with a clean 413 before JSON parsing. + */ +public class McpRequestSizeFilter extends OncePerRequestFilter { + + private final long maxBodyBytes; + + public McpRequestSizeFilter(long maxBodyBytes) { + this.maxBodyBytes = maxBodyBytes > 0 ? maxBodyBytes : 256L * 1024L; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + long declared = request.getContentLengthLong(); + if (declared > maxBodyBytes) { + tooLarge(response); + return; + } + byte[] body; + try { + body = readUpTo(request.getInputStream(), maxBodyBytes); + } catch (BodyTooLargeException e) { + tooLarge(response); + return; + } + filterChain.doFilter(new CachedBodyRequest(request, body), response); + } + + private static byte[] readUpTo(InputStream in, long max) throws IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] chunk = new byte[8192]; + long total = 0; + int n; + while ((n = in.read(chunk)) != -1) { + total += n; + if (total > max) { + throw new BodyTooLargeException(); + } + buffer.write(chunk, 0, n); + } + return buffer.toByteArray(); + } + + private void tooLarge(HttpServletResponse response) throws IOException { + response.setStatus(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE); + response.setContentType("application/json"); + response.getWriter() + .write( + "{\"error\":\"payload_too_large\",\"message\":\"MCP request body exceeds the" + + " configured limit of " + + maxBodyBytes + + " bytes.\"}"); + } + + private static final class BodyTooLargeException extends IOException {} + + /** Re-serves the buffered body to the controller. */ + private static final class CachedBodyRequest extends HttpServletRequestWrapper { + private final byte[] body; + + CachedBodyRequest(HttpServletRequest request, byte[] body) { + super(request); + this.body = body; + } + + @Override + public ServletInputStream getInputStream() { + ByteArrayInputStream source = new ByteArrayInputStream(body); + return new ServletInputStream() { + @Override + public int read() { + return source.read(); + } + + @Override + public int read(byte[] b, int off, int len) { + return source.read(b, off, len); + } + + @Override + public boolean isFinished() { + return source.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener readListener) { + // Synchronous buffered body; no async reads. + } + }; + } + + @Override + public BufferedReader getReader() { + String enc = getCharacterEncoding(); + Charset cs = enc == null ? StandardCharsets.UTF_8 : Charset.forName(enc); + return new BufferedReader(new InputStreamReader(new ByteArrayInputStream(body), cs)); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpSecurityConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpSecurityConfig.java new file mode 100644 index 000000000..ab6341ed3 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpSecurityConfig.java @@ -0,0 +1,262 @@ +package stirling.software.proprietary.mcp.security; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; +import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.intercept.AuthorizationFilter; +import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; +import org.springframework.web.cors.CorsConfigurationSource; + +import jakarta.annotation.PostConstruct; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.service.UserService; + +/** + * MCP security chain: validates JWTs (JWKS + RFC 8707 audience), maps scope claims to authorities, + * and fails closed when the issuer is unset. + */ +@Slf4j +@Configuration +@Order(Ordered.HIGHEST_PRECEDENCE) +@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") +public class McpSecurityConfig { + + private final ApplicationProperties applicationProperties; + private final UserService userService; + + // Reuse the app's CORS config; ObjectProvider so the chain still wires when no CORS bean + // exists. + private final ObjectProvider corsConfigurationSource; + + private static final String BASE_PATH = "/mcp"; + + public McpSecurityConfig( + ApplicationProperties applicationProperties, + @Lazy UserService userService, + ObjectProvider corsConfigurationSource) { + this.applicationProperties = applicationProperties; + this.userService = userService; + this.corsConfigurationSource = corsConfigurationSource; + } + + /** Enable CORS on the MCP chain using the app-wide source when available. */ + private void applyCors(HttpSecurity http) throws Exception { + CorsConfigurationSource source = corsConfigurationSource.getIfAvailable(); + if (source != null) { + http.cors(cors -> cors.configurationSource(source)); + } + } + + @PostConstruct + void warnIfMisconfigured() { + ApplicationProperties.Mcp mcp = applicationProperties.getMcp(); + if (isApiKeyMode()) { + log.info( + "MCP auth mode = apikey: clients authenticate with a Stirling per-user API key" + + " (X-API-KEY header). No OAuth issuer required."); + } else { + if (mcp.getAuth().getIssuerUri().isBlank()) { + log.warn( + "MCP enabled but mcp.auth.issuer-uri is blank - JWT decoder will reject" + + " every token (fail-closed). Set mcp.auth.issuer-uri and" + + " mcp.auth.resource-id before exposing /mcp to clients."); + } + if (mcp.getAuth().getResourceId().isBlank()) { + log.warn( + "MCP enabled but mcp.auth.resource-id is blank - audience validator will" + + " reject every token. Set this to the public URL of the MCP" + + " endpoint (RFC 8707)."); + } + } + } + + @Bean + @Order(0) + SecurityFilterChain mcpSecurityFilterChain(HttpSecurity http, JwtDecoder mcpJwtDecoder) + throws Exception { + ApplicationProperties.Mcp.Auth auth = applicationProperties.getMcp().getAuth(); + if (isApiKeyMode()) { + return apiKeyFilterChain(http); + } + return oauthFilterChain(http, mcpJwtDecoder, auth); + } + + private boolean isApiKeyMode() { + return "apikey".equalsIgnoreCase(applicationProperties.getMcp().getAuth().getMode()); + } + + /** + * API-key chain: a Stirling per-user API key is validated by {@link McpApiKeyAuthFilter}; + * otherwise 401. + */ + private SecurityFilterChain apiKeyFilterChain(HttpSecurity http) throws Exception { + applyCors(http); + http.securityMatcher(BASE_PATH, BASE_PATH + "/**") + // CSRF intentionally disabled: /mcp is a stateless JSON-RPC API authenticated by an + // out-of-band X-API-KEY header (or Authorization: Bearer ). No cookies, no + // session, no form submissions; a browser cannot trick a victim into sending the + // header cross-origin, so the CSRF attack model does not apply. CodeQL flags this + // generically; the SessionCreationPolicy.STATELESS below is the relevant guarantee. + .csrf(csrf -> csrf.disable()) + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(a -> a.anyRequest().authenticated()) + .exceptionHandling( + e -> + e.authenticationEntryPoint( + (request, response, ex) -> { + response.setStatus(401); + response.setHeader( + "WWW-Authenticate", + "Bearer realm=\"Stirling MCP (API key)\""); + response.setContentType("application/json"); + response.getWriter() + .write( + "{\"error\":\"unauthorized\",\"message\":\"Provide a valid Stirling API key via the X-API-KEY header (or Authorization: Bearer ).\"}"); + })) + .addFilterBefore( + new McpRequestSizeFilter( + applicationProperties.getMcp().getMaxRequestBytes()), + AuthorizationFilter.class) + // Authenticate before the anonymous filter sets an anonymous token. + .addFilterBefore( + new McpApiKeyAuthFilter(userService), AnonymousAuthenticationFilter.class); + return http.build(); + } + + /** OAuth2 resource-server chain (JWT, RFC 8707 audience, RFC 9728 metadata). */ + private SecurityFilterChain oauthFilterChain( + HttpSecurity http, JwtDecoder mcpJwtDecoder, ApplicationProperties.Mcp.Auth auth) + throws Exception { + String metadataPath = "/.well-known/oauth-protected-resource"; + applyCors(http); + http.securityMatcher(BASE_PATH, BASE_PATH + "/**", metadataPath) + // CSRF intentionally disabled: /mcp is a stateless JSON-RPC resource server + // authenticated by OAuth2 Bearer JWTs (Authorization header). No cookies, no + // session, no form submissions; CSRF requires browser-attached ambient credentials + // and the bearer token is supplied per-request by the MCP client. CodeQL flags + // this generically; the SessionCreationPolicy.STATELESS below is the actual + // guarantee, and the .well-known metadata endpoint only serves GET. + .csrf(csrf -> csrf.disable()) + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests( + a -> + a.requestMatchers(HttpMethod.GET, metadataPath) + .permitAll() + .anyRequest() + .authenticated()) + // Cap body size pre-auth, then bind the validated token to a Stirling user after + // the bearer filter. + .addFilterBefore( + new McpRequestSizeFilter( + applicationProperties.getMcp().getMaxRequestBytes()), + BearerTokenAuthenticationFilter.class) + .addFilterAfter( + new McpUserBindingFilter( + userService, + auth.getUsernameClaim(), + auth.isRequireExistingAccount()), + BearerTokenAuthenticationFilter.class) + .oauth2ResourceServer( + oauth2 -> + oauth2.authenticationEntryPoint( + new McpAuthenticationEntryPoint(metadataPath)) + // RFC 9728 protected-resource metadata for OAuth discovery. + .protectedResourceMetadata( + prm -> + prm.protectedResourceMetadataCustomizer( + builder -> { + if (!auth.getResourceId() + .isBlank()) { + builder.resource( + auth + .getResourceId()); + } + if (!auth.getIssuerUri() + .isBlank()) { + builder.authorizationServer( + auth + .getIssuerUri()); + } + builder.scope("mcp.tools.read"); + builder.scope( + "mcp.tools.write"); + })) + .jwt( + jwt -> + jwt.decoder(mcpJwtDecoder) + .jwtAuthenticationConverter( + mcpJwtAuthenticationConverter()))); + return http.build(); + } + + @Bean + JwtDecoder mcpJwtDecoder() { + ApplicationProperties.Mcp.Auth auth = applicationProperties.getMcp().getAuth(); + if (auth.getIssuerUri().isBlank()) { + // Fail-closed decoder: rejects every token until the issuer is set. + return token -> { + throw new org.springframework.security.oauth2.jwt.BadJwtException( + "mcp.auth.issuer-uri is not configured"); + }; + } + String jwksUri = auth.getJwksUri(); + NimbusJwtDecoder decoder = + jwksUri.isBlank() + ? NimbusJwtDecoder.withIssuerLocation(auth.getIssuerUri()).build() + : NimbusJwtDecoder.withJwkSetUri(jwksUri).build(); + OAuth2TokenValidator defaultValidators = + JwtValidators.createDefaultWithIssuer(auth.getIssuerUri()); + OAuth2TokenValidator combined = + new DelegatingOAuth2TokenValidator<>( + defaultValidators, new McpAudienceValidator(auth.getResourceId())); + decoder.setJwtValidator(combined); + return decoder; + } + + private Converter mcpJwtAuthenticationConverter() { + JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter(); + scopes.setAuthorityPrefix("SCOPE_"); + scopes.setAuthoritiesClaimName("scope"); + JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); + converter.setJwtGrantedAuthoritiesConverter( + jwt -> { + Collection out = new ArrayList<>(scopes.convert(jwt)); + List aud = jwt.getAudience(); + if (aud != null) { + for (String a : aud) { + out.add(new SimpleGrantedAuthority("AUDIENCE_" + a)); + } + } + return out; + }); + return converter; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpUserBindingFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpUserBindingFilter.java new file mode 100644 index 000000000..59dfbbfac --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpUserBindingFilter.java @@ -0,0 +1,115 @@ +package stirling.software.proprietary.mcp.security; + +import java.io.IOException; +import java.util.Optional; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.UserService; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +/** + * Binds an MCP-validated JWT to a provisioned Stirling user: optionally rejects subjects with no + * enabled account, then rebinds the principal to the canonical Stirling username (scope authorities + * only) so audit/metering attribute correctly. + */ +@Slf4j +public class McpUserBindingFilter extends OncePerRequestFilter { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final UserService userService; + private final String usernameClaim; + private final boolean requireExistingAccount; + + public McpUserBindingFilter( + UserService userService, String usernameClaim, boolean requireExistingAccount) { + this.userService = userService; + this.usernameClaim = + (usernameClaim == null || usernameClaim.isBlank()) ? "sub" : usernameClaim; + this.requireExistingAccount = requireExistingAccount; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + Authentication current = SecurityContextHolder.getContext().getAuthentication(); + + // Only act on a JWT-authenticated request; everything else passes through. + if (current instanceof JwtAuthenticationToken jwtAuth && jwtAuth.isAuthenticated()) { + Jwt jwt = jwtAuth.getToken(); + String username = jwt.getClaimAsString(usernameClaim); + + if (username == null || username.isBlank()) { + reject( + response, + "Token is missing the '" + + usernameClaim + + "' claim used to map to a" + + " Stirling user."); + return; + } + + // Prefer the canonical username from the account record; fall back to the claim when + // binding is off. + String boundUsername = username; + if (requireExistingAccount) { + Optional account = userService.findByUsernameIgnoreCase(username); + if (account.isEmpty() || !account.get().isEnabled()) { + log.warn( + "MCP access denied: token subject '{}' has no active Stirling account", + sanitizeForLog(username)); + reject( + response, + "MCP access requires a provisioned, enabled Stirling account for this" + + " subject."); + return; + } + boundUsername = account.get().getUsername(); + } + + // Rebind to the Stirling username, carrying only the OAuth scope authorities. + UsernamePasswordAuthenticationToken bound = + new UsernamePasswordAuthenticationToken( + boundUsername, null, jwtAuth.getAuthorities()); + bound.setDetails(jwtAuth.getDetails()); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(bound); + SecurityContextHolder.setContext(context); + } + + filterChain.doFilter(request, response); + } + + /** Strip CR/LF so a crafted claim value can't forge log lines. */ + private static String sanitizeForLog(String value) { + return value == null ? null : value.replace('\r', ' ').replace('\n', ' '); + } + + private void reject(HttpServletResponse response, String message) throws IOException { + SecurityContextHolder.clearContext(); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json"); + ObjectNode body = MAPPER.createObjectNode(); + body.put("error", "insufficient_account"); + body.put("message", message); + response.getWriter().write(MAPPER.writeValueAsString(body)); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/AbstractCategoryTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/AbstractCategoryTool.java new file mode 100644 index 000000000..8c75ee145 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/AbstractCategoryTool.java @@ -0,0 +1,154 @@ +package stirling.software.proprietary.mcp.tools; + +import java.util.List; + +import org.springframework.beans.factory.ObjectProvider; + +import stirling.software.proprietary.mcp.McpCallContext; +import stirling.software.proprietary.mcp.McpTool; +import stirling.software.proprietary.mcp.catalog.McpToolCatalog; +import stirling.software.proprietary.mcp.catalog.OperationCategory; +import stirling.software.proprietary.mcp.catalog.OperationMeta; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; + +/** + * Common scaffolding for the PDF category tools. Operation ids and summaries come from the live + * {@link McpToolCatalog}. + */ +abstract class AbstractCategoryTool implements McpTool { + + protected final ObjectMapper mapper; + protected final ObjectProvider catalogProvider; + protected final ObjectProvider executorProvider; + + protected AbstractCategoryTool( + ObjectMapper mapper, + ObjectProvider catalog, + ObjectProvider executor) { + this.mapper = mapper; + this.catalogProvider = catalog; + this.executorProvider = executor; + } + + protected abstract OperationCategory category(); + + protected List enabledOperations() { + McpToolCatalog catalog = catalogProvider.getIfAvailable(); + if (catalog == null) { + return List.of(); + } + return catalog.enabledOps(category()); + } + + @Override + public ObjectNode inputSchema() { + ObjectNode schema = mapper.createObjectNode(); + schema.put("type", "object"); + schema.put("additionalProperties", false); + + ObjectNode props = schema.putObject("properties"); + + ObjectNode op = props.putObject("operation"); + op.put("type", "string"); + List enabled = enabledOperations(); + StringBuilder opDesc = new StringBuilder(); + opDesc.append( + "Operation id from this category. Call stirling_describe_operation first to learn" + + " the exact parameters schema. Available operations:\n"); + ArrayNode opEnum = op.putArray("enum"); + for (OperationMeta m : enabled) { + opEnum.add(m.id()); + opDesc.append("- ").append(m.id()).append(" - ").append(m.summary()).append('\n'); + } + op.put("description", opDesc.toString().trim()); + + ObjectNode params = props.putObject("parameters"); + params.put("type", "object"); + params.put( + "description", + "Per-operation parameters. Schema available via stirling_describe_operation."); + params.put("additionalProperties", true); + + McpToolSupport.stringProperty( + props, + "file", + "Base64-encoded file content to process. The recommended way to provide a file for" + + " most uses. Bounded by the MCP request size limit; for very large files" + + " use 'fileId' instead."); + McpToolSupport.stringProperty( + props, + "fileName", + "Optional original filename (with extension) for the input; helps operations that" + + " key off file type."); + McpToolSupport.stringProperty( + props, + "fileId", + "Reference to a file already stored via stirling_upload. Recommended only for large" + + " files or multi-step workflows; most users should pass the file inline" + + " via 'file' instead."); + + ArrayNode required = schema.putArray("required"); + required.add("operation"); + return schema; + } + + @Override + public ObjectNode call(JsonNode arguments, McpCallContext context) { + JsonNode opNode = arguments == null ? null : arguments.get("operation"); + // No operation chosen: return this category's operation list. + if (opNode == null || !opNode.isTextual() || opNode.asText().isBlank()) { + return operationListError(null); + } + String opId = opNode.asText(); + McpToolCatalog catalog = catalogProvider.getIfAvailable(); + if (catalog == null) { + return McpResponses.error(mapper, "MCP catalog is not available"); + } + OperationMeta meta = catalog.findByOperationId(opId).orElse(null); + // Invalid/disabled/wrong-category op: return this category's operations. + if (meta == null || meta.category() != category()) { + return operationListError(opId); + } + if (!context.hasScope(meta.requiredScope())) { + return McpResponses.error( + mapper, + "Insufficient scope: this operation requires '" + meta.requiredScope() + "'."); + } + McpOperationExecutor executor = executorProvider.getIfAvailable(); + if (executor == null) { + return McpResponses.error(mapper, "MCP execution is not available."); + } + return executor.execute(meta, arguments); + } + + /** + * Error for a missing/unknown operation, listing this category's available operation ids and + * summaries. + */ + private ObjectNode operationListError(String badOpId) { + StringBuilder sb = new StringBuilder(); + if (badOpId == null) { + sb.append("Missing required argument 'operation' for ").append(category().toolName()); + } else { + sb.append("Unknown or disabled operation '") + .append(badOpId) + .append("' for ") + .append(category().toolName()); + } + List ops = enabledOperations(); + if (ops.isEmpty()) { + sb.append(". No operations are currently available in this category."); + } else { + sb.append(". Available operations:"); + for (OperationMeta m : ops) { + sb.append("\n- ").append(m.id()).append(" - ").append(m.summary()); + } + sb.append("\nRe-call this tool with a valid 'operation'."); + } + return McpResponses.error(mapper, sb.toString()); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/DescribeOperationTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/DescribeOperationTool.java new file mode 100644 index 000000000..c93578136 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/DescribeOperationTool.java @@ -0,0 +1,84 @@ +package stirling.software.proprietary.mcp.tools; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import stirling.software.proprietary.mcp.McpCallContext; +import stirling.software.proprietary.mcp.McpTool; +import stirling.software.proprietary.mcp.catalog.McpToolCatalog; +import stirling.software.proprietary.mcp.catalog.OperationMeta; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; + +/** Returns the JSON Schema for one operation's parameters, from the live {@link McpToolCatalog}. */ +@Component +@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") +public class DescribeOperationTool implements McpTool { + + private final ObjectMapper mapper; + private final ObjectProvider catalogProvider; + + public DescribeOperationTool(ObjectMapper mapper, ObjectProvider catalog) { + this.mapper = mapper; + this.catalogProvider = catalog; + } + + @Override + public String name() { + return "stirling_describe_operation"; + } + + @Override + public String description() { + return "Return the full JSON Schema for one Stirling operation's parameters. Call this " + + "before invoking a category tool to learn the exact shape of `parameters`. " + + "Argument: { operation: } where appears in the enum of any " + + "category tool (stirling_convert, _pages, _misc, _security, _ai)."; + } + + @Override + public ObjectNode inputSchema() { + ObjectNode schema = mapper.createObjectNode(); + schema.put("type", "object"); + schema.put("additionalProperties", false); + ObjectNode props = schema.putObject("properties"); + ObjectNode op = props.putObject("operation"); + op.put("type", "string"); + op.put( + "description", + "Operation id (e.g. compress-pdf, pdf-to-word, q-and-a). See category tool enums."); + ArrayNode required = schema.putArray("required"); + required.add("operation"); + return schema; + } + + @Override + public ObjectNode call(JsonNode arguments, McpCallContext context) { + JsonNode opNode = arguments == null ? null : arguments.get("operation"); + if (opNode == null || !opNode.isTextual() || opNode.asText().isBlank()) { + return McpResponses.error(mapper, "Missing required argument: operation"); + } + String opId = opNode.asText(); + McpToolCatalog catalog = catalogProvider.getIfAvailable(); + if (catalog == null) { + return McpResponses.error(mapper, "MCP catalog is not available"); + } + OperationMeta meta = catalog.findByOperationId(opId).orElse(null); + if (meta == null) { + return McpResponses.error(mapper, "Unknown or disabled operation: " + opId); + } + + ObjectNode payload = mapper.createObjectNode(); + payload.put("operation", meta.id()); + payload.put("category", meta.category().toolName()); + payload.put("summary", meta.summary()); + payload.put("endpoint", meta.endpointPath()); + payload.put("requiredScope", meta.requiredScope()); + payload.set("parametersSchema", meta.paramSchema()); + return McpResponses.json(mapper, payload); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/McpOperationExecutor.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/McpOperationExecutor.java new file mode 100644 index 000000000..28cb56d96 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/McpOperationExecutor.java @@ -0,0 +1,255 @@ +package stirling.software.proprietary.mcp.tools; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClientResponseException; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.FileStorage; +import stirling.software.common.service.InternalApiClient; +import stirling.software.common.service.InternalApiTimeoutException; +import stirling.software.proprietary.mcp.catalog.OperationMeta; + +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +/** + * Runs a JAVA_ENDPOINT operation: resolves the input file (inline base64 or a fileId), dispatches + * to the Stirling endpoint over the loopback via {@link InternalApiClient}, and stores the result. + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") +public class McpOperationExecutor { + + private final ObjectMapper mapper; + private final InternalApiClient internalApiClient; + private final FileStorage fileStorage; + private final ApplicationProperties applicationProperties; + + public McpOperationExecutor( + ObjectMapper mapper, + InternalApiClient internalApiClient, + FileStorage fileStorage, + ApplicationProperties applicationProperties) { + this.mapper = mapper; + this.internalApiClient = internalApiClient; + this.fileStorage = fileStorage; + this.applicationProperties = applicationProperties; + } + + public ObjectNode execute(OperationMeta meta, JsonNode arguments) { + String fileName = McpToolSupport.textArg(arguments, "fileName"); + String fileId = McpToolSupport.textArg(arguments, "fileId"); + byte[] inputBytes; + String inputName; + if (fileId != null) { + try { + if (!fileStorage.fileExists(fileId)) { + return McpResponses.error( + mapper, + "Unknown or inaccessible fileId '" + + fileId + + "'. Re-upload with stirling_upload."); + } + inputBytes = fileStorage.retrieveBytes(fileId); + } catch (SecurityException e) { + return McpResponses.error( + mapper, + "Unknown or inaccessible fileId '" + + fileId + + "'. Re-upload with stirling_upload."); + } catch (IOException e) { + return McpResponses.error(mapper, "Could not read fileId '" + fileId + "'."); + } + inputName = fileName != null ? fileName : fileId; + } else { + String base64 = McpToolSupport.textArg(arguments, "file"); + if (base64 == null) { + return McpResponses.error( + mapper, + "This operation needs an input file. Pass 'file' as base64 (recommended for" + + " most files), or 'fileId' from stirling_upload for large files."); + } + inputBytes = McpToolSupport.decodeBase64OrNull(base64); + if (inputBytes == null) { + return McpResponses.error(mapper, "The 'file' argument is not valid base64."); + } + inputName = fileName != null ? fileName : "input.pdf"; + } + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("fileInput", bytesResource(inputBytes, inputName)); + addParameters(body, arguments == null ? null : arguments.get("parameters")); + + ResponseEntity response; + try { + response = internalApiClient.post(meta.endpointPath(), body); + } catch (InternalApiTimeoutException e) { + return McpResponses.error( + mapper, + meta.id() + + " timed out after " + + e.getReadTimeout().toSeconds() + + "s. Try a smaller file or a different approach."); + } catch (RestClientResponseException e) { + log.warn( + "MCP {} upstream error: HTTP {} - {}", + meta.id(), + e.getStatusCode().value(), + snippet(e.getResponseBodyAsString())); + return McpResponses.error( + mapper, meta.id() + " failed: HTTP " + e.getStatusCode().value() + "."); + } catch (SecurityException e) { + return McpResponses.error( + mapper, meta.id() + " endpoint is not permitted for MCP dispatch."); + } catch (RuntimeException e) { + log.warn("MCP execution of {} failed", meta.id(), e); + return McpResponses.error( + mapper, meta.id() + " failed unexpectedly. See server logs for details."); + } + return buildResult(meta, response); + } + + private ObjectNode buildResult(OperationMeta meta, ResponseEntity response) { + Resource body = response.getBody(); + if (body == null) { + return McpResponses.error(mapper, meta.id() + " returned an empty response."); + } + MediaType contentType = response.getHeaders().getContentType(); + + // A JSON body is a structured report (e.g. get-info), not a file. + if (contentType != null && MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) { + try (InputStream is = body.getInputStream()) { + return McpResponses.text( + mapper, new String(is.readAllBytes(), StandardCharsets.UTF_8)); + } catch (IOException e) { + return McpResponses.error(mapper, "Failed to read " + meta.id() + " result."); + } + } + + String filename = + body.getFilename() == null || body.getFilename().isBlank() + ? meta.id() + : body.getFilename(); + String mimeType = + contentType != null + ? contentType.toString() + : MediaType.APPLICATION_OCTET_STREAM_VALUE; + long maxInline = applicationProperties.getMcp().getMaxInlineResponseBytes(); + try { + long size = body.contentLength(); + byte[] inline = null; + if (size >= 0 && size <= maxInline) { + try (InputStream is = body.getInputStream()) { + inline = is.readAllBytes(); + } + } + String fileId = + inline != null + ? fileStorage.storeBytes(inline, filename) + : storeStreamed(body, filename); + String summary = + meta.id() + + " succeeded. Result: " + + filename + + " (" + + size + + " bytes), fileId=" + + fileId + + ". "; + if (inline != null) { + return McpResponses.result( + mapper, + false, + McpResponses.textBlock( + mapper, summary + "The file is included inline below."), + McpResponses.resourceBlock( + mapper, + "stirling://file/" + fileId, + mimeType, + Base64.getEncoder().encodeToString(inline))); + } + return McpResponses.result( + mapper, + false, + McpResponses.textBlock( + mapper, + summary + + "Large result - fetch it with stirling_download {\"fileId\":\"" + + fileId + + "\"}, or pass this fileId to another operation.")); + } catch (IOException e) { + return McpResponses.error(mapper, "Failed to store " + meta.id() + " result."); + } + } + + private String storeStreamed(Resource body, String filename) throws IOException { + try (InputStream is = body.getInputStream()) { + return fileStorage.storeInputStream(is, filename).fileId(); + } + } + + private void addParameters(MultiValueMap body, JsonNode params) { + if (params == null || !params.isObject()) { + return; + } + Map map = + mapper.convertValue(params, new TypeReference>() {}); + for (Map.Entry entry : map.entrySet()) { + Object value = entry.getValue(); + if (value == null) { + continue; + } + if (value instanceof List list) { + if (containsStructured(list)) { + body.add(entry.getKey(), mapper.writeValueAsString(list)); + } else { + list.forEach(item -> body.add(entry.getKey(), item)); + } + } else if (value instanceof Map) { + body.add(entry.getKey(), mapper.writeValueAsString(value)); + } else { + body.add(entry.getKey(), value); + } + } + } + + private static boolean containsStructured(List list) { + return list.stream().anyMatch(item -> item instanceof Map || item instanceof List); + } + + private static Resource bytesResource(byte[] bytes, String filename) { + return new ByteArrayResource(bytes) { + @Override + public String getFilename() { + return filename; + } + }; + } + + private static String snippet(String body) { + if (body == null || body.isBlank()) { + return "(no body)"; + } + String trimmed = body.strip(); + return trimmed.length() > 300 ? trimmed.substring(0, 300) + "..." : trimmed; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/McpResponses.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/McpResponses.java new file mode 100644 index 000000000..57ee2cdbd --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/McpResponses.java @@ -0,0 +1,80 @@ +package stirling.software.proprietary.mcp.tools; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; + +/** Helpers for the MCP {@code CallToolResult} response shape. */ +public final class McpResponses { + + private McpResponses() {} + + /** Plain-text content block. */ + public static ObjectNode text(ObjectMapper mapper, String text) { + ObjectNode block = mapper.createObjectNode(); + block.put("type", "text"); + block.put("text", text); + return wrap(mapper, block, false); + } + + /** Plain-text error ({@code isError:true}). */ + public static ObjectNode error(ObjectMapper mapper, String message) { + ObjectNode block = mapper.createObjectNode(); + block.put("type", "text"); + block.put("text", message); + return wrap(mapper, block, true); + } + + /** JSON payload as embedded text. */ + public static ObjectNode json(ObjectMapper mapper, ObjectNode payload) { + ObjectNode block = mapper.createObjectNode(); + block.put("type", "text"); + block.put("text", payload.toString()); + return wrap(mapper, block, false); + } + + /** A text content block (unwrapped). */ + public static ObjectNode textBlock(ObjectMapper mapper, String text) { + ObjectNode block = mapper.createObjectNode(); + block.put("type", "text"); + block.put("text", text); + return block; + } + + /** An embedded-resource content block carrying base64 file content. */ + public static ObjectNode resourceBlock( + ObjectMapper mapper, String uri, String mimeType, String base64) { + ObjectNode block = mapper.createObjectNode(); + block.put("type", "resource"); + ObjectNode res = block.putObject("resource"); + res.put("uri", uri); + if (mimeType != null) { + res.put("mimeType", mimeType); + } + res.put("blob", base64); + return block; + } + + /** Build a result from explicit content blocks. */ + public static ObjectNode result(ObjectMapper mapper, boolean isError, ObjectNode... blocks) { + ObjectNode result = mapper.createObjectNode(); + ArrayNode content = result.putArray("content"); + for (ObjectNode b : blocks) { + content.add(b); + } + if (isError) { + result.put("isError", true); + } + return result; + } + + private static ObjectNode wrap(ObjectMapper mapper, ObjectNode block, boolean isError) { + ObjectNode result = mapper.createObjectNode(); + ArrayNode content = result.putArray("content"); + content.add(block); + if (isError) { + result.put("isError", true); + } + return result; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/McpToolSupport.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/McpToolSupport.java new file mode 100644 index 000000000..dae928261 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/McpToolSupport.java @@ -0,0 +1,43 @@ +package stirling.software.proprietary.mcp.tools; + +import java.util.Base64; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.node.ObjectNode; + +/** Shared helpers for MCP tools: argument parsing and JSON-Schema building. */ +final class McpToolSupport { + + private McpToolSupport() {} + + /** Trimmed text value of an argument, or null if absent, blank, or not a string. */ + static String textArg(JsonNode args, String field) { + if (args == null) { + return null; + } + JsonNode node = args.get(field); + if (node == null || !node.isTextual()) { + return null; + } + String value = node.asText().trim(); + return value.isEmpty() ? null : value; + } + + /** Decode base64 content, or null if the input is not valid base64. */ + static byte[] decodeBase64OrNull(String base64) { + try { + return Base64.getDecoder().decode(base64); + } catch (IllegalArgumentException e) { + return null; + } + } + + /** + * Add a {@code string} property with a description to a JSON-Schema {@code properties} node. + */ + static void stringProperty(ObjectNode properties, String name, String description) { + ObjectNode prop = properties.putObject(name); + prop.put("type", "string"); + prop.put("description", description); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingAiTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingAiTool.java new file mode 100644 index 000000000..fc3696b65 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingAiTool.java @@ -0,0 +1,149 @@ +package stirling.software.proprietary.mcp.tools; + +import java.io.IOException; +import java.util.List; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.mcp.McpCallContext; +import stirling.software.proprietary.mcp.McpTool; +import stirling.software.proprietary.mcp.catalog.McpToolCatalog; +import stirling.software.proprietary.mcp.catalog.OperationCategory; +import stirling.software.proprietary.mcp.catalog.OperationMeta; +import stirling.software.proprietary.service.AiEngineClient; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; + +/** + * Exposes curated Python agent capabilities as a single MCP tool, sourced from the engine + * capabilities manifest. + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") +public class StirlingAiTool implements McpTool { + + private final ObjectMapper mapper; + private final ObjectProvider catalogProvider; + private final ObjectProvider engineClientProvider; + + public StirlingAiTool( + ObjectMapper mapper, + ObjectProvider catalog, + ObjectProvider engineClient) { + this.mapper = mapper; + this.catalogProvider = catalog; + this.engineClientProvider = engineClient; + } + + @Override + public String name() { + return "stirling_ai"; + } + + @Override + public String description() { + return "Invoke a Stirling AI agent capability (Q&A about a PDF, edit-plan generation," + + " inline comments, math audit, draft-spec helper). Call" + + " stirling_describe_operation with the chosen capability id to get its" + + " parameters schema before invoking this tool. Some capabilities return content" + + " inline; others return a job reference that resolves to a file when ready."; + } + + @Override + public ObjectNode inputSchema() { + ObjectNode schema = mapper.createObjectNode(); + schema.put("type", "object"); + schema.put("additionalProperties", false); + ObjectNode props = schema.putObject("properties"); + + ObjectNode op = props.putObject("operation"); + op.put("type", "string"); + StringBuilder desc = new StringBuilder(); + desc.append("Capability id from the engine manifest. Available capabilities:\n"); + ArrayNode opEnum = op.putArray("enum"); + for (OperationMeta m : aiOps()) { + opEnum.add(m.id()); + desc.append("- ").append(m.id()).append(" - ").append(m.summary()).append('\n'); + } + op.put("description", desc.toString().trim()); + + ObjectNode params = props.putObject("parameters"); + params.put("type", "object"); + params.put("description", "Per-capability parameters."); + params.put("additionalProperties", true); + + ObjectNode fileId = props.putObject("fileId"); + fileId.put("type", "string"); + fileId.put( + "description", + "Reference to a previously-uploaded PDF in Stirling's job store. Required for" + + " capabilities that consume a document."); + + ArrayNode required = schema.putArray("required"); + required.add("operation"); + return schema; + } + + @Override + public ObjectNode call(JsonNode arguments, McpCallContext context) { + JsonNode opNode = arguments == null ? null : arguments.get("operation"); + if (opNode == null || !opNode.isTextual() || opNode.asText().isBlank()) { + return McpResponses.error(mapper, "Missing required argument: operation"); + } + String opId = opNode.asText(); + McpToolCatalog catalog = catalogProvider.getIfAvailable(); + if (catalog == null) { + return McpResponses.error(mapper, "MCP catalog is not available"); + } + OperationMeta meta = catalog.findByOperationId(opId).orElse(null); + if (meta == null || meta.category() != OperationCategory.AI) { + return McpResponses.error( + mapper, + "Unknown AI capability '" + + opId + + "'. The engine manifest may not be loaded yet - retry shortly or" + + " confirm the engine is reachable."); + } + if (!context.hasScope(meta.requiredScope())) { + return McpResponses.error( + mapper, + "Insufficient scope: this capability requires '" + meta.requiredScope() + "'."); + } + AiEngineClient client = engineClientProvider.getIfAvailable(); + if (client == null) { + return McpResponses.error( + mapper, "AI engine client is not configured - enable aiEngine in settings."); + } + if (meta.endpointPath() == null) { + return McpResponses.error( + mapper, + "Capability '" + opId + "' has no route configured in the engine manifest."); + } + JsonNode params = arguments.get("parameters"); + String body = (params == null ? mapper.createObjectNode() : params).toString(); + try { + String response = client.post(meta.endpointPath(), body, context.stirlingUserId()); + return McpResponses.text(mapper, response); + } catch (IOException e) { + log.warn("MCP AI capability '{}' engine request failed", opId, e); + return McpResponses.error( + mapper, "Engine request failed for capability '" + opId + "'."); + } + } + + private List aiOps() { + McpToolCatalog catalog = catalogProvider.getIfAvailable(); + if (catalog == null) { + return List.of(); + } + return catalog.enabledOps(OperationCategory.AI); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingConvertTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingConvertTool.java new file mode 100644 index 000000000..9e89f6a5c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingConvertTool.java @@ -0,0 +1,41 @@ +package stirling.software.proprietary.mcp.tools; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import stirling.software.proprietary.mcp.catalog.McpToolCatalog; +import stirling.software.proprietary.mcp.catalog.OperationCategory; + +import tools.jackson.databind.ObjectMapper; + +/** Exposes the {@code /api/v1/convert/*} namespace as a single MCP tool. */ +@Component +@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") +public class StirlingConvertTool extends AbstractCategoryTool { + + public StirlingConvertTool( + ObjectMapper mapper, + ObjectProvider catalog, + ObjectProvider executor) { + super(mapper, catalog, executor); + } + + @Override + public String name() { + return "stirling_convert"; + } + + @Override + public String description() { + return "Convert files between PDF and other formats (PDF<->Word, PDF<->image, HTML->PDF," + + " etc.). Inspect the `operation` enum, then call stirling_describe_operation" + + " with the chosen op to get its parameters JSON Schema before calling this" + + " tool."; + } + + @Override + protected OperationCategory category() { + return OperationCategory.CONVERT; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingDownloadTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingDownloadTool.java new file mode 100644 index 000000000..22fad2384 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingDownloadTool.java @@ -0,0 +1,113 @@ +package stirling.software.proprietary.mcp.tools; + +import java.io.IOException; +import java.util.Base64; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.FileStorage; +import stirling.software.proprietary.mcp.McpCallContext; +import stirling.software.proprietary.mcp.McpTool; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +/** + * Fetches a stored file's content by fileId, returned inline as base64. For large results that were + * not returned inline by an operation. + */ +@Component +@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") +public class StirlingDownloadTool implements McpTool { + + private final ObjectMapper mapper; + private final FileStorage fileStorage; + private final ApplicationProperties applicationProperties; + + public StirlingDownloadTool( + ObjectMapper mapper, + FileStorage fileStorage, + ApplicationProperties applicationProperties) { + this.mapper = mapper; + this.fileStorage = fileStorage; + this.applicationProperties = applicationProperties; + } + + @Override + public String name() { + return "stirling_download"; + } + + @Override + public String description() { + return "Fetch a stored file's content by fileId (e.g. an operation result), returned inline" + + " as base64. Recommended only when a result was too large to be returned inline." + + " Argument: { fileId: }."; + } + + @Override + public ObjectNode inputSchema() { + ObjectNode schema = mapper.createObjectNode(); + schema.put("type", "object"); + schema.put("additionalProperties", false); + ObjectNode props = schema.putObject("properties"); + McpToolSupport.stringProperty( + props, "fileId", "Id of a stored file (e.g. an operation result's fileId)."); + schema.putArray("required").add("fileId"); + return schema; + } + + @Override + public ObjectNode call(JsonNode arguments, McpCallContext context) { + if (!context.hasScope("mcp.tools.read")) { + return McpResponses.error( + mapper, "Insufficient scope: stirling_download requires 'mcp.tools.read'."); + } + String fileId = McpToolSupport.textArg(arguments, "fileId"); + if (fileId == null) { + return McpResponses.error(mapper, "Missing required argument: fileId."); + } + long maxInline = applicationProperties.getMcp().getMaxInlineResponseBytes(); + try { + if (!fileStorage.fileExists(fileId)) { + return McpResponses.error( + mapper, "Unknown or inaccessible fileId '" + fileId + "'."); + } + long size = fileStorage.getFileSize(fileId); + if (size > maxInline) { + return McpResponses.error( + mapper, + "File is " + + size + + " bytes, over the inline limit of " + + maxInline + + " bytes. Raise mcp.maxInlineResponseBytes or retrieve it via the" + + " Stirling UI/API."); + } + byte[] bytes = fileStorage.retrieveBytes(fileId); + return McpResponses.result( + mapper, + false, + McpResponses.textBlock( + mapper, + "File " + + fileId + + " (" + + bytes.length + + " bytes) included inline below."), + McpResponses.resourceBlock( + mapper, + "stirling://file/" + fileId, + MediaType.APPLICATION_OCTET_STREAM_VALUE, + Base64.getEncoder().encodeToString(bytes))); + } catch (SecurityException e) { + return McpResponses.error(mapper, "Unknown or inaccessible fileId '" + fileId + "'."); + } catch (IOException e) { + return McpResponses.error(mapper, "Failed to read fileId '" + fileId + "'."); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingMiscTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingMiscTool.java new file mode 100644 index 000000000..3d76f47a7 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingMiscTool.java @@ -0,0 +1,40 @@ +package stirling.software.proprietary.mcp.tools; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import stirling.software.proprietary.mcp.catalog.McpToolCatalog; +import stirling.software.proprietary.mcp.catalog.OperationCategory; + +import tools.jackson.databind.ObjectMapper; + +/** Exposes the {@code /api/v1/misc/*} namespace as a single MCP tool. */ +@Component +@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") +public class StirlingMiscTool extends AbstractCategoryTool { + + public StirlingMiscTool( + ObjectMapper mapper, + ObjectProvider catalog, + ObjectProvider executor) { + super(mapper, catalog, executor); + } + + @Override + public String name() { + return "stirling_misc"; + } + + @Override + public String description() { + return "Miscellaneous PDF operations: compress, OCR, stamp / watermark, edit metadata," + + " flatten, repair, and similar utilities. Call stirling_describe_operation with" + + " the chosen op to get its parameters schema before invoking this tool."; + } + + @Override + protected OperationCategory category() { + return OperationCategory.MISC; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingPagesTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingPagesTool.java new file mode 100644 index 000000000..a736a7812 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingPagesTool.java @@ -0,0 +1,40 @@ +package stirling.software.proprietary.mcp.tools; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import stirling.software.proprietary.mcp.catalog.McpToolCatalog; +import stirling.software.proprietary.mcp.catalog.OperationCategory; + +import tools.jackson.databind.ObjectMapper; + +/** Exposes the {@code /api/v1/general/*} (page operations) namespace as a single MCP tool. */ +@Component +@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") +public class StirlingPagesTool extends AbstractCategoryTool { + + public StirlingPagesTool( + ObjectMapper mapper, + ObjectProvider catalog, + ObjectProvider executor) { + super(mapper, catalog, executor); + } + + @Override + public String name() { + return "stirling_pages"; + } + + @Override + public String description() { + return "Manipulate PDF pages: merge, split, rotate, rearrange, crop, delete, overlay," + + " add blank pages. Call stirling_describe_operation with the chosen op to get" + + " its parameters schema before invoking this tool."; + } + + @Override + protected OperationCategory category() { + return OperationCategory.PAGES; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingSecurityTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingSecurityTool.java new file mode 100644 index 000000000..300b6c1d0 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingSecurityTool.java @@ -0,0 +1,41 @@ +package stirling.software.proprietary.mcp.tools; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import stirling.software.proprietary.mcp.catalog.McpToolCatalog; +import stirling.software.proprietary.mcp.catalog.OperationCategory; + +import tools.jackson.databind.ObjectMapper; + +/** Exposes the {@code /api/v1/security/*} namespace as a single MCP tool. */ +@Component +@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") +public class StirlingSecurityTool extends AbstractCategoryTool { + + public StirlingSecurityTool( + ObjectMapper mapper, + ObjectProvider catalog, + ObjectProvider executor) { + super(mapper, catalog, executor); + } + + @Override + public String name() { + return "stirling_security"; + } + + @Override + public String description() { + return "Security-related PDF operations: password add/remove, redact, sanitize, certify" + + " / sign with cert, validate signature, add watermark. Call" + + " stirling_describe_operation with the chosen op to get its parameters schema" + + " before invoking this tool."; + } + + @Override + protected OperationCategory category() { + return OperationCategory.SECURITY; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingUploadTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingUploadTool.java new file mode 100644 index 000000000..ebee4e412 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingUploadTool.java @@ -0,0 +1,96 @@ +package stirling.software.proprietary.mcp.tools; + +import java.io.IOException; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.service.FileStorage; +import stirling.software.proprietary.mcp.McpCallContext; +import stirling.software.proprietary.mcp.McpTool; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +/** + * Stores a file server-side and returns a fileId. For large files or multi-step workflows only - + * most operations accept the file inline via their {@code file} argument. + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") +public class StirlingUploadTool implements McpTool { + + private final ObjectMapper mapper; + private final FileStorage fileStorage; + + public StirlingUploadTool(ObjectMapper mapper, FileStorage fileStorage) { + this.mapper = mapper; + this.fileStorage = fileStorage; + } + + @Override + public String name() { + return "stirling_upload"; + } + + @Override + public String description() { + return "Store a file server-side and get back a fileId to reuse across operations." + + " Recommended only for large files or multi-step workflows; for a single" + + " operation on a typical file, pass the file inline via the operation's `file`" + + " argument instead. Argument: { file: , fileName?: }."; + } + + @Override + public ObjectNode inputSchema() { + ObjectNode schema = mapper.createObjectNode(); + schema.put("type", "object"); + schema.put("additionalProperties", false); + ObjectNode props = schema.putObject("properties"); + McpToolSupport.stringProperty(props, "file", "Base64-encoded file content."); + McpToolSupport.stringProperty( + props, "fileName", "Optional original filename (with extension)."); + schema.putArray("required").add("file"); + return schema; + } + + @Override + public ObjectNode call(JsonNode arguments, McpCallContext context) { + if (!context.hasScope("mcp.tools.write")) { + return McpResponses.error( + mapper, "Insufficient scope: stirling_upload requires 'mcp.tools.write'."); + } + String base64 = McpToolSupport.textArg(arguments, "file"); + if (base64 == null) { + return McpResponses.error( + mapper, "Missing required argument: file (base64-encoded content)."); + } + byte[] bytes = McpToolSupport.decodeBase64OrNull(base64); + if (bytes == null) { + return McpResponses.error(mapper, "The 'file' argument is not valid base64."); + } + String name = McpToolSupport.textArg(arguments, "fileName"); + if (name == null) { + name = "upload.bin"; + } + try { + String fileId = fileStorage.storeBytes(bytes, name); + return McpResponses.text( + mapper, + "Stored '" + + name + + "' (" + + bytes.length + + " bytes) as fileId=" + + fileId + + ". Pass this fileId to a Stirling operation's 'fileId' argument."); + } catch (IOException e) { + log.warn("MCP upload failed to store file", e); + return McpResponses.error(mapper, "Failed to store the uploaded file."); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java index 115727eb9..06fc80c75 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java @@ -618,6 +618,8 @@ public class AdminSettingsController { case "autopipeline", "autoPipeline" -> applicationProperties.getAutoPipeline(); case "legal" -> applicationProperties.getLegal(); case "telegram" -> applicationProperties.getTelegram(); + case "aiengine", "aiEngine" -> applicationProperties.getAiEngine(); + case "mcp" -> applicationProperties.getMcp(); default -> null; }; } @@ -641,7 +643,10 @@ public class AdminSettingsController { "autoPipeline", "autopipeline", "legal", - "telegram"); + "telegram", + "aiEngine", + "aiengine", + "mcp"); // Pattern to validate safe property paths - only alphanumeric, dots, and underscores private static final Pattern SAFE_KEY_PATTERN = diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineClient.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineClient.java index dedc558af..b0d726cec 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineClient.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineClient.java @@ -25,6 +25,7 @@ public class AiEngineClient { private final ApplicationProperties applicationProperties; private final HttpClient httpClient; + private final String engineSharedSecret; @Autowired public AiEngineClient(ApplicationProperties applicationProperties) { @@ -39,8 +40,17 @@ public class AiEngineClient { /** Package-private constructor that accepts an HttpClient directly; intended for tests. */ AiEngineClient(ApplicationProperties applicationProperties, HttpClient httpClient) { + this(applicationProperties, httpClient, System.getenv("STIRLING_ENGINE_SHARED_SECRET")); + } + + /** Package-private constructor that also injects the engine shared secret; for tests. */ + AiEngineClient( + ApplicationProperties applicationProperties, + HttpClient httpClient, + String engineSharedSecret) { this.applicationProperties = applicationProperties; this.httpClient = httpClient; + this.engineSharedSecret = engineSharedSecret; } public String post(String path, String jsonBody, String userId) throws IOException { @@ -78,6 +88,7 @@ public class AiEngineClient { .timeout(timeout) .POST(HttpRequest.BodyPublishers.ofString(jsonBody)); addUserHeader(builder, userId); + addEngineAuthHeader(builder); HttpResponse response = sendRequest(builder.build()); log.debug("AI engine responded with status {}", response.statusCode()); @@ -86,10 +97,15 @@ public class AiEngineClient { } /** - * Attach the X-User-Id header so the engine can scope per-user storage (RAG documents, search - * results) to the caller. Skipped when {@code userId} is blank: the engine treats the request - * as anonymous and refuses any route that requires tenancy. + * Attach the {@code X-Engine-Auth} shared secret when configured so the engine trusts this + * backend request. */ + private void addEngineAuthHeader(HttpRequest.Builder builder) { + if (engineSharedSecret != null && !engineSharedSecret.isBlank()) { + builder.header("X-Engine-Auth", engineSharedSecret); + } + } + private static void addUserHeader(HttpRequest.Builder builder, String userId) { if (userId != null && !userId.isBlank()) { builder.header("X-User-Id", userId); @@ -129,6 +145,7 @@ public class AiEngineClient { .timeout(timeout) .POST(HttpRequest.BodyPublishers.ofString(jsonBody)); addUserHeader(builder, userId); + addEngineAuthHeader(builder); HttpRequest request = builder.build(); HttpResponse> response; @@ -184,6 +201,7 @@ public class AiEngineClient { .timeout(Duration.ofSeconds(config.getTimeoutSeconds())) .DELETE(); addUserHeader(builder, userId); + addEngineAuthHeader(builder); HttpResponse response = sendRequest(builder.build()); log.debug("AI engine responded with status {}", response.statusCode()); @@ -208,6 +226,7 @@ public class AiEngineClient { .timeout(Duration.ofSeconds(config.getTimeoutSeconds())) .GET(); addUserHeader(builder, userId); + addEngineAuthHeader(builder); HttpResponse response = sendRequest(builder.build()); log.debug("AI engine responded with status {}", response.statusCode()); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/cluster/s3/S3FileStoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/cluster/s3/S3FileStoreTest.java index b407041bd..9bb4d08b7 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/cluster/s3/S3FileStoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/cluster/s3/S3FileStoreTest.java @@ -250,4 +250,27 @@ class S3FileStoreTest { return toWrite; } } + + @Test + void store_withOwner_persistsOwnerMetadata() throws IOException { + FileStore.Stored stored = + store.store( + new ByteArrayInputStream("owned".getBytes(StandardCharsets.UTF_8)), + "o.txt", + "alice"); + assertThat(store.getOwner(stored.fileId())).isEqualTo("alice"); + } + + @Test + void store_withoutOwner_yieldsNullFromGetOwner() throws IOException { + FileStore.Stored stored = + store.store( + new ByteArrayInputStream("anon".getBytes(StandardCharsets.UTF_8)), "a.txt"); + assertThat(store.getOwner(stored.fileId())).isNull(); + } + + @Test + void getOwner_returnsNullForUnknownFileId() throws IOException { + assertThat(store.getOwner("00000000-0000-0000-0000-000000000000")).isNull(); + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/McpConditionalTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/McpConditionalTest.java new file mode 100644 index 000000000..ca6eee5ee --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/McpConditionalTest.java @@ -0,0 +1,99 @@ +package stirling.software.proprietary.mcp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; + +import stirling.software.proprietary.mcp.catalog.McpToolCatalog; +import stirling.software.proprietary.mcp.engine.EngineCapabilityClient; +import stirling.software.proprietary.mcp.security.McpSecurityConfig; +import stirling.software.proprietary.mcp.tools.DescribeOperationTool; +import stirling.software.proprietary.mcp.tools.McpOperationExecutor; +import stirling.software.proprietary.mcp.tools.StirlingAiTool; +import stirling.software.proprietary.mcp.tools.StirlingConvertTool; +import stirling.software.proprietary.mcp.tools.StirlingDownloadTool; +import stirling.software.proprietary.mcp.tools.StirlingMiscTool; +import stirling.software.proprietary.mcp.tools.StirlingPagesTool; +import stirling.software.proprietary.mcp.tools.StirlingSecurityTool; +import stirling.software.proprietary.mcp.tools.StirlingUploadTool; + +/** Verifies MCP beans are gated behind {@code @ConditionalOnProperty(name="mcp.enabled")}. */ +class McpConditionalTest { + + @Test + void serverController_isGatedByMcpEnabled() { + assertGatedByEnabled(McpServerController.class); + } + + @Test + void securityConfig_isGatedByMcpEnabled() { + assertGatedByEnabled(McpSecurityConfig.class); + } + + @Test + void categoryToolsAndDescribeOperation_doNotNeedOwnGate() { + // The tool beans are only wired into the gated controller; sanity-check their signatures. + Class[] tools = { + DescribeOperationTool.class, + StirlingConvertTool.class, + StirlingPagesTool.class, + StirlingMiscTool.class, + StirlingSecurityTool.class, + StirlingAiTool.class + }; + for (Class t : tools) { + assertTrue( + McpTool.class.isAssignableFrom(t), + t.getSimpleName() + " must implement McpTool"); + assertNotNull( + t.getAnnotation(org.springframework.stereotype.Component.class), + t.getSimpleName() + " must be @Component"); + } + } + + @Test + void mcpBeans_areNotSaasProfileRestricted() { + // Beans gate on mcp.enabled only; no @Profile, so MCP can run under the saas profile too. + Class[] beans = { + McpServerController.class, + McpSecurityConfig.class, + McpToolCatalog.class, + EngineCapabilityClient.class, + McpOperationExecutor.class, + DescribeOperationTool.class, + StirlingAiTool.class, + StirlingConvertTool.class, + StirlingMiscTool.class, + StirlingPagesTool.class, + StirlingSecurityTool.class, + StirlingUploadTool.class, + StirlingDownloadTool.class + }; + for (Class bean : beans) { + assertNull( + bean.getAnnotation(Profile.class), + bean.getSimpleName() + + " must not be @Profile-restricted so MCP can run under saas"); + } + } + + private static void assertGatedByEnabled(Class beanClass) { + ConditionalOnProperty conditional = beanClass.getAnnotation(ConditionalOnProperty.class); + assertNotNull(conditional, beanClass.getSimpleName() + " missing @ConditionalOnProperty"); + assertTrue( + Arrays.asList(conditional.name()).contains("mcp.enabled") + || Arrays.asList(conditional.value()).contains("mcp.enabled"), + beanClass.getSimpleName() + " must gate on mcp.enabled"); + assertEquals( + "true", + conditional.havingValue(), + beanClass.getSimpleName() + " must require mcp.enabled=true"); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/McpServerControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/McpServerControllerTest.java new file mode 100644 index 000000000..041829579 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/McpServerControllerTest.java @@ -0,0 +1,238 @@ +package stirling.software.proprietary.mcp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Iterator; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.mcp.catalog.McpToolCatalog; +import stirling.software.proprietary.mcp.tools.DescribeOperationTool; +import stirling.software.proprietary.mcp.tools.McpOperationExecutor; +import stirling.software.proprietary.mcp.tools.StirlingAiTool; +import stirling.software.proprietary.mcp.tools.StirlingConvertTool; +import stirling.software.proprietary.mcp.tools.StirlingMiscTool; +import stirling.software.proprietary.mcp.tools.StirlingPagesTool; +import stirling.software.proprietary.mcp.tools.StirlingSecurityTool; +import stirling.software.proprietary.service.AiEngineClient; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +/** Unit test of the MCP server controller: JSON-RPC framing and the 6-tool contract. */ +class McpServerControllerTest { + + private final ObjectMapper mapper = new ObjectMapper(); + private final McpServerController controller = buildController(); + + private McpServerController buildController() { + ApplicationProperties props = new ApplicationProperties(); + props.getAutomaticallyGenerated().setAppVersion("test-version"); + ObjectProvider emptyCatalog = emptyProvider(); + ObjectProvider emptyEngine = emptyProvider(); + ObjectProvider emptyExecutor = emptyProvider(); + List tools = + List.of( + new DescribeOperationTool(mapper, emptyCatalog), + new StirlingConvertTool(mapper, emptyCatalog, emptyExecutor), + new StirlingPagesTool(mapper, emptyCatalog, emptyExecutor), + new StirlingMiscTool(mapper, emptyCatalog, emptyExecutor), + new StirlingSecurityTool(mapper, emptyCatalog, emptyExecutor), + new StirlingAiTool(mapper, emptyCatalog, emptyEngine)); + return new McpServerController(mapper, props, tools); + } + + private static ObjectProvider emptyProvider() { + return new ObjectProvider<>() { + @Override + public T getObject() { + throw new UnsupportedOperationException("no bean in unit tests"); + } + + @Override + public T getObject(Object... args) { + return getObject(); + } + + @Override + public T getIfAvailable() { + return null; + } + + @Override + public T getIfUnique() { + return null; + } + + @Override + public T getIfAvailable(Supplier defaultSupplier) { + return defaultSupplier == null ? null : defaultSupplier.get(); + } + + @Override + public void ifAvailable(Consumer dependencyConsumer) {} + + @Override + public Iterator iterator() { + return java.util.Collections.emptyIterator(); + } + }; + } + + @Test + void toolsList_returnsExactlySixTools() throws Exception { + JsonNode body = mapper.readTree("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}"); + + ResponseEntity response = controller.handle(body); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + JsonNode tools = mapper.valueToTree(response.getBody()).get("result").get("tools"); + assertEquals(6, tools.size(), "tools/list must return exactly 6 tools"); + + Set names = + Set.of( + "stirling_describe_operation", + "stirling_convert", + "stirling_pages", + "stirling_misc", + "stirling_security", + "stirling_ai"); + Set seen = new java.util.HashSet<>(); + tools.forEach(t -> seen.add(t.get("name").asText())); + assertEquals(names, seen); + + for (JsonNode tool : tools) { + assertTrue(tool.get("description").asText().length() > 10, "description present"); + assertEquals("object", tool.get("inputSchema").get("type").asText()); + } + } + + @Test + void initialize_returnsServerInfoAndProtocolVersion() throws Exception { + JsonNode body = mapper.readTree("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}"); + + ResponseEntity response = controller.handle(body); + + JsonNode result = mapper.valueToTree(response.getBody()).get("result"); + assertNotNull(result.get("protocolVersion")); + assertEquals("stirling-pdf-mcp", result.get("serverInfo").get("name").asText()); + assertEquals("test-version", result.get("serverInfo").get("version").asText()); + assertNotNull(result.get("capabilities").get("tools"), "tools capability advertised"); + } + + @Test + void ping_returnsEmptyResult() throws Exception { + JsonNode body = mapper.readTree("{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"ping\"}"); + + ResponseEntity response = controller.handle(body); + + JsonNode out = mapper.valueToTree(response.getBody()); + assertEquals(7, out.get("id").asInt()); + assertNotNull(out.get("result")); + assertNull(out.get("error")); + } + + @Test + void notification_returnsNoContentWithEmptyBody() throws Exception { + // No id field: a JSON-RPC notification gets no response object. + JsonNode body = + mapper.readTree("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}"); + + ResponseEntity response = controller.handle(body); + + assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode()); + assertNull(response.getBody()); + } + + @Test + void unknownMethod_returnsMethodNotFoundError() throws Exception { + JsonNode body = + mapper.readTree("{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"does/not/exist\"}"); + + ResponseEntity response = controller.handle(body); + + JsonNode error = mapper.valueToTree(response.getBody()).get("error"); + assertEquals(-32601, error.get("code").asInt()); + assertTrue(error.get("message").asText().contains("does/not/exist")); + } + + @Test + void toolsCall_unknownTool_returnsInvalidParams() throws Exception { + JsonNode body = + mapper.readTree( + "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"stirling_does_not_exist\",\"arguments\":{}}}"); + + ResponseEntity response = controller.handle(body); + + JsonNode error = mapper.valueToTree(response.getBody()).get("error"); + assertEquals(-32602, error.get("code").asInt()); + } + + @Test + void toolsCall_describeOperation_withoutCatalog_returnsErrorContent() throws Exception { + // Null catalog: describe must surface an isError content block, not crash. + JsonNode body = + mapper.readTree( + "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"stirling_describe_operation\"," + + "\"arguments\":{\"operation\":\"compress-pdf\"}}}"); + + ResponseEntity response = controller.handle(body); + + JsonNode result = mapper.valueToTree(response.getBody()).get("result"); + assertTrue(result.get("isError").asBoolean()); + String text = result.get("content").get(0).get("text").asText(); + assertTrue(text.toLowerCase().contains("catalog") || text.contains("compress-pdf")); + } + + @Test + void wrongShapeJson_returnsInvalidRequest() throws Exception { + // Valid JSON but not a JSON-RPC request object -> Invalid Request (-32600). + JsonNode body = mapper.readTree("{\"not\":\"a json-rpc frame\"}"); + + ResponseEntity response = controller.handle(body); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + JsonNode error = mapper.valueToTree(response.getBody()).get("error"); + assertEquals(-32600, error.get("code").asInt()); + } + + @Test + void initialize_echoesSupportedClientProtocolVersion() throws Exception { + // Older but supported revision -> server echoes it. + JsonNode body = + mapper.readTree( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"," + + "\"params\":{\"protocolVersion\":\"2025-03-26\"}}"); + + ResponseEntity response = controller.handle(body); + + JsonNode result = mapper.valueToTree(response.getBody()).get("result"); + assertEquals("2025-03-26", result.get("protocolVersion").asText()); + } + + @Test + void initialize_unknownClientProtocolVersion_fallsBackToPreferred() throws Exception { + JsonNode body = + mapper.readTree( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"," + + "\"params\":{\"protocolVersion\":\"1999-01-01\"}}"); + + ResponseEntity response = controller.handle(body); + + JsonNode result = mapper.valueToTree(response.getBody()).get("result"); + assertEquals("2025-06-18", result.get("protocolVersion").asText()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/catalog/McpToolCatalogTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/catalog/McpToolCatalogTest.java new file mode 100644 index 000000000..71341cf6c --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/catalog/McpToolCatalogTest.java @@ -0,0 +1,185 @@ +package stirling.software.proprietary.mcp.catalog; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationContext; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +import stirling.software.SPDF.config.EndpointConfiguration; +import stirling.software.common.model.ApplicationProperties; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +/** Catalog tests: DELETE/GET exclusion and disabled-PDF-op AI fall-through. */ +class McpToolCatalogTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void isInvocableMethod_excludesDeleteAndGet() { + assertTrue(McpToolCatalog.isInvocableMethod(Set.of(RequestMethod.POST))); + assertTrue(McpToolCatalog.isInvocableMethod(Set.of(RequestMethod.PUT))); + // DELETE/GET handlers must never be cataloged as runnable tools. + assertFalse(McpToolCatalog.isInvocableMethod(Set.of(RequestMethod.DELETE))); + assertFalse(McpToolCatalog.isInvocableMethod(Set.of(RequestMethod.GET))); + // Empty method set (matches all verbs) is not invocable. + assertFalse(McpToolCatalog.isInvocableMethod(Set.of())); + // Multi-verb mapping including POST stays invocable. + assertTrue( + McpToolCatalog.isInvocableMethod(Set.of(RequestMethod.POST, RequestMethod.DELETE))); + } + + @Test + void findByOperationId_disabledPdfOp_doesNotFallThroughToAi() throws Exception { + ApplicationContext ctx = mock(ApplicationContext.class); + when(ctx.getBeansOfType(RequestMappingHandlerMapping.class)).thenReturn(Map.of()); + EndpointConfiguration endpoints = mock(EndpointConfiguration.class); + // The PDF op's endpoint is disabled. + when(endpoints.isEndpointEnabledForUri(anyString())).thenReturn(false); + + McpToolCatalog catalog = + new McpToolCatalog(ctx, endpoints, new ApplicationProperties(), mapper); + + ObjectNode schema = mapper.createObjectNode(); + OperationMeta pdf = + new OperationMeta( + "collide", + OperationCategory.MISC, + "pdf op", + schema, + "mcp.tools.write", + OperationMeta.Target.JAVA_ENDPOINT, + "/api/v1/misc/collide", + null); + OperationMeta ai = + new OperationMeta( + "collide", + OperationCategory.AI, + "ai op", + schema, + "mcp.tools.write", + OperationMeta.Target.ENGINE_CAPABILITY, + "collide", + null); + seed(catalog, "pdfOps", "collide", pdf); + seed(catalog, "aiOps", "collide", ai); + + // A disabled PDF op must resolve to empty, not a colliding AI capability of the same id. + assertTrue( + catalog.findByOperationId("collide").isEmpty(), + "disabled PDF op must not resolve to a colliding AI capability"); + + // A genuine AI-only id still resolves. + seed( + catalog, + "aiOps", + "ai-only", + new OperationMeta( + "ai-only", + OperationCategory.AI, + "ai op", + schema, + "mcp.tools.write", + OperationMeta.Target.ENGINE_CAPABILITY, + "ai-only", + null)); + assertEquals("ai-only", catalog.findByOperationId("ai-only").orElseThrow().id()); + } + + @Test + void blockedOperations_hidesOp() throws Exception { + ApplicationProperties props = new ApplicationProperties(); + props.getMcp().setBlockedOperations(List.of("compress-pdf")); + McpToolCatalog catalog = catalogWithEndpointsEnabled(props); + seed(catalog, "pdfOps", "compress-pdf", miscOp("compress-pdf")); + seed(catalog, "pdfOps", "ocr-pdf", miscOp("ocr-pdf")); + + assertTrue( + catalog.findByOperationId("compress-pdf").isEmpty(), "blocked op must be hidden"); + assertEquals("ocr-pdf", catalog.findByOperationId("ocr-pdf").orElseThrow().id()); + assertFalse(idsOf(catalog).contains("compress-pdf")); + assertTrue(idsOf(catalog).contains("ocr-pdf")); + } + + @Test + void allowedOperations_isWhitelist() throws Exception { + ApplicationProperties props = new ApplicationProperties(); + props.getMcp().setAllowedOperations(List.of("compress-pdf")); + McpToolCatalog catalog = catalogWithEndpointsEnabled(props); + seed(catalog, "pdfOps", "compress-pdf", miscOp("compress-pdf")); + seed(catalog, "pdfOps", "ocr-pdf", miscOp("ocr-pdf")); + + assertEquals("compress-pdf", catalog.findByOperationId("compress-pdf").orElseThrow().id()); + assertTrue( + catalog.findByOperationId("ocr-pdf").isEmpty(), + "op not on the allow-list must be hidden"); + assertEquals(List.of("compress-pdf"), idsOf(catalog)); + } + + @Test + void blockedOperations_takePrecedenceOverAllowed() throws Exception { + ApplicationProperties props = new ApplicationProperties(); + props.getMcp().setAllowedOperations(List.of("compress-pdf")); + props.getMcp().setBlockedOperations(List.of("compress-pdf")); + McpToolCatalog catalog = catalogWithEndpointsEnabled(props); + seed(catalog, "pdfOps", "compress-pdf", miscOp("compress-pdf")); + + assertTrue( + catalog.findByOperationId("compress-pdf").isEmpty(), + "block-list must win over allow-list"); + } + + @Test + void emptyAllowAndBlockLists_exposeAllEnabledOps() throws Exception { + McpToolCatalog catalog = catalogWithEndpointsEnabled(new ApplicationProperties()); + seed(catalog, "pdfOps", "compress-pdf", miscOp("compress-pdf")); + + assertEquals("compress-pdf", catalog.findByOperationId("compress-pdf").orElseThrow().id()); + assertTrue(idsOf(catalog).contains("compress-pdf")); + } + + private McpToolCatalog catalogWithEndpointsEnabled(ApplicationProperties props) { + ApplicationContext ctx = mock(ApplicationContext.class); + when(ctx.getBeansOfType(RequestMappingHandlerMapping.class)).thenReturn(Map.of()); + EndpointConfiguration endpoints = mock(EndpointConfiguration.class); + when(endpoints.isEndpointEnabledForUri(anyString())).thenReturn(true); + return new McpToolCatalog(ctx, endpoints, props, mapper); + } + + private OperationMeta miscOp(String id) { + return new OperationMeta( + id, + OperationCategory.MISC, + id, + mapper.createObjectNode(), + "mcp.tools.write", + OperationMeta.Target.JAVA_ENDPOINT, + "/api/v1/misc/" + id, + null); + } + + private static List idsOf(McpToolCatalog catalog) { + return catalog.enabledOps(OperationCategory.MISC).stream().map(OperationMeta::id).toList(); + } + + @SuppressWarnings("unchecked") + private static void seed(McpToolCatalog catalog, String field, String id, OperationMeta meta) + throws Exception { + Field f = McpToolCatalog.class.getDeclaredField(field); + f.setAccessible(true); + ((Map) f.get(catalog)).put(id, meta); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/catalog/SimpleSchemaGeneratorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/catalog/SimpleSchemaGeneratorTest.java new file mode 100644 index 000000000..601c4c11c --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/catalog/SimpleSchemaGeneratorTest.java @@ -0,0 +1,59 @@ +package stirling.software.proprietary.mcp.catalog; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashSet; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +/** Schema must describe the JSON wire contract, not raw Java field names. */ +class SimpleSchemaGeneratorTest { + + private final SimpleSchemaGenerator gen = new SimpleSchemaGenerator(new ObjectMapper()); + + @SuppressWarnings("unused") + static class SampleRequest { + @JsonProperty("file_name") + String fileName; + + @JsonProperty(required = true) + String mode; + + @JsonIgnore String internalSecret; + + boolean flag; + + @jakarta.validation.constraints.NotBlank String title; + } + + @Test + void usesJsonPropertyNames_skipsJsonIgnore_marksRequired() { + ObjectNode schema = gen.toSchema(SampleRequest.class); + ObjectNode props = (ObjectNode) schema.get("properties"); + + assertTrue(props.has("file_name"), "must use the @JsonProperty name"); + assertFalse(props.has("fileName"), "must not emit the raw field name"); + + assertFalse(props.has("internalSecret"), "@JsonIgnore field must be skipped"); + + assertTrue(props.has("flag")); + assertEquals("boolean", props.get("flag").get("type").asText()); + + assertNotNull(schema.get("required"), "required array expected"); + Set required = new HashSet<>(); + schema.get("required").forEach(n -> required.add(n.asText())); + assertTrue(required.contains("mode"), "@JsonProperty(required=true) -> required"); + assertTrue(required.contains("title"), "@NotBlank -> required"); + assertFalse(required.contains("file_name"), "optional field not required"); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/engine/EngineCapabilityParseTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/engine/EngineCapabilityParseTest.java new file mode 100644 index 000000000..9be2d9908 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/engine/EngineCapabilityParseTest.java @@ -0,0 +1,138 @@ +package stirling.software.proprietary.mcp.engine; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Method; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.mcp.catalog.McpToolCatalog; +import stirling.software.proprietary.mcp.catalog.OperationCategory; +import stirling.software.proprietary.mcp.catalog.OperationMeta; + +import tools.jackson.databind.ObjectMapper; + +/** + * Verifies {@link EngineCapabilityClient#parseManifest} maps a manifest to {@link OperationMeta}. + */ +class EngineCapabilityParseTest { + + @Test + void manifest_maps_to_operation_meta() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + ApplicationProperties props = new ApplicationProperties(); + // parseManifest doesn't touch the catalog, so a null catalog is fine. + EngineCapabilityClient client = + new EngineCapabilityClient(props, (McpToolCatalog) null, mapper); + + String body = + """ + { + "version": 1, + "capabilities": [ + { + "id": "pdf-question-answer", + "description": "Answer a question about a PDF.", + "input_schema": {"type": "object", "properties": {"question": {"type": "string"}}}, + "mode": "sync", + "required_scope": "mcp.tools.read", + "route": "/api/v1/pdf-question" + }, + { + "id": "pdf-edit-plan", + "description": "Produce an edit plan from natural language.", + "input_schema": {"type": "object"}, + "mode": "async", + "required_scope": "mcp.tools.write", + "route": "/api/v1/pdf-edit" + } + ] + } + """; + + Method parse = + EngineCapabilityClient.class.getDeclaredMethod("parseManifest", String.class); + parse.setAccessible(true); + @SuppressWarnings("unchecked") + Map parsed = (Map) parse.invoke(client, body); + + assertThat(parsed).hasSize(2); + + OperationMeta qa = parsed.get("pdf-question-answer"); + assertThat(qa).isNotNull(); + assertThat(qa.category()).isEqualTo(OperationCategory.AI); + assertThat(qa.requiredScope()).isEqualTo("mcp.tools.read"); + assertThat(qa.endpointPath()).isEqualTo("/api/v1/pdf-question"); + assertThat(qa.target()).isEqualTo(OperationMeta.Target.ENGINE_CAPABILITY); + assertThat(qa.paramSchema().get("type").asText()).isEqualTo("object"); + + OperationMeta edit = parsed.get("pdf-edit-plan"); + assertThat(edit).isNotNull(); + assertThat(edit.requiredScope()).isEqualTo("mcp.tools.write"); + assertThat(edit.endpointPath()).isEqualTo("/api/v1/pdf-edit"); + } + + @Test + void missing_capabilities_array_throws() { + ObjectMapper mapper = new ObjectMapper(); + EngineCapabilityClient client = + new EngineCapabilityClient( + new ApplicationProperties(), (McpToolCatalog) null, mapper); + + try { + Method parse = + EngineCapabilityClient.class.getDeclaredMethod("parseManifest", String.class); + parse.setAccessible(true); + parse.invoke(client, "{\"version\":1}"); + org.junit.jupiter.api.Assertions.fail("Expected IOException"); + } catch (java.lang.reflect.InvocationTargetException e) { + assertThat(e.getCause()).isInstanceOf(java.io.IOException.class); + } catch (Exception e) { + org.junit.jupiter.api.Assertions.fail("Unexpected exception: " + e); + } + } + + @Test + void unsafe_routes_are_skipped_and_blank_scope_fails_safe() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + EngineCapabilityClient client = + new EngineCapabilityClient( + new ApplicationProperties(), (McpToolCatalog) null, mapper); + + String body = + """ + {"version":1,"capabilities":[ + {"id":"good","description":"ok","input_schema":{"type":"object"},"required_scope":"","route":"/api/v1/pdf-question"}, + {"id":"ssrf-at","description":"x","input_schema":{"type":"object"},"route":"@evil.com/steal"}, + {"id":"ssrf-proto","description":"x","input_schema":{"type":"object"},"route":"//evil.com/x"}, + {"id":"escape","description":"x","input_schema":{"type":"object"},"route":"/api/../../internal/secret"}, + {"id":"scheme","description":"x","input_schema":{"type":"object"},"route":"http://evil.com/x"}, + {"id":"non-api","description":"x","input_schema":{"type":"object"},"route":"/admin/settings"} + ]} + """; + + Method parse = + EngineCapabilityClient.class.getDeclaredMethod("parseManifest", String.class); + parse.setAccessible(true); + @SuppressWarnings("unchecked") + Map parsed = (Map) parse.invoke(client, body); + + // Only the safe, server-relative /api route survives. + assertThat(parsed.keySet()).containsExactly("good"); + // Blank required_scope fails safe to the stricter write scope. + assertThat(parsed.get("good").requiredScope()).isEqualTo("mcp.tools.write"); + } + + @Test + void isSafeRelativeRoute_acceptsOnlyServerRelativeApiPaths() { + assertThat(EngineCapabilityClient.isSafeRelativeRoute("/api/v1/pdf-question")).isTrue(); + assertThat(EngineCapabilityClient.isSafeRelativeRoute("@evil.com/x")).isFalse(); + assertThat(EngineCapabilityClient.isSafeRelativeRoute("//evil.com/x")).isFalse(); + assertThat(EngineCapabilityClient.isSafeRelativeRoute("/api/../secret")).isFalse(); + assertThat(EngineCapabilityClient.isSafeRelativeRoute("http://evil/x")).isFalse(); + assertThat(EngineCapabilityClient.isSafeRelativeRoute("/admin/x")).isFalse(); + assertThat(EngineCapabilityClient.isSafeRelativeRoute("/api/v1/x y")).isFalse(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpApiKeyIntegrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpApiKeyIntegrationTest.java new file mode 100644 index 000000000..b8c1f953e --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpApiKeyIntegrationTest.java @@ -0,0 +1,148 @@ +package stirling.software.proprietary.mcp.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.mcp.McpServerController; +import stirling.software.proprietary.mcp.tools.DescribeOperationTool; +import stirling.software.proprietary.mcp.tools.StirlingAiTool; +import stirling.software.proprietary.mcp.tools.StirlingConvertTool; +import stirling.software.proprietary.mcp.tools.StirlingMiscTool; +import stirling.software.proprietary.mcp.tools.StirlingPagesTool; +import stirling.software.proprietary.mcp.tools.StirlingSecurityTool; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.UserService; + +/** + * End-to-end test of {@code mcp.auth.mode=apikey} against the real security chain on live Jetty. + */ +@SpringBootTest( + classes = McpApiKeyIntegrationTest.TestApp.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class McpApiKeyIntegrationTest { + + private static final String VALID_KEY = "stirling-test-key-abc123"; + + @LocalServerPort private int port; + private final HttpClient http = HttpClient.newHttpClient(); + + @DynamicPropertySource + static void mcpProperties(DynamicPropertyRegistry registry) { + registry.add("mcp.enabled", () -> "true"); + registry.add("mcp.auth.mode", () -> "apikey"); + } + + @Test + void validApiKeyViaHeader_callsToolsList() throws Exception { + HttpResponse response = + postMcp( + b -> b.header("X-API-KEY", VALID_KEY), + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}"); + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.body()).contains("stirling_describe_operation"); + } + + @Test + void validApiKeyViaBearer_callsToolsList() throws Exception { + HttpResponse response = + postMcp( + b -> b.header("Authorization", "Bearer " + VALID_KEY), + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}"); + assertThat(response.statusCode()).isEqualTo(200); + } + + @Test + void noKey_isRejectedWith401() throws Exception { + HttpResponse response = + postMcp(b -> b, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}"); + assertThat(response.statusCode()).isEqualTo(401); + } + + @Test + void wrongKey_isRejectedWith401() throws Exception { + HttpResponse response = + postMcp( + b -> b.header("X-API-KEY", "not-a-real-key"), + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}"); + assertThat(response.statusCode()).isEqualTo(401); + } + + @Test + void noOAuthMetadataInApiKeyMode() throws Exception { + HttpRequest req = + HttpRequest.newBuilder() + .uri(URI.create(base() + "/.well-known/oauth-protected-resource")) + .GET() + .build(); + HttpResponse response = http.send(req, HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).isNotEqualTo(200); + } + + private HttpResponse postMcp( + java.util.function.UnaryOperator headers, String body) + throws Exception { + HttpRequest.Builder builder = + HttpRequest.newBuilder() + .uri(URI.create(base() + "/mcp")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)); + return http.send(headers.apply(builder).build(), HttpResponse.BodyHandlers.ofString()); + } + + private String base() { + return "http://localhost:" + port; + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import({ + McpSecurityConfig.class, + McpServerController.class, + DescribeOperationTool.class, + StirlingConvertTool.class, + StirlingPagesTool.class, + StirlingMiscTool.class, + StirlingSecurityTool.class, + StirlingAiTool.class + }) + static class TestApp { + + @Bean + ApplicationProperties applicationProperties() { + ApplicationProperties props = new ApplicationProperties(); + props.getMcp().setEnabled(true); + props.getMcp().getAuth().setMode("apikey"); + props.getAutomaticallyGenerated().setAppVersion("test"); + return props; + } + + @Bean + UserService userService() { + UserService mock = org.mockito.Mockito.mock(UserService.class); + User account = org.mockito.Mockito.mock(User.class); + org.mockito.Mockito.when(account.isEnabled()).thenReturn(true); + org.mockito.Mockito.when(account.getUsername()).thenReturn("alice"); + org.mockito.Mockito.when(mock.getUserByApiKey(org.mockito.ArgumentMatchers.anyString())) + .thenReturn(Optional.empty()); + org.mockito.Mockito.when(mock.getUserByApiKey(VALID_KEY)) + .thenReturn(Optional.of(account)); + return mock; + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpAudienceValidatorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpAudienceValidatorTest.java new file mode 100644 index 000000000..06f23e1ff --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpAudienceValidatorTest.java @@ -0,0 +1,64 @@ +package stirling.software.proprietary.mcp.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; + +/** RFC 8707 audience validator: token {@code aud} must list the resource id; blank fails closed. */ +class McpAudienceValidatorTest { + + private static final String RESOURCE = "http://localhost:8080/mcp"; + + private final McpAudienceValidator validator = new McpAudienceValidator(RESOURCE); + + @Test + void matchingAudience_isAccepted() { + Jwt token = tokenWithAudience(List.of(RESOURCE)); + OAuth2TokenValidatorResult result = validator.validate(token); + assertThat(result.hasErrors()).isFalse(); + } + + @Test + void multiAudienceIncludingResource_isAccepted() { + Jwt token = tokenWithAudience(List.of("https://other.example.com", RESOURCE)); + OAuth2TokenValidatorResult result = validator.validate(token); + assertThat(result.hasErrors()).isFalse(); + } + + @Test + void wrongAudience_isRejected() { + Jwt token = tokenWithAudience(List.of("https://other.example.com")); + OAuth2TokenValidatorResult result = validator.validate(token); + assertThat(result.hasErrors()).isTrue(); + assertThat(result.getErrors()).anyMatch(e -> e.getErrorCode().equals("invalid_token")); + } + + @Test + void missingAudienceClaim_isRejected() { + Jwt token = tokenWithAudience(null); + OAuth2TokenValidatorResult result = validator.validate(token); + assertThat(result.hasErrors()).isTrue(); + } + + @Test + void blankResourceId_failsClosed_rejectingEvenMatchingTokens() { + McpAudienceValidator blank = new McpAudienceValidator(""); + OAuth2TokenValidatorResult result = blank.validate(tokenWithAudience(List.of(RESOURCE))); + assertThat(result.hasErrors()).isTrue(); + } + + private static Jwt tokenWithAudience(List audience) { + return new Jwt( + "header.payload.signature", + Instant.now(), + Instant.now().plusSeconds(60), + Map.of("alg", "RS256"), + audience == null ? Map.of("sub", "u1") : Map.of("sub", "u1", "aud", audience)); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpAuthenticationEntryPointTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpAuthenticationEntryPointTest.java new file mode 100644 index 000000000..d1d89779e --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpAuthenticationEntryPointTest.java @@ -0,0 +1,61 @@ +package stirling.software.proprietary.mcp.security; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** The resource_metadata URL must reflect the public host behind a reverse proxy. */ +class McpAuthenticationEntryPointTest { + + private static final String META = "/.well-known/oauth-protected-resource"; + private final McpAuthenticationEntryPoint entryPoint = new McpAuthenticationEntryPoint(META); + + @Test + void usesForwardedHeadersForMetadataUrl() throws Exception { + HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getScheme()).thenReturn("http"); + when(req.getServerName()).thenReturn("internal-host"); + when(req.getServerPort()).thenReturn(8080); + when(req.getHeader("X-Forwarded-Proto")).thenReturn("https"); + when(req.getHeader("X-Forwarded-Host")).thenReturn("mcp.example.com"); + when(req.getHeader("X-Forwarded-Port")).thenReturn("443"); + HttpServletResponse resp = mock(HttpServletResponse.class); + + entryPoint.commence(req, resp, null); + + ArgumentCaptor header = ArgumentCaptor.forClass(String.class); + verify(resp).setHeader(eq("WWW-Authenticate"), header.capture()); + String www = header.getValue(); + assertTrue( + www.contains("resource_metadata=\"https://mcp.example.com" + META + "\""), + "must use forwarded host/proto, got: " + www); + assertFalse(www.contains("internal-host"), "internal host must not leak"); + verify(resp).sendError(anyInt(), anyString()); + } + + @Test + void fallsBackToServletHostWithoutForwardedHeaders() throws Exception { + HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getScheme()).thenReturn("http"); + when(req.getServerName()).thenReturn("localhost"); + when(req.getServerPort()).thenReturn(8080); + HttpServletResponse resp = mock(HttpServletResponse.class); + + entryPoint.commence(req, resp, null); + + ArgumentCaptor header = ArgumentCaptor.forClass(String.class); + verify(resp).setHeader(eq("WWW-Authenticate"), header.capture()); + assertTrue(header.getValue().contains("http://localhost:8080" + META), header.getValue()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpOAuthIntegrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpOAuthIntegrationTest.java new file mode 100644 index 000000000..53f0b2660 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpOAuthIntegrationTest.java @@ -0,0 +1,337 @@ +package stirling.software.proprietary.mcp.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.time.Instant; +import java.util.Date; +import java.util.List; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.mcp.McpServerController; +import stirling.software.proprietary.mcp.tools.DescribeOperationTool; +import stirling.software.proprietary.mcp.tools.StirlingAiTool; +import stirling.software.proprietary.mcp.tools.StirlingConvertTool; +import stirling.software.proprietary.mcp.tools.StirlingMiscTool; +import stirling.software.proprietary.mcp.tools.StirlingPagesTool; +import stirling.software.proprietary.mcp.tools.StirlingSecurityTool; +import stirling.software.proprietary.security.service.UserService; + +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; + +/** + * End-to-end OAuth test against the real {@link McpSecurityConfig} chain. A real RSA keypair signs + * JWTs; the public key is served as JWKS over HTTP (mockwebserver) and the resource server fetches + * and validates against it. The JDK HttpClient drives a live Jetty instance on a random port. + */ +@SpringBootTest( + classes = McpOAuthIntegrationTest.TestApp.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class McpOAuthIntegrationTest { + + private static final String ISSUER = "https://test-issuer.example.com"; + private static final String RESOURCE_ID = "http://localhost/mcp"; + + private static MockWebServer jwksServer; + private static RSAPrivateKey privateKey; + private static final String KEY_ID = "mcp-test-key"; + + @LocalServerPort private int port; + + private final HttpClient http = HttpClient.newHttpClient(); + + @BeforeAll + static void startJwks() throws Exception { + KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA"); + gen.initialize(2048); + KeyPair kp = gen.generateKeyPair(); + privateKey = (RSAPrivateKey) kp.getPrivate(); + RSAPublicKey publicKey = (RSAPublicKey) kp.getPublic(); + + RSAKey jwk = new RSAKey.Builder(publicKey).keyID(KEY_ID).build(); + String jwksJson = new JWKSet(jwk).toString(); + + jwksServer = new MockWebServer(); + jwksServer.setDispatcher( + new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + return new MockResponse() + .setHeader("Content-Type", "application/json") + .setBody(jwksJson); + } + }); + jwksServer.start(); + } + + @AfterAll + static void stopJwks() throws Exception { + if (jwksServer != null) { + jwksServer.shutdown(); + } + } + + @DynamicPropertySource + static void mcpProperties(DynamicPropertyRegistry registry) { + registry.add("mcp.enabled", () -> "true"); + registry.add("mcp.auth.issuer-uri", () -> ISSUER); + registry.add("mcp.auth.jwks-uri", () -> jwksServer.url("/jwks").toString()); + registry.add("mcp.auth.resource-id", () -> RESOURCE_ID); + } + + @Test + void noToken_returns401WithResourceMetadataHeader() throws Exception { + HttpResponse response = + postMcp(null, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}"); + assertThat(response.statusCode()).isEqualTo(401); + String wwwAuth = response.headers().firstValue("WWW-Authenticate").orElse(""); + assertThat(wwwAuth).contains("resource_metadata="); + assertThat(wwwAuth).contains("/.well-known/oauth-protected-resource"); + } + + @Test + void validToken_callsToolsListSuccessfully() throws Exception { + String token = + mintToken( + ISSUER, + List.of(RESOURCE_ID), + "mcp.tools.read mcp.tools.write", + Instant.now().plusSeconds(300)); + HttpResponse response = + postMcp(token, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}"); + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.body()).contains("stirling_describe_operation"); + } + + @Test + void wrongAudience_isRejected() throws Exception { + String token = + mintToken( + ISSUER, + List.of("https://some-other-resource.example.com"), + "mcp.tools.read", + Instant.now().plusSeconds(300)); + HttpResponse response = + postMcp(token, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}"); + assertThat(response.statusCode()).isEqualTo(401); + } + + @Test + void expiredToken_isRejected() throws Exception { + String token = + mintToken( + ISSUER, + List.of(RESOURCE_ID), + "mcp.tools.read", + Instant.now().minusSeconds(60)); + HttpResponse response = + postMcp(token, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}"); + assertThat(response.statusCode()).isEqualTo(401); + } + + @Test + void validTokenButNoStirlingAccount_isRejectedWith403() throws Exception { + // 'ghost-user' is not provisioned, so account-binding rejects an otherwise-valid token. + String token = + mintToken( + "ghost-user", + ISSUER, + List.of(RESOURCE_ID), + "mcp.tools.read mcp.tools.write", + Instant.now().plusSeconds(300)); + HttpResponse response = + postMcp(token, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}"); + assertThat(response.statusCode()).isEqualTo(403); + } + + @Test + void oversizedBody_isRejectedWith413() throws Exception { + String token = + mintToken( + ISSUER, + List.of(RESOURCE_ID), + "mcp.tools.read mcp.tools.write", + Instant.now().plusSeconds(300)); + StringBuilder big = + new StringBuilder("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"x\",\"params\":\""); + big.append("A".repeat(300 * 1024)); + big.append("\"}"); + HttpResponse response = postMcp(token, big.toString()); + assertThat(response.statusCode()).isEqualTo(413); + } + + @Test + void oversizedChunkedBody_isRejectedWith413() throws Exception { + // No Content-Length (chunked transfer) exercises the streaming cap rather than the fast + // check. + String token = + mintToken( + ISSUER, + List.of(RESOURCE_ID), + "mcp.tools.read mcp.tools.write", + Instant.now().plusSeconds(300)); + byte[] big = + ("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"x\",\"params\":\"" + + "A".repeat(300 * 1024) + + "\"}") + .getBytes(java.nio.charset.StandardCharsets.UTF_8); + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(base() + "/mcp")) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + token) + .POST( + HttpRequest.BodyPublishers.ofInputStream( + () -> new java.io.ByteArrayInputStream(big))) + .build(); + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).isEqualTo(413); + } + + @Test + void malformedJson_returnsJsonRpcParseErrorEnvelope() throws Exception { + // Unparseable JSON must be wrapped as a JSON-RPC Parse error (-32700), not Spring's HTML + // 400. + String token = + mintToken( + ISSUER, + List.of(RESOURCE_ID), + "mcp.tools.read mcp.tools.write", + Instant.now().plusSeconds(300)); + HttpResponse response = postMcp(token, "{ this is not valid json "); + assertThat(response.statusCode()).isEqualTo(400); + assertThat(response.body()).contains("-32700"); + } + + @Test + void metadataEndpoint_isReachableWithoutToken() throws Exception { + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(base() + "/.well-known/oauth-protected-resource")) + .GET() + .build(); + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.body()).contains(RESOURCE_ID); + assertThat(response.body()).contains(ISSUER); + assertThat(response.body()).contains("mcp.tools.read"); + } + + private HttpResponse postMcp(String token, String body) throws Exception { + HttpRequest.Builder builder = + HttpRequest.newBuilder() + .uri(URI.create(base() + "/mcp")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)); + if (token != null) { + builder.header("Authorization", "Bearer " + token); + } + return http.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + } + + private String base() { + return "http://localhost:" + port; + } + + private static String mintToken( + String issuer, List audience, String scope, Instant expiry) { + return mintToken("test-user", issuer, audience, scope, expiry); + } + + private static String mintToken( + String subject, String issuer, List audience, String scope, Instant expiry) { + try { + JWTClaimsSet claims = + new JWTClaimsSet.Builder() + .issuer(issuer) + .subject(subject) + .audience(audience) + .claim("scope", scope) + .issueTime(Date.from(Instant.now().minusSeconds(5))) + .expirationTime(Date.from(expiry)) + .build(); + SignedJWT jwt = + new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(KEY_ID).build(), + claims); + jwt.sign(new RSASSASigner(privateKey)); + return jwt.serialize(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import({ + McpSecurityConfig.class, + McpServerController.class, + DescribeOperationTool.class, + StirlingConvertTool.class, + StirlingPagesTool.class, + StirlingMiscTool.class, + StirlingSecurityTool.class, + StirlingAiTool.class + }) + static class TestApp { + + @Bean + ApplicationProperties applicationProperties() { + ApplicationProperties props = new ApplicationProperties(); + props.getMcp().setEnabled(true); + props.getMcp().setMaxRequestBytes(256L * 1024); + props.getMcp().getAuth().setIssuerUri(ISSUER); + props.getMcp().getAuth().setJwksUri(jwksServer.url("/jwks").toString()); + props.getMcp().getAuth().setResourceId(RESOURCE_ID); + props.getAutomaticallyGenerated().setAppVersion("test"); + return props; + } + + /** Stub UserService: only 'test-user' is a provisioned, enabled account. */ + @Bean + UserService userService() { + UserService mock = org.mockito.Mockito.mock(UserService.class); + stirling.software.proprietary.security.model.User account = + org.mockito.Mockito.mock( + stirling.software.proprietary.security.model.User.class); + org.mockito.Mockito.when(account.isEnabled()).thenReturn(true); + org.mockito.Mockito.when(account.getUsername()).thenReturn("test-user"); + org.mockito.Mockito.when( + mock.findByUsernameIgnoreCase(org.mockito.ArgumentMatchers.anyString())) + .thenReturn(java.util.Optional.empty()); + org.mockito.Mockito.when(mock.findByUsernameIgnoreCase("test-user")) + .thenReturn(java.util.Optional.of(account)); + return mock; + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/tools/CategoryToolDispatchTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/tools/CategoryToolDispatchTest.java new file mode 100644 index 000000000..de843ae42 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/tools/CategoryToolDispatchTest.java @@ -0,0 +1,121 @@ +package stirling.software.proprietary.mcp.tools; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; + +import stirling.software.proprietary.mcp.McpCallContext; +import stirling.software.proprietary.mcp.catalog.McpToolCatalog; +import stirling.software.proprietary.mcp.catalog.OperationCategory; +import stirling.software.proprietary.mcp.catalog.OperationMeta; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +/** + * PDF category tools must not fake success: a bad/missing op returns the operation list, a valid + * scoped op delegates to the executor, and a missing scope is refused. + */ +class CategoryToolDispatchTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + private OperationMeta miscOp() { + return new OperationMeta( + "compress-pdf", + OperationCategory.MISC, + "Compress a PDF", + mapper.createObjectNode(), + "mcp.tools.write", + OperationMeta.Target.JAVA_ENDPOINT, + "/api/v1/misc/compress-pdf", + null); + } + + private McpOperationExecutor executorReturning(ObjectNode sentinel) { + McpOperationExecutor executor = mock(McpOperationExecutor.class); + when(executor.execute(any(), any())).thenReturn(sentinel); + return executor; + } + + private StirlingMiscTool toolWith(McpOperationExecutor executor) { + OperationMeta meta = miscOp(); + McpToolCatalog catalog = mock(McpToolCatalog.class); + when(catalog.findByOperationId("compress-pdf")).thenReturn(Optional.of(meta)); + when(catalog.enabledOps(OperationCategory.MISC)).thenReturn(List.of(meta)); + @SuppressWarnings("unchecked") + ObjectProvider catalogProvider = mock(ObjectProvider.class); + when(catalogProvider.getIfAvailable()).thenReturn(catalog); + @SuppressWarnings("unchecked") + ObjectProvider executorProvider = mock(ObjectProvider.class); + when(executorProvider.getIfAvailable()).thenReturn(executor); + return new StirlingMiscTool(mapper, catalogProvider, executorProvider); + } + + private ObjectNode args(String op) { + ObjectNode a = mapper.createObjectNode(); + if (op != null) { + a.put("operation", op); + } + return a; + } + + private String textOf(ObjectNode result) { + return result.get("content").get(0).get("text").asText(); + } + + @Test + void validOpWithScope_delegatesToExecutor() { + ObjectNode sentinel = McpResponses.text(mapper, "EXECUTED"); + StirlingMiscTool tool = toolWith(executorReturning(sentinel)); + McpCallContext ctx = new McpCallContext("user", Set.of("mcp.tools.write"), true); + + ObjectNode result = tool.call(args("compress-pdf"), ctx); + + assertEquals("EXECUTED", textOf(result), "valid scoped op must run via the executor"); + } + + @Test + void unknownOperation_returnsAvailableOperationList() { + StirlingMiscTool tool = toolWith(executorReturning(mapper.createObjectNode())); + McpCallContext ctx = new McpCallContext("user", Set.of("mcp.tools.write"), true); + + ObjectNode result = tool.call(args("does-not-exist"), ctx); + + assertTrue(result.path("isError").asBoolean(false)); + String text = textOf(result); + assertTrue(text.contains("Available operations"), "should list available ops: " + text); + assertTrue(text.contains("compress-pdf"), "should include the valid op id: " + text); + } + + @Test + void missingOperation_returnsAvailableOperationList() { + StirlingMiscTool tool = toolWith(executorReturning(mapper.createObjectNode())); + McpCallContext ctx = new McpCallContext("user", Set.of("mcp.tools.write"), true); + + ObjectNode result = tool.call(args(null), ctx); + + assertTrue(result.path("isError").asBoolean(false)); + assertTrue(textOf(result).contains("Available operations")); + } + + @Test + void missingScope_returnsScopeError() { + StirlingMiscTool tool = toolWith(executorReturning(mapper.createObjectNode())); + McpCallContext ctx = new McpCallContext("user", Set.of("mcp.tools.read"), true); + + ObjectNode result = tool.call(args("compress-pdf"), ctx); + + assertTrue(result.path("isError").asBoolean(false)); + assertTrue(textOf(result).toLowerCase().contains("scope")); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/tools/FileToolScopeTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/tools/FileToolScopeTest.java new file mode 100644 index 000000000..966420548 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/tools/FileToolScopeTest.java @@ -0,0 +1,55 @@ +package stirling.software.proprietary.mcp.tools; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.FileStorage; +import stirling.software.proprietary.mcp.McpCallContext; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +/** stirling_upload requires write scope; stirling_download requires read scope. */ +class FileToolScopeTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + private McpCallContext noScopes() { + return new McpCallContext("user", Set.of(), true); + } + + private String text(ObjectNode result) { + return result.get("content").get(0).get("text").asText(); + } + + @Test + void upload_withoutWriteScope_isRefused() { + StirlingUploadTool tool = new StirlingUploadTool(mapper, mock(FileStorage.class)); + ObjectNode args = mapper.createObjectNode(); + args.put("file", "YWJj"); + + ObjectNode result = tool.call(args, noScopes()); + + assertTrue(result.path("isError").asBoolean(false)); + assertTrue(text(result).toLowerCase().contains("scope")); + } + + @Test + void download_withoutReadScope_isRefused() { + StirlingDownloadTool tool = + new StirlingDownloadTool( + mapper, mock(FileStorage.class), new ApplicationProperties()); + ObjectNode args = mapper.createObjectNode(); + args.put("fileId", "abc"); + + ObjectNode result = tool.call(args, noScopes()); + + assertTrue(result.path("isError").asBoolean(false)); + assertTrue(text(result).toLowerCase().contains("scope")); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/tools/McpOperationExecutorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/tools/McpOperationExecutorTest.java new file mode 100644 index 000000000..f2d938714 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/tools/McpOperationExecutorTest.java @@ -0,0 +1,146 @@ +package stirling.software.proprietary.mcp.tools; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.util.MultiValueMap; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.FileStorage; +import stirling.software.common.service.InternalApiClient; +import stirling.software.proprietary.mcp.catalog.OperationCategory; +import stirling.software.proprietary.mcp.catalog.OperationMeta; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +class McpOperationExecutorTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + private OperationMeta compressOp() { + return new OperationMeta( + "compress-pdf", + OperationCategory.MISC, + "Compress", + mapper.createObjectNode(), + "mcp.tools.write", + OperationMeta.Target.JAVA_ENDPOINT, + "/api/v1/misc/compress-pdf", + null); + } + + private ResponseEntity pdfResponse(byte[] bytes) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_PDF); + Resource body = + new ByteArrayResource(bytes) { + @Override + public String getFilename() { + return "out.pdf"; + } + }; + return ResponseEntity.ok().headers(headers).body(body); + } + + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + void inlineBase64Input_runsAndReturnsResultInline() throws Exception { + InternalApiClient api = mock(InternalApiClient.class); + FileStorage storage = mock(FileStorage.class); + ApplicationProperties props = new ApplicationProperties(); + + when(api.post(eq("/api/v1/misc/compress-pdf"), any())) + .thenReturn(pdfResponse("RESULT".getBytes(StandardCharsets.UTF_8))); + when(storage.storeBytes(any(), anyString())).thenReturn("result-123"); + + McpOperationExecutor executor = new McpOperationExecutor(mapper, api, storage, props); + + ObjectNode args = mapper.createObjectNode(); + args.put("operation", "compress-pdf"); + args.put( + "file", + Base64.getEncoder().encodeToString("INPUT".getBytes(StandardCharsets.UTF_8))); + args.putObject("parameters").put("optimizeLevel", 2); + + ObjectNode result = executor.execute(compressOp(), args); + + assertFalse(result.path("isError").asBoolean(false)); + + ArgumentCaptor bodyCap = ArgumentCaptor.forClass(MultiValueMap.class); + verify(api).post(eq("/api/v1/misc/compress-pdf"), bodyCap.capture()); + MultiValueMap captured = bodyCap.getValue(); + assertTrue(captured.containsKey("fileInput"), "must send fileInput"); + assertEquals("2", String.valueOf(captured.getFirst("optimizeLevel")), "must pass params"); + + String text = result.get("content").get(0).get("text").asText(); + assertTrue(text.contains("result-123"), "must report the result fileId: " + text); + ObjectNode resBlock = (ObjectNode) result.get("content").get(1); + assertEquals("resource", resBlock.get("type").asText()); + String blob = resBlock.get("resource").get("blob").asText(); + assertEquals( + "RESULT", new String(Base64.getDecoder().decode(blob), StandardCharsets.UTF_8)); + } + + @Test + void missingFile_returnsError() { + McpOperationExecutor executor = + new McpOperationExecutor( + mapper, + mock(InternalApiClient.class), + mock(FileStorage.class), + new ApplicationProperties()); + ObjectNode args = mapper.createObjectNode(); + args.put("operation", "compress-pdf"); + + ObjectNode result = executor.execute(compressOp(), args); + + assertTrue(result.path("isError").asBoolean(false)); + assertTrue( + result.get("content") + .get(0) + .get("text") + .asText() + .toLowerCase() + .contains("input file")); + } + + @Test + void fileIdInput_retrievesStoredBytes() throws Exception { + InternalApiClient api = mock(InternalApiClient.class); + FileStorage storage = mock(FileStorage.class); + when(storage.fileExists("abc")).thenReturn(true); + when(storage.retrieveBytes("abc")).thenReturn("INPUT".getBytes(StandardCharsets.UTF_8)); + when(api.post(anyString(), any())) + .thenReturn(pdfResponse("OUT".getBytes(StandardCharsets.UTF_8))); + when(storage.storeBytes(any(), anyString())).thenReturn("res"); + + McpOperationExecutor executor = + new McpOperationExecutor(mapper, api, storage, new ApplicationProperties()); + ObjectNode args = mapper.createObjectNode(); + args.put("operation", "compress-pdf"); + args.put("fileId", "abc"); + + ObjectNode result = executor.execute(compressOp(), args); + + assertFalse(result.path("isError").asBoolean(false)); + verify(storage).retrieveBytes("abc"); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiEngineClientTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiEngineClientTest.java index f4c9af592..259ae55a0 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiEngineClientTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiEngineClientTest.java @@ -84,4 +84,47 @@ class AiEngineClientTest { assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode()); } + + @Test + @SuppressWarnings("unchecked") + void allVerbsSendEngineAuthHeaderWhenSecretConfigured() throws Exception { + // Regression for the engine shared-secret hardening: every verb that hits a non-public + // engine route (post/delete/get) must present X-Engine-Auth, or the route 401s once the + // secret is set. delete() backs the logout-time RAG purge, so a miss silently leaks data. + AiEngineClient secured = + new AiEngineClient(applicationProperties, httpClient, "top-secret"); + HttpResponse ok = mock(HttpResponse.class); + when(ok.statusCode()).thenReturn(200); + when(ok.body()).thenReturn("{}"); + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(java.net.http.HttpRequest.class); + when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class))).thenReturn(ok); + + secured.post("/api/v1/pdf-question", "{}", "alice"); + secured.delete("/api/v1/documents/by-owner", "alice"); + secured.get("/api/v1/x", "alice"); + + for (java.net.http.HttpRequest req : captor.getAllValues()) { + assertEquals( + "top-secret", + req.headers().firstValue("X-Engine-Auth").orElse(null), + req.method() + " must carry the engine shared secret"); + } + } + + @Test + @SuppressWarnings("unchecked") + void noEngineAuthHeaderWhenSecretUnset() throws Exception { + AiEngineClient noSecret = new AiEngineClient(applicationProperties, httpClient, null); + HttpResponse ok = mock(HttpResponse.class); + when(ok.statusCode()).thenReturn(200); + when(ok.body()).thenReturn("{}"); + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(java.net.http.HttpRequest.class); + when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class))).thenReturn(ok); + + noSecret.delete("/api/v1/documents/by-owner", "alice"); + + assertEquals(null, captor.getValue().headers().firstValue("X-Engine-Auth").orElse(null)); + } } diff --git a/engine/src/stirling/api/agent_capabilities.py b/engine/src/stirling/api/agent_capabilities.py new file mode 100644 index 000000000..2d552ea55 --- /dev/null +++ b/engine/src/stirling/api/agent_capabilities.py @@ -0,0 +1,160 @@ +""" +Curated registry of agent capabilities the MCP server (Java side) is allowed to publish. + +Internal sub-agents (currently only ``ExecutionPlanningAgent`` - it lives behind the orchestrator +and has no end-user-facing API surface) are intentionally absent. The handoff spec calls for +"user-facing" capabilities only; revisit this list when adding a new agent and ask whether MCP +clients should be able to invoke it directly. + +The Java side pulls ``/api/v1/agents/capabilities`` once at boot and again every few minutes; the +manifest is the authoritative source for the ``stirling_ai`` MCP tool's operation enum. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from pydantic import BaseModel + +from stirling.contracts import ( + AgentDraftRequest, + AgentExecutionRequest, + AgentRevisionRequest, + Evidence, + FolioManifest, + PdfCommentRequest, + PdfEditRequest, + PdfQuestionRequest, +) + + +@dataclass(frozen=True) +class AgentCapability: + """One row in the curated manifest. + + Attributes: + id: stable capability identifier (used as the operation enum value in + ``stirling_ai``). Avoid renaming - clients persist these. + description: one-line human-friendly summary shown inside MCP tool descriptions. + input_model: Pydantic class whose JSON Schema becomes the capability's + ``input_schema``. Auto-derived; do not hand-write schemas. + mode: ``"sync"`` if the capability returns content inline, ``"async"`` if it returns a + plan that Java executes via the job pipeline. + required_scope: coarse OAuth scope. ``mcp.tools.read`` for pure-read capabilities + (Q&A, audits) and ``mcp.tools.write`` for anything that yields a plan / file. + route: HTTP path Java POSTs to when invoking this capability. When a capability does + not have a stable per-agent route yet, use the generic invoke fallback at + ``/api/v1/agents/invoke/{id}``. + """ + + id: str + description: str + input_model: type[BaseModel] + mode: str + required_scope: str + route: str + + +EXPOSED_CAPABILITIES: list[AgentCapability] = [ + AgentCapability( + id="pdf-question-answer", + description="Answer a natural-language question about a PDF document.", + input_model=PdfQuestionRequest, + mode="sync", + required_scope="mcp.tools.read", + route="/api/v1/pdf-question", + ), + AgentCapability( + id="pdf-edit-plan", + description=( + "Produce an edit plan (a structured sequence of PDF operations) from a" + " natural-language edit request. The plan is executed by Java through the job" + " pipeline; this capability does not modify files itself." + ), + input_model=PdfEditRequest, + mode="async", + required_scope="mcp.tools.write", + route="/api/v1/pdf-edit", + ), + AgentCapability( + id="agent-draft", + description=( + "Draft a structured agent specification from a free-text description of the task the user wants automated." + ), + input_model=AgentDraftRequest, + mode="sync", + required_scope="mcp.tools.read", + route="/api/v1/ai/agents/draft", + ), + AgentCapability( + id="agent-revise", + description=("Revise an existing draft agent specification based on user feedback or constraint changes."), + input_model=AgentRevisionRequest, + mode="sync", + required_scope="mcp.tools.read", + route="/api/v1/ai/agents/revise", + ), + AgentCapability( + id="math-audit-examine", + description=( + "Examine a folio manifest of financial / numeric documents and surface the" + " evidence that needs to be checked for arithmetic consistency." + ), + input_model=FolioManifest, + mode="sync", + required_scope="mcp.tools.read", + route="/api/v1/ai/math-auditor-agent/examine", + ), + AgentCapability( + id="math-audit-deliberate", + description=( + "Render a deliberated verdict on a single piece of evidence the examine step" + " surfaced (does the arithmetic check out, with what caveats)." + ), + input_model=Evidence, + mode="sync", + required_scope="mcp.tools.read", + route="/api/v1/ai/math-auditor-agent/deliberate", + ), + AgentCapability( + id="pdf-comment-generate", + description="Generate inline review comments for a PDF document.", + input_model=PdfCommentRequest, + mode="sync", + required_scope="mcp.tools.read", + route="/api/v1/pdf-comment/generate", + ), + AgentCapability( + id="agent-next-action", + description=( + "Decide the next execution step for an in-progress agent workflow. Returns a" + " ToolCall, Completed, or CannotContinue action." + ), + input_model=AgentExecutionRequest, + mode="sync", + required_scope="mcp.tools.read", + route="/api/v1/agents/next-action", + ), +] + + +def manifest_payload() -> dict[str, Any]: + """Serialize the curated registry to the wire shape consumed by Java. + + Schema is derived from ``input_model.model_json_schema()`` so we never hand-write JSON + Schema - the Pydantic model is the single source of truth. + """ + items: list[dict[str, Any]] = [] + for cap in EXPOSED_CAPABILITIES: + items.append( + { + "id": cap.id, + "description": cap.description, + "input_schema": cap.input_model.model_json_schema(), + "mode": cap.mode, + "required_scope": cap.required_scope, + "route": cap.route, + } + ) + return {"version": 1, "capabilities": items} diff --git a/engine/src/stirling/api/app.py b/engine/src/stirling/api/app.py index 1cf4b76c3..699cede06 100644 --- a/engine/src/stirling/api/app.py +++ b/engine/src/stirling/api/app.py @@ -18,8 +18,10 @@ from stirling.agents import ( ) from stirling.agents.ledger import MathAuditorAgent from stirling.agents.pdf_comment import PdfCommentAgent +from stirling.api.engine_auth import EngineSharedSecretMiddleware from stirling.api.middleware import UserIdMiddleware from stirling.api.routes import ( + agent_capabilities_router, agent_draft_router, document_router, execution_router, @@ -115,6 +117,7 @@ async def lifespan(fast_api: FastAPI): app = FastAPI(title="Stirling AI Engine", lifespan=lifespan, version="0.1.0") app.add_middleware(UserIdMiddleware) +app.add_middleware(EngineSharedSecretMiddleware) app.include_router(orchestrator_router) app.include_router(pdf_edit_router) app.include_router(pdf_question_router) @@ -123,6 +126,7 @@ app.include_router(execution_router) app.include_router(document_router) app.include_router(ledger_router) app.include_router(pdf_comments_router) +app.include_router(agent_capabilities_router) @app.get("/health", response_model=HealthResponse) diff --git a/engine/src/stirling/api/engine_auth.py b/engine/src/stirling/api/engine_auth.py new file mode 100644 index 000000000..c30b9f742 --- /dev/null +++ b/engine/src/stirling/api/engine_auth.py @@ -0,0 +1,99 @@ +""" +Shared-secret middleware that locks the engine to the trusted Java backend. + +Config (resolved via :class:`stirling.config.AppSettings`/pydantic-settings): +``STIRLING_ENGINE_SHARED_SECRET`` - non-public routes need ``X-Engine-Auth`` or 401. +``STIRLING_ENGINE_REQUIRE_AUTH`` - fail closed with 503 when truthy and no secret is set. +""" + +from __future__ import annotations + +import hmac +import logging +from collections.abc import Iterable + +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.types import ASGIApp + +from stirling.config import load_settings + +logger = logging.getLogger(__name__) + +_HEADER = "X-Engine-Auth" + +# Public paths (liveness + docs); everything else needs the secret when configured. +_PUBLIC_PREFIXES: tuple[str, ...] = ( + "/health", + "/docs", + "/redoc", + "/openapi.json", +) + + +class EngineSharedSecretMiddleware(BaseHTTPMiddleware): + """Reject non-public requests lacking the shared secret. + + Non-public path: secret set -> require matching X-Engine-Auth (else 401); else require flag + truthy -> 503 (fail-closed); else allow through. + + Secret/require values come from :class:`stirling.config.AppSettings` by default; tests can + pass them explicitly to avoid touching the lru-cached settings. + """ + + def __init__( + self, + app: ASGIApp, + public_prefixes: Iterable[str] = _PUBLIC_PREFIXES, + *, + secret: str | None = None, + require: bool | None = None, + ) -> None: + super().__init__(app) + self._public_prefixes = tuple(public_prefixes) + if secret is None or require is None: + settings = load_settings() + if secret is None: + secret = settings.engine_shared_secret + if require is None: + require = settings.engine_require_auth + self._secret = secret or "" + self._require = bool(require) + if self._secret: + logger.info( + "Engine shared-secret enforcement ENABLED: non-public routes require a valid %s" + " header (constant-time compared).", + _HEADER, + ) + elif self._require: + logger.error( + "STIRLING_ENGINE_REQUIRE_AUTH is enabled but STIRLING_ENGINE_SHARED_SECRET is not" + " set - the engine will REFUSE every non-public request (HTTP 503, fail-closed)" + " until a shared secret is configured.", + ) + else: + logger.warning( + "STIRLING_ENGINE_SHARED_SECRET not set - engine shared-secret enforcement is" + " DISABLED. The AI and document routes then trust the caller-supplied X-User-Id" + " header alone. Set this secret (and STIRLING_ENGINE_REQUIRE_AUTH=true) in any" + " deployment that exposes the engine beyond localhost.", + ) + + def _is_public(self, path: str) -> bool: + return any(path == p or path.startswith(p + "/") for p in self._public_prefixes) + + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + if not self._is_public(request.url.path): + if self._secret: + offered = request.headers.get(_HEADER) or "" + # Constant-time compare to avoid timing leaks. + if not hmac.compare_digest(offered, self._secret): + return JSONResponse({"detail": "Missing or invalid X-Engine-Auth header."}, status_code=401) + elif self._require: + # Fail closed: require flag set but no secret configured. + return JSONResponse( + {"detail": ("Engine authentication is required but no shared secret is configured.")}, + status_code=503, + ) + return await call_next(request) diff --git a/engine/src/stirling/api/routes/__init__.py b/engine/src/stirling/api/routes/__init__.py index 37572c5e7..1c1e4e1d2 100644 --- a/engine/src/stirling/api/routes/__init__.py +++ b/engine/src/stirling/api/routes/__init__.py @@ -1,3 +1,4 @@ +from .agent_capabilities import router as agent_capabilities_router from .agent_drafts import router as agent_draft_router from .documents import router as document_router from .execution import router as execution_router @@ -8,6 +9,7 @@ from .pdf_edit import router as pdf_edit_router from .pdf_questions import router as pdf_question_router __all__ = [ + "agent_capabilities_router", "agent_draft_router", "document_router", "execution_router", diff --git a/engine/src/stirling/api/routes/agent_capabilities.py b/engine/src/stirling/api/routes/agent_capabilities.py new file mode 100644 index 000000000..75f1d0f82 --- /dev/null +++ b/engine/src/stirling/api/routes/agent_capabilities.py @@ -0,0 +1,22 @@ +"""GET ``/api/v1/agents/capabilities`` - the manifest the Java MCP server pulls at boot.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter + +from stirling.api.agent_capabilities import manifest_payload + +router = APIRouter(prefix="/api/v1/agents", tags=["agents"]) + + +@router.get("/capabilities") +def get_capabilities() -> dict[str, Any]: + """Return the curated agent capabilities manifest. + + Gated by ``EngineSharedSecretMiddleware`` when the ``STIRLING_ENGINE_SHARED_SECRET`` env var + is configured. In dev/local mode (no secret set), the endpoint is open - the engine binds to + localhost only by default, so this is acceptable while iterating. + """ + return manifest_payload() diff --git a/engine/src/stirling/config/settings.py b/engine/src/stirling/config/settings.py index bd0499e6b..4cd2bbc9e 100644 --- a/engine/src/stirling/config/settings.py +++ b/engine/src/stirling/config/settings.py @@ -102,6 +102,11 @@ class AppSettings(BaseSettings): posthog_api_key: str = Field(validation_alias="STIRLING_POSTHOG_API_KEY") posthog_host: str = Field(validation_alias="STIRLING_POSTHOG_HOST") + # Shared secret enforced by EngineSharedSecretMiddleware. Empty disables enforcement + # unless engine_require_auth is set, in which case the engine fails closed (503). + engine_shared_secret: str = Field(default="", validation_alias="STIRLING_ENGINE_SHARED_SECRET") + engine_require_auth: bool = Field(default=False, validation_alias="STIRLING_ENGINE_REQUIRE_AUTH") + def _configure_logging(level_name: str, log_file: str, http_debug: bool) -> None: """Configure the ``stirling`` logger hierarchy.""" diff --git a/engine/tests/test_engine_auth.py b/engine/tests/test_engine_auth.py new file mode 100644 index 000000000..d831b1a67 --- /dev/null +++ b/engine/tests/test_engine_auth.py @@ -0,0 +1,89 @@ +"""Tests for the engine shared-secret middleware (EngineSharedSecretMiddleware).""" + +from __future__ import annotations + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from stirling.api.engine_auth import EngineSharedSecretMiddleware + +SECRET = "s3cr3t-between-java-and-engine" + +_TRUTHY = {"1", "true", "yes", "on"} + + +def _client(*, secret: str | None = None, require: str | None = None) -> TestClient: + # Values come from explicit kwargs rather than env so tests don't depend on the lru-cached + # AppSettings; this mirrors how production code wires the middleware via pydantic-settings. + require_flag = require is not None and require.strip().lower() in _TRUTHY + app = FastAPI() + app.add_middleware( + EngineSharedSecretMiddleware, + secret=secret or "", + require=require_flag, + ) + + @app.get("/health") + def health() -> dict[str, bool]: + return {"ok": True} + + @app.post("/v1/agents/invoke") + def invoke() -> dict[str, bool]: + return {"ran": True} + + return TestClient(app) + + +def test_dev_mode_open_when_unset(): + c = _client() + assert c.post("/v1/agents/invoke").status_code == 200 + + +def test_health_is_public_even_with_secret(): + c = _client(secret=SECRET) + assert c.get("/health").status_code == 200 # no header required + + +def test_missing_header_rejected(): + c = _client(secret=SECRET) + assert c.post("/v1/agents/invoke").status_code == 401 + + +def test_wrong_secret_rejected(): + c = _client(secret=SECRET) + r = c.post("/v1/agents/invoke", headers={"X-Engine-Auth": "not-the-secret"}) + assert r.status_code == 401 + + +def test_valid_secret_allowed(): + c = _client(secret=SECRET) + r = c.post("/v1/agents/invoke", headers={"X-Engine-Auth": SECRET}) + assert r.status_code == 200 + assert r.json() == {"ran": True} + + +def test_require_auth_fails_closed_without_secret(): + c = _client(require="true") + # Require flag, no secret -> protected routes refused. + assert c.post("/v1/agents/invoke").status_code == 503 + # Liveness still works for health checks. + assert c.get("/health").status_code == 200 + + +def test_require_auth_with_secret_enforces_normally(): + c = _client(secret=SECRET, require="true") + assert c.post("/v1/agents/invoke").status_code == 401 + assert c.post("/v1/agents/invoke", headers={"X-Engine-Auth": SECRET}).status_code == 200 + + +@pytest.mark.parametrize("flag", ["true", "1", "YES", "On"]) +def test_require_flag_truthy_variants(flag: str): + c = _client(require=flag) + assert c.post("/v1/agents/invoke").status_code == 503 + + +@pytest.mark.parametrize("flag", ["false", "0", "no", ""]) +def test_require_flag_falsey_variants_stay_open(flag: str): + c = _client(require=flag) + assert c.post("/v1/agents/invoke").status_code == 200 diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index ddc117e01..cdec16d1e 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -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 ). The key maps to its owning Stirling user - only provisioned accounts get in, and actions are audited as that user. Manage keys under Account > API Keys." +description = "Expose Stirling's PDF tools and AI agents to MCP clients over an OAuth-protected endpoint." +title = "MCP Server" + +[admin.settings.mcp.allowedOps] +description = "If set, ONLY these operation ids are exposed (an allow-list; comma or space separated). Leave blank to expose all enabled tools except any blocked below." +label = "Allowed tools" + +[admin.settings.mcp.blockedOps] +description = "Operation ids to hide from MCP (comma or space separated), e.g. add-password remove-password. Leave blank to expose all enabled tools." +label = "Blocked tools" + +[admin.settings.mcp.enabled] +description = "When off (default), no /mcp endpoint, metadata, or MCP beans are loaded." +label = "Enable MCP Server" + +[admin.settings.mcp.guide] +step1 = "Enter your OAuth issuer + resource ID above, Save, and restart." +step1ApiKey = "Create an API key under Account > API Keys (each user uses their own)." +step2 = "In your MCP client add a" +step2b = "server pointing at:" +step3 = "The client auto-discovers OAuth from:" +step3ApiKey = "Send the key in an" +step3ApiKeyb = "header (or Authorization: Bearer ). No OAuth or metadata discovery is used." +step4 = "Approve the sign-in; the client retries with a token. Tools appear grouped: convert, pages, misc, security, ai." +step4ApiKey = "Tools appear grouped: convert, pages, misc, security, ai - and every call is audited as the key's owner." +tip = "Tip: register the resource ID as an allowed audience in your IdP. Tested with MCP Inspector and Claude Desktop." +tipApiKey = "Tip: API-key mode needs no external IdP - simplest for self-host. The key maps to its owning Stirling user." +title = "Connect an MCP client" + +[admin.settings.mcp.issuerUri] +description = "Your OAuth2 authorization server (must publish /.well-known/openid-configuration). Required when enabled." +label = "OAuth Issuer URL" + +[admin.settings.mcp.jwksUri] +description = "Leave blank to discover it from the issuer. Set only if your IdP serves keys at a non-standard URL." +label = "JWKS URL (optional)" +placeholder = "Auto-discovered from issuer" + +[admin.settings.mcp.mode] +description = "OAuth needs an external IdP. API key uses a Stirling per-user API key (X-API-KEY) - simplest for self-host." +label = "Authentication mode" + +[admin.settings.mcp.requireAccount] +description = "Only let tokens through if their subject maps to a provisioned, enabled Stirling user." +label = "Require an existing Stirling account" + +[admin.settings.mcp.resourceId] +description = "This server's public /mcp URL. Tokens must list it in their audience (RFC 8707) or they are rejected." +label = "Resource ID" + +[admin.settings.mcp.scopes] +description = "Require mcp.tools.read for read ops and mcp.tools.write for write/AI ops." +label = "Enforce OAuth scopes" + +[admin.settings.mcp.usernameClaim] +description = "JWT claim matched against a Stirling username (e.g. sub, email, preferred_username)." +label = "Username claim" + [admin.settings.plan.noData] message = "Plans data is not available at the moment." title = "No data available" @@ -7219,6 +7279,7 @@ advanced = "Advanced" database = "Database" endpoints = "Endpoints" features = "Features" +mcp = "MCP Server" storageSharing = "File Storage & Sharing" systemSettings = "System Settings" title = "Configuration" diff --git a/frontend/editor/src/core/components/shared/config/SettingsSearchBar.tsx b/frontend/editor/src/core/components/shared/config/SettingsSearchBar.tsx index e1114f3fc..ea1819dfb 100644 --- a/frontend/editor/src/core/components/shared/config/SettingsSearchBar.tsx +++ b/frontend/editor/src/core/components/shared/config/SettingsSearchBar.tsx @@ -40,6 +40,7 @@ const SETTINGS_SEARCH_TRANSLATION_PREFIXES: Partial> = adminDatabase: ["admin.settings.database"], adminAdvanced: ["admin.settings.advanced"], adminSecurity: ["admin.settings.security"], + adminMcp: ["admin.settings.mcp"], adminConnections: [ "admin.settings.connections", "admin.settings.mail", diff --git a/frontend/editor/src/core/components/shared/config/types.ts b/frontend/editor/src/core/components/shared/config/types.ts index 32081c5c4..22bdce81e 100644 --- a/frontend/editor/src/core/components/shared/config/types.ts +++ b/frontend/editor/src/core/components/shared/config/types.ts @@ -29,6 +29,7 @@ export const VALID_NAV_KEYS = [ "adminUsage", "adminEndpoints", "adminStorageSharing", + "adminMcp", "help", ] as const; diff --git a/frontend/editor/src/proprietary/components/shared/config/configNavSections.tsx b/frontend/editor/src/proprietary/components/shared/config/configNavSections.tsx index 19fe77559..7b9d0cfea 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configNavSections.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configNavSections.tsx @@ -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: , + disabled: requiresLogin, + disabledTooltip: requiresLogin ? enableLoginTooltip : undefined, + }, { key: "adminDatabase", label: t("settings.configuration.database", "Database"), diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminMcpSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminMcpSection.tsx new file mode 100644 index 000000000..5aa5eb003 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminMcpSection.tsx @@ -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 { + _pending?: Partial; +} + +type McpApiResponse = McpSettingsData & ApiResponseWithPending; + +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({ + sectionName: "mcp", + fetchTransformer: async (): Promise< + McpSettingsData & { _pending?: Partial } + > => { + const response = await apiClient.get( + "/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) => + setSettings({ ...settings, auth: { ...(settings.auth || {}), ...patch } }); + + if (loading) { + return ( + + + + ); + } + + 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 ( +

+ +
+ + {t("admin.settings.mcp.title", "MCP Server")} + + + {t( + "admin.settings.mcp.description", + "Expose Stirling's PDF tools and AI agents to MCP clients over an OAuth-protected endpoint.", + )} + +
+ + + + +
+ + {t("admin.settings.mcp.enabled.label", "Enable MCP Server")} + + + {t( + "admin.settings.mcp.enabled.description", + "When off (default), no /mcp endpoint, metadata, or MCP beans are loaded.", + )} + +
+ + + setSettings({ ...settings, enabled: e.target.checked }) + } + /> + + +
+ +