mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 03:20:46 +02:00
Add MCP server with OAuth/API-key auth (#6570)
Adds an optional MCP server (proprietary module) that exposes Stirling's PDF operations and AI capabilities to MCP clients. Off by default, zero footprint when disabled. ### What - New `/mcp` endpoint: streamable-HTTP + JSON-RPC 2.0; 8 tools (describe_operation, pages/convert/misc/security category tools, AI, upload, download). - Runs real operations over an internal loopback; results returned inline as base64 (small) or by fileId (large). ### Auth (two modes) - OAuth2 resource server: RFC 9728 protected-resource metadata, RFC 8707 audience binding, JWKS, `mcp.tools.read/write` scopes; binds each token to a provisioned Stirling account. - API-key mode: reuses Stirling per-user `X-API-KEY` (no IdP needed). ### Security - Per-user file ownership in FileStorage: async/queued writes scoped to the submitting user; legacy/owner-less files stay readable. - Admin allow/block list controls which operations are exposed. - Python engine gated behind a shared secret (`X-Engine-Auth`). - MCP filter chain is isolated and cannot weaken the main app's security. - Hardened: no upstream error-body leakage, log injection sanitized, fileId path/sidecar enumeration blocked. ### Config / footprint - Disabled by default (`mcp.enabled=false`); all beans `@ConditionalOnProperty`. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details.
This commit is contained in:
+41
-5
@@ -6,6 +6,8 @@ import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -35,6 +37,7 @@ import software.amazon.awssdk.services.s3.model.S3Exception;
|
||||
public class S3FileStore implements FileStore, AutoCloseable {
|
||||
|
||||
public static final String DEFAULT_KEY_PREFIX = "transient/";
|
||||
static final String OWNER_METADATA_KEY = "owner";
|
||||
|
||||
private final S3Client s3Client;
|
||||
private final String bucket;
|
||||
@@ -64,7 +67,7 @@ public class S3FileStore implements FileStore, AutoCloseable {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stored store(InputStream in, String originalName) throws IOException {
|
||||
public Stored store(InputStream in, String originalName, String owner) throws IOException {
|
||||
String fileId = UUID.randomUUID().toString();
|
||||
// S3 PUT requires a known content-length; spool to a temp file first so memory stays
|
||||
// bounded for large payloads, then stream the file to S3 via RequestBody.fromFile.
|
||||
@@ -75,10 +78,13 @@ public class S3FileStore implements FileStore, AutoCloseable {
|
||||
Files.copy(src, tempFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
size = Files.size(tempFile);
|
||||
PutObjectRequest request =
|
||||
PutObjectRequest.builder().bucket(bucket).key(resolveKey(fileId)).build();
|
||||
PutObjectRequest.Builder builder =
|
||||
PutObjectRequest.builder().bucket(bucket).key(resolveKey(fileId));
|
||||
if (owner != null && !owner.isBlank()) {
|
||||
builder.metadata(Map.of(OWNER_METADATA_KEY, owner));
|
||||
}
|
||||
try {
|
||||
s3Client.putObject(request, RequestBody.fromFile(tempFile));
|
||||
s3Client.putObject(builder.build(), RequestBody.fromFile(tempFile));
|
||||
} catch (SdkException e) {
|
||||
throw new IOException("Failed to upload object to S3", e);
|
||||
}
|
||||
@@ -185,6 +191,36 @@ public class S3FileStore implements FileStore, AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getOwner(String fileId) throws IOException {
|
||||
try {
|
||||
validateFileId(fileId);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
HeadObjectRequest request =
|
||||
HeadObjectRequest.builder().bucket(bucket).key(resolveKey(fileId)).build();
|
||||
try {
|
||||
HeadObjectResponse response = s3Client.headObject(request);
|
||||
Map<String, String> metadata =
|
||||
Optional.ofNullable(response.metadata()).orElse(Collections.emptyMap());
|
||||
String owner = metadata.get(OWNER_METADATA_KEY);
|
||||
if (owner != null && !owner.isBlank()) {
|
||||
return owner;
|
||||
}
|
||||
return null;
|
||||
} catch (NoSuchKeyException e) {
|
||||
return null;
|
||||
} catch (S3Exception e) {
|
||||
if (e.statusCode() == 404) {
|
||||
return null;
|
||||
}
|
||||
throw new IOException("Failed to read owner metadata from S3", e);
|
||||
} catch (SdkException e) {
|
||||
throw new IOException("Failed to read owner metadata from S3", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (!ownsClient) {
|
||||
@@ -205,7 +241,7 @@ public class S3FileStore implements FileStore, AutoCloseable {
|
||||
if (fileId == null || fileId.isBlank()) {
|
||||
throw new IllegalArgumentException("File ID must not be blank");
|
||||
}
|
||||
if (fileId.contains("..") || fileId.contains("/") || fileId.contains("\\")) {
|
||||
if (fileId.contains(".") || fileId.contains("/") || fileId.contains("\\")) {
|
||||
throw new IllegalArgumentException("Invalid file ID");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package stirling.software.proprietary.mcp;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/** Per-call context: resolved Stirling identity and granted scopes for an {@link McpTool#call}. */
|
||||
public record McpCallContext(
|
||||
String stirlingUserId, Set<String> grantedScopes, boolean scopesEnabled) {
|
||||
|
||||
public boolean hasScope(String required) {
|
||||
if (!scopesEnabled) {
|
||||
return true;
|
||||
}
|
||||
return required == null || required.isBlank() || grantedScopes.contains(required);
|
||||
}
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
package stirling.software.proprietary.mcp;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.mcp.jsonrpc.JsonRpcError;
|
||||
import stirling.software.proprietary.mcp.jsonrpc.JsonRpcRequest;
|
||||
import stirling.software.proprietary.mcp.jsonrpc.JsonRpcResponse;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ArrayNode;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/** Streamable-HTTP MCP server endpoint serving JSON-RPC 2.0 frames on {@code POST /mcp}. */
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping
|
||||
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
|
||||
public class McpServerController {
|
||||
|
||||
private static final String PREFERRED_PROTOCOL_VERSION = "2025-06-18";
|
||||
private static final Set<String> SUPPORTED_PROTOCOL_VERSIONS =
|
||||
Set.of("2025-06-18", "2025-03-26", "2024-11-05");
|
||||
private static final String SERVER_NAME = "stirling-pdf-mcp";
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final Map<String, McpTool> toolsByName;
|
||||
|
||||
public McpServerController(
|
||||
ObjectMapper mapper, ApplicationProperties applicationProperties, List<McpTool> tools) {
|
||||
this.mapper = mapper;
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.toolsByName = new HashMap<>();
|
||||
for (McpTool tool : tools) {
|
||||
this.toolsByName.put(tool.name(), tool);
|
||||
}
|
||||
log.info(
|
||||
"MCP server controller wired with {} tool(s): {}",
|
||||
toolsByName.size(),
|
||||
toolsByName.keySet());
|
||||
}
|
||||
|
||||
@PostMapping(
|
||||
path = "/mcp",
|
||||
consumes = MediaType.APPLICATION_JSON_VALUE,
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<?> handle(@RequestBody JsonNode body) {
|
||||
JsonRpcRequest request = decode(body);
|
||||
if (request == null) {
|
||||
// Valid JSON but not a JSON-RPC request -> Invalid Request, not Parse error.
|
||||
return ResponseEntity.badRequest()
|
||||
.body(
|
||||
JsonRpcResponse.failure(
|
||||
null,
|
||||
JsonRpcError.invalidRequest(
|
||||
"Body is not a valid JSON-RPC 2.0 request")));
|
||||
}
|
||||
if (request.isNotification()) {
|
||||
log.debug("Notification received: {}", sanitizeForLog(request.method()));
|
||||
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
|
||||
}
|
||||
JsonRpcResponse response;
|
||||
try {
|
||||
response = dispatch(request);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn(
|
||||
"MCP dispatch failed for method {}: {}",
|
||||
sanitizeForLog(request.method()),
|
||||
e.getMessage(),
|
||||
e);
|
||||
response =
|
||||
JsonRpcResponse.failure(
|
||||
request.id(),
|
||||
JsonRpcError.internalError(
|
||||
"Internal error handling " + request.method()));
|
||||
}
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/** Wrap malformed-JSON failures (caught before {@link #handle}) as a JSON-RPC Parse error. */
|
||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||
public ResponseEntity<JsonRpcResponse> handleUnreadable(HttpMessageNotReadableException ex) {
|
||||
return ResponseEntity.badRequest()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(
|
||||
JsonRpcResponse.failure(
|
||||
null, JsonRpcError.parseError("Request body is not valid JSON")));
|
||||
}
|
||||
|
||||
private static String sanitizeForLog(String value) {
|
||||
return value == null ? null : value.replace('\r', ' ').replace('\n', ' ');
|
||||
}
|
||||
|
||||
private JsonRpcRequest decode(JsonNode body) {
|
||||
if (body == null || !body.isObject()) {
|
||||
return null;
|
||||
}
|
||||
JsonNode jsonrpc = body.get("jsonrpc");
|
||||
JsonNode method = body.get("method");
|
||||
if (jsonrpc == null || !"2.0".equals(jsonrpc.asText())) {
|
||||
return null;
|
||||
}
|
||||
if (method == null || !method.isTextual()) {
|
||||
return null;
|
||||
}
|
||||
return new JsonRpcRequest(
|
||||
jsonrpc.asText(), body.get("id"), method.asText(), body.get("params"));
|
||||
}
|
||||
|
||||
private JsonRpcResponse dispatch(JsonRpcRequest request) {
|
||||
return switch (request.method()) {
|
||||
case "initialize" ->
|
||||
JsonRpcResponse.success(request.id(), initializeResult(request.params()));
|
||||
case "tools/list" -> JsonRpcResponse.success(request.id(), toolsListResult());
|
||||
case "tools/call" -> handleToolsCall(request);
|
||||
case "ping" -> JsonRpcResponse.success(request.id(), mapper.createObjectNode());
|
||||
case "notifications/initialized" ->
|
||||
JsonRpcResponse.success(request.id(), mapper.createObjectNode());
|
||||
default ->
|
||||
JsonRpcResponse.failure(
|
||||
request.id(), JsonRpcError.methodNotFound(request.method()));
|
||||
};
|
||||
}
|
||||
|
||||
private ObjectNode initializeResult(JsonNode params) {
|
||||
ObjectNode result = mapper.createObjectNode();
|
||||
// Echo the client's requested protocolVersion when supported, else advertise our preferred.
|
||||
String requested =
|
||||
params != null && params.hasNonNull("protocolVersion")
|
||||
? params.get("protocolVersion").asText()
|
||||
: null;
|
||||
String negotiated =
|
||||
requested != null && SUPPORTED_PROTOCOL_VERSIONS.contains(requested)
|
||||
? requested
|
||||
: PREFERRED_PROTOCOL_VERSION;
|
||||
result.put("protocolVersion", negotiated);
|
||||
ObjectNode caps = result.putObject("capabilities");
|
||||
caps.putObject("tools");
|
||||
ObjectNode info = result.putObject("serverInfo");
|
||||
info.put("name", SERVER_NAME);
|
||||
info.put("version", applicationProperties.getAutomaticallyGenerated().getAppVersion());
|
||||
return result;
|
||||
}
|
||||
|
||||
private ObjectNode toolsListResult() {
|
||||
ObjectNode result = mapper.createObjectNode();
|
||||
ArrayNode tools = result.putArray("tools");
|
||||
for (McpTool t : toolsByName.values()) {
|
||||
ObjectNode entry = mapper.createObjectNode();
|
||||
entry.put("name", t.name());
|
||||
entry.put("description", t.description());
|
||||
entry.set("inputSchema", t.inputSchema());
|
||||
tools.add(entry);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private JsonRpcResponse handleToolsCall(JsonRpcRequest request) {
|
||||
JsonNode params = request.params();
|
||||
if (params == null || !params.isObject()) {
|
||||
return JsonRpcResponse.failure(
|
||||
request.id(), JsonRpcError.invalidParams("Missing params for tools/call"));
|
||||
}
|
||||
JsonNode nameNode = params.get("name");
|
||||
if (nameNode == null || !nameNode.isTextual()) {
|
||||
return JsonRpcResponse.failure(
|
||||
request.id(), JsonRpcError.invalidParams("Missing tool name"));
|
||||
}
|
||||
McpTool tool = toolsByName.get(nameNode.asText());
|
||||
if (tool == null) {
|
||||
return JsonRpcResponse.failure(
|
||||
request.id(), JsonRpcError.invalidParams("Unknown tool: " + nameNode.asText()));
|
||||
}
|
||||
JsonNode args = params.get("arguments");
|
||||
McpCallContext context = resolveContext();
|
||||
ObjectNode toolResult = tool.call(args == null ? mapper.createObjectNode() : args, context);
|
||||
return JsonRpcResponse.success(request.id(), toolResult);
|
||||
}
|
||||
|
||||
private McpCallContext resolveContext() {
|
||||
boolean scopesEnabled = applicationProperties.getMcp().isScopesEnabled();
|
||||
org.springframework.security.core.Authentication auth =
|
||||
org.springframework.security.core.context.SecurityContextHolder.getContext()
|
||||
.getAuthentication();
|
||||
// Fail closed: no/unauthenticated principal yields an empty context so scoped ops are
|
||||
// refused.
|
||||
if (auth == null || !auth.isAuthenticated() || auth.getName() == null) {
|
||||
return new McpCallContext(null, Set.of(), scopesEnabled);
|
||||
}
|
||||
java.util.Set<String> scopes = new java.util.HashSet<>();
|
||||
for (org.springframework.security.core.GrantedAuthority ga : auth.getAuthorities()) {
|
||||
String authority = ga.getAuthority();
|
||||
if (authority != null && authority.startsWith("SCOPE_")) {
|
||||
scopes.add(authority.substring("SCOPE_".length()));
|
||||
}
|
||||
}
|
||||
return new McpCallContext(auth.getName(), scopes, scopesEnabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package stirling.software.proprietary.mcp;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/** Contract every MCP tool registered with the server must satisfy. */
|
||||
public interface McpTool {
|
||||
|
||||
String name();
|
||||
|
||||
String description();
|
||||
|
||||
/** The tool's {@code inputSchema} (an object JSON Schema) published in {@code tools/list}. */
|
||||
ObjectNode inputSchema();
|
||||
|
||||
/** Execute the tool; the controller wraps any thrown exception as an MCP internal error. */
|
||||
ObjectNode call(JsonNode arguments, McpCallContext context);
|
||||
}
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
package stirling.software.proprietary.mcp.catalog;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.event.ContextRefreshedEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Discovers MCP-exposable operations and caches a per-op {@link OperationMeta}. Refreshed on {@link
|
||||
* ContextRefreshedEvent} and filtered on read by {@link
|
||||
* EndpointConfiguration#isEndpointEnabledForUri}. AI capabilities are fed in via {@link
|
||||
* #replaceAiCapabilities}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
|
||||
public class McpToolCatalog {
|
||||
|
||||
private static final String WRITE_SCOPE = "mcp.tools.write";
|
||||
|
||||
private final ApplicationContext applicationContext;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final SimpleSchemaGenerator schemaGenerator;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
// Concurrent: written on the boot thread, read on request threads, AI map replaced at runtime.
|
||||
private final Map<String, OperationMeta> pdfOps = new ConcurrentHashMap<>();
|
||||
|
||||
// Engine-driven AI capabilities. Replaced wholesale by the scheduled refresh task on a
|
||||
// background thread while request threads read via findByOperationId/enabledOps. The volatile
|
||||
// reference makes the swap publication-safe; readers either see the old or the new snapshot,
|
||||
// never a partially-merged one.
|
||||
private volatile Map<String, OperationMeta> aiOps = new ConcurrentHashMap<>();
|
||||
|
||||
public McpToolCatalog(
|
||||
ApplicationContext applicationContext,
|
||||
EndpointConfiguration endpointConfiguration,
|
||||
ApplicationProperties applicationProperties,
|
||||
ObjectMapper objectMapper) {
|
||||
this.applicationContext = applicationContext;
|
||||
this.endpointConfiguration = endpointConfiguration;
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.schemaGenerator = new SimpleSchemaGenerator(objectMapper);
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/** Admin tool filter: non-empty allow list is a whitelist; block list always removes. */
|
||||
private boolean isOperationAllowed(String id) {
|
||||
ApplicationProperties.Mcp mcp = applicationProperties.getMcp();
|
||||
List<String> allowed = mcp.getAllowedOperations();
|
||||
List<String> blocked = mcp.getBlockedOperations();
|
||||
if (blocked != null && blocked.contains(id)) {
|
||||
return false;
|
||||
}
|
||||
if (allowed != null && !allowed.isEmpty()) {
|
||||
return allowed.contains(id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@EventListener(ContextRefreshedEvent.class)
|
||||
public void discover() {
|
||||
pdfOps.clear();
|
||||
for (RequestMappingHandlerMapping mapping :
|
||||
applicationContext.getBeansOfType(RequestMappingHandlerMapping.class).values()) {
|
||||
for (Map.Entry<RequestMappingInfo, HandlerMethod> e :
|
||||
mapping.getHandlerMethods().entrySet()) {
|
||||
indexOne(e.getKey(), e.getValue());
|
||||
}
|
||||
}
|
||||
log.info("MCP tool catalog discovered {} PDF operation(s)", pdfOps.size());
|
||||
}
|
||||
|
||||
private void indexOne(RequestMappingInfo info, HandlerMethod handler) {
|
||||
Set<String> patterns = extractPatterns(info);
|
||||
if (patterns.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Set<RequestMethod> methods = info.getMethodsCondition().getMethods();
|
||||
if (!isInvocableMethod(methods)) {
|
||||
return;
|
||||
}
|
||||
for (String pattern : patterns) {
|
||||
OperationCategory category = OperationCategory.fromUrl(pattern);
|
||||
if (category == null) {
|
||||
continue;
|
||||
}
|
||||
String opId = extractOpId(pattern, category);
|
||||
if (opId == null) {
|
||||
continue;
|
||||
}
|
||||
OperationMeta meta = buildMeta(opId, category, pattern, handler);
|
||||
// First handler wins on duplicate URLs.
|
||||
pdfOps.putIfAbsent(opId, meta);
|
||||
}
|
||||
}
|
||||
|
||||
private OperationMeta buildMeta(
|
||||
String opId, OperationCategory category, String url, HandlerMethod handler) {
|
||||
Method method = handler.getMethod();
|
||||
Operation opAnno = method.getAnnotation(Operation.class);
|
||||
String summary =
|
||||
opAnno != null && !opAnno.summary().isBlank()
|
||||
? opAnno.summary()
|
||||
: prettifyOpId(opId);
|
||||
ObjectNode schema = paramSchemaFor(handler);
|
||||
// Every mutating endpoint requires the write scope.
|
||||
return new OperationMeta(
|
||||
opId,
|
||||
category,
|
||||
summary,
|
||||
schema,
|
||||
WRITE_SCOPE,
|
||||
OperationMeta.Target.JAVA_ENDPOINT,
|
||||
url,
|
||||
handler);
|
||||
}
|
||||
|
||||
private ObjectNode paramSchemaFor(HandlerMethod handler) {
|
||||
Optional<Class<?>> bodyType = firstComplexParamType(handler);
|
||||
return bodyType.map(schemaGenerator::toSchema).orElseGet(() -> emptyObjectSchema());
|
||||
}
|
||||
|
||||
private ObjectNode emptyObjectSchema() {
|
||||
ObjectNode out = objectMapper.createObjectNode();
|
||||
out.put("type", "object");
|
||||
out.put("additionalProperties", true);
|
||||
return out;
|
||||
}
|
||||
|
||||
private Optional<Class<?>> firstComplexParamType(HandlerMethod handler) {
|
||||
for (MethodParameter p : handler.getMethodParameters()) {
|
||||
Class<?> type = p.getParameterType();
|
||||
if (type.isPrimitive() || type == String.class || type.getName().startsWith("java.")) {
|
||||
continue;
|
||||
}
|
||||
// Skip Spring-managed parameter types (HttpServletRequest, Principal, etc.).
|
||||
String pkg = type.getPackageName();
|
||||
if (pkg.startsWith("jakarta.") || pkg.startsWith("org.springframework.")) {
|
||||
continue;
|
||||
}
|
||||
return Optional.of(type);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public List<OperationMeta> enabledOps(OperationCategory category) {
|
||||
if (category == OperationCategory.AI) {
|
||||
List<OperationMeta> ai = new ArrayList<>();
|
||||
for (OperationMeta m : aiOps.values()) {
|
||||
if (isOperationAllowed(m.id())) {
|
||||
ai.add(m);
|
||||
}
|
||||
}
|
||||
return ai;
|
||||
}
|
||||
List<OperationMeta> out = new ArrayList<>();
|
||||
for (OperationMeta m : pdfOps.values()) {
|
||||
if (m.category() == category
|
||||
&& isOperationAllowed(m.id())
|
||||
&& endpointConfiguration.isEndpointEnabledForUri(m.endpointPath())) {
|
||||
out.add(m);
|
||||
}
|
||||
}
|
||||
out.sort((a, b) -> a.id().compareTo(b.id()));
|
||||
return out;
|
||||
}
|
||||
|
||||
public Optional<OperationMeta> findByOperationId(String id) {
|
||||
if (!isOperationAllowed(id)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
// A disabled PDF op returns empty rather than falling through to a same-id AI capability.
|
||||
OperationMeta meta = pdfOps.get(id);
|
||||
if (meta != null) {
|
||||
boolean enabled =
|
||||
meta.target() != OperationMeta.Target.JAVA_ENDPOINT
|
||||
|| endpointConfiguration.isEndpointEnabledForUri(meta.endpointPath());
|
||||
return enabled ? Optional.of(meta) : Optional.empty();
|
||||
}
|
||||
return Optional.ofNullable(aiOps.get(id));
|
||||
}
|
||||
|
||||
/** Replace the AI capabilities snapshot. Called by the engine refresh task. */
|
||||
public void replaceAiCapabilities(Map<String, OperationMeta> updated) {
|
||||
// Build a fresh map then swap atomically via the volatile reference. The previous
|
||||
// implementation did putAll-then-retainAll on a shared ConcurrentHashMap, which left a
|
||||
// transient window where readers could observe stale entries that should have been
|
||||
// removed (race between the two structural updates).
|
||||
Map<String, OperationMeta> next = new ConcurrentHashMap<>(updated);
|
||||
this.aiOps = next;
|
||||
log.info("MCP tool catalog AI capabilities replaced: {} entries", next.size());
|
||||
}
|
||||
|
||||
/** Only POST/PUT endpoints are exposed as tools; DELETE and GET are excluded. */
|
||||
static boolean isInvocableMethod(Set<RequestMethod> methods) {
|
||||
return methods.contains(RequestMethod.POST) || methods.contains(RequestMethod.PUT);
|
||||
}
|
||||
|
||||
private static String extractOpId(String pattern, OperationCategory category) {
|
||||
if (category.urlPrefix() == null || !pattern.startsWith(category.urlPrefix())) {
|
||||
return null;
|
||||
}
|
||||
String tail = pattern.substring(category.urlPrefix().length());
|
||||
if (tail.isBlank() || tail.contains("/") || tail.contains("{")) {
|
||||
// Skip nested paths and path-variable templates.
|
||||
return null;
|
||||
}
|
||||
return tail;
|
||||
}
|
||||
|
||||
private static String prettifyOpId(String id) {
|
||||
return id.replace('-', ' ');
|
||||
}
|
||||
|
||||
private static Set<String> extractPatterns(RequestMappingInfo info) {
|
||||
try {
|
||||
Method getDirectPaths = info.getClass().getMethod("getDirectPaths");
|
||||
Object result = getDirectPaths.invoke(info);
|
||||
if (result instanceof Set<?> set) {
|
||||
Set<String> patterns = new TreeSet<>();
|
||||
for (Object v : set) {
|
||||
if (v instanceof String s) {
|
||||
patterns.add(s);
|
||||
}
|
||||
}
|
||||
return patterns;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.trace("getDirectPaths unavailable on RequestMappingInfo", e);
|
||||
}
|
||||
return Collections.emptySet();
|
||||
}
|
||||
|
||||
public Map<String, OperationMeta> snapshotPdfOps() {
|
||||
return new LinkedHashMap<>(pdfOps);
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package stirling.software.proprietary.mcp.catalog;
|
||||
|
||||
/** MCP tool categories; {@link #urlPrefix} maps a {@code /api/v1/} namespace to a category. */
|
||||
public enum OperationCategory {
|
||||
CONVERT("/api/v1/convert/", "stirling_convert"),
|
||||
PAGES("/api/v1/general/", "stirling_pages"),
|
||||
MISC("/api/v1/misc/", "stirling_misc"),
|
||||
SECURITY("/api/v1/security/", "stirling_security"),
|
||||
AI(null, "stirling_ai");
|
||||
|
||||
private final String urlPrefix;
|
||||
private final String toolName;
|
||||
|
||||
OperationCategory(String urlPrefix, String toolName) {
|
||||
this.urlPrefix = urlPrefix;
|
||||
this.toolName = toolName;
|
||||
}
|
||||
|
||||
public String urlPrefix() {
|
||||
return urlPrefix;
|
||||
}
|
||||
|
||||
public String toolName() {
|
||||
return toolName;
|
||||
}
|
||||
|
||||
public static OperationCategory fromUrl(String url) {
|
||||
if (url == null) {
|
||||
return null;
|
||||
}
|
||||
for (OperationCategory c : values()) {
|
||||
if (c.urlPrefix != null && url.startsWith(c.urlPrefix)) {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package stirling.software.proprietary.mcp.catalog;
|
||||
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/** Metadata for one MCP-exposed operation (PDF endpoint or AI capability). */
|
||||
public record OperationMeta(
|
||||
String id,
|
||||
OperationCategory category,
|
||||
String summary,
|
||||
ObjectNode paramSchema,
|
||||
String requiredScope,
|
||||
Target target,
|
||||
String endpointPath,
|
||||
HandlerMethod handlerMethod) {
|
||||
|
||||
public enum Target {
|
||||
JAVA_ENDPOINT,
|
||||
ENGINE_CAPABILITY
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
package stirling.software.proprietary.mcp.catalog;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.ParameterizedType;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ArrayNode;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Reflection-based JSON Schema generator for controller request-body classes. {@link MultipartFile}
|
||||
* fields are emitted as {@code "type":"string"} with a {@code "format":"file-id"} hint.
|
||||
*/
|
||||
public final class SimpleSchemaGenerator {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
public SimpleSchemaGenerator(ObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public ObjectNode toSchema(Class<?> type) {
|
||||
return toSchema(type, new HashSet<>());
|
||||
}
|
||||
|
||||
private ObjectNode toSchema(Class<?> type, Set<Class<?>> visited) {
|
||||
ObjectNode schema = mapper.createObjectNode();
|
||||
schema.put("type", "object");
|
||||
schema.put("additionalProperties", false);
|
||||
ObjectNode properties = schema.putObject("properties");
|
||||
ArrayNode required = mapper.createArrayNode();
|
||||
if (!visited.add(type)) {
|
||||
// Cycle: emit a loose object and bail.
|
||||
schema.put("additionalProperties", true);
|
||||
return schema;
|
||||
}
|
||||
|
||||
Set<String> seen = new HashSet<>();
|
||||
for (Field field : collectFields(type)) {
|
||||
if (java.lang.reflect.Modifier.isStatic(field.getModifiers())
|
||||
|| java.lang.reflect.Modifier.isTransient(field.getModifiers())) {
|
||||
continue;
|
||||
}
|
||||
// Skip fields Jackson won't (de)serialize.
|
||||
if (field.isAnnotationPresent(JsonIgnore.class)) {
|
||||
continue;
|
||||
}
|
||||
String name = jsonPropertyName(field);
|
||||
if (!seen.add(name)) {
|
||||
continue;
|
||||
}
|
||||
properties.set(name, typeSchema(field.getGenericType(), visited));
|
||||
if (isRequired(field)) {
|
||||
required.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (!required.isEmpty()) {
|
||||
schema.set("required", required);
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
private List<Field> collectFields(Class<?> type) {
|
||||
List<Field> all = new ArrayList<>();
|
||||
for (Class<?> c = type; c != null && c != Object.class; c = c.getSuperclass()) {
|
||||
for (Field f : c.getDeclaredFields()) {
|
||||
all.add(f);
|
||||
}
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
private static String jsonPropertyName(Field field) {
|
||||
JsonProperty ann = field.getAnnotation(JsonProperty.class);
|
||||
if (ann != null && !ann.value().isEmpty()) {
|
||||
return ann.value();
|
||||
}
|
||||
return field.getName();
|
||||
}
|
||||
|
||||
private boolean isRequired(Field field) {
|
||||
JsonProperty json = field.getAnnotation(JsonProperty.class);
|
||||
if (json != null && json.required()) {
|
||||
return true;
|
||||
}
|
||||
return field.isAnnotationPresent(jakarta.validation.constraints.NotNull.class)
|
||||
|| field.isAnnotationPresent(jakarta.validation.constraints.NotBlank.class)
|
||||
|| field.isAnnotationPresent(jakarta.validation.constraints.NotEmpty.class);
|
||||
}
|
||||
|
||||
private ObjectNode typeSchema(Type t, Set<Class<?>> visited) {
|
||||
ObjectNode out = mapper.createObjectNode();
|
||||
if (t instanceof Class<?> c) {
|
||||
populatePrimitive(out, c, visited);
|
||||
} else if (t instanceof ParameterizedType pt) {
|
||||
Type raw = pt.getRawType();
|
||||
if (raw instanceof Class<?> rawClass) {
|
||||
if (java.util.Collection.class.isAssignableFrom(rawClass)) {
|
||||
out.put("type", "array");
|
||||
Type[] args = pt.getActualTypeArguments();
|
||||
if (args.length == 1) {
|
||||
out.set("items", typeSchema(args[0], visited));
|
||||
}
|
||||
} else if (java.util.Map.class.isAssignableFrom(rawClass)) {
|
||||
out.put("type", "object");
|
||||
out.put("additionalProperties", true);
|
||||
} else {
|
||||
populatePrimitive(out, rawClass, visited);
|
||||
}
|
||||
} else {
|
||||
out.put("type", "object");
|
||||
}
|
||||
} else {
|
||||
out.put("type", "object");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private void populatePrimitive(ObjectNode out, Class<?> c, Set<Class<?>> visited) {
|
||||
if (MultipartFile.class.isAssignableFrom(c)) {
|
||||
out.put("type", "string");
|
||||
out.put("format", "file-id");
|
||||
out.put(
|
||||
"description",
|
||||
"Reference to a previously-uploaded file in Stirling's job store.");
|
||||
return;
|
||||
}
|
||||
if (c.isArray()) {
|
||||
out.put("type", "array");
|
||||
out.set("items", typeSchema(c.getComponentType(), visited));
|
||||
return;
|
||||
}
|
||||
if (c == String.class) {
|
||||
out.put("type", "string");
|
||||
} else if (c == boolean.class || c == Boolean.class) {
|
||||
out.put("type", "boolean");
|
||||
} else if (c == int.class
|
||||
|| c == Integer.class
|
||||
|| c == long.class
|
||||
|| c == Long.class
|
||||
|| c == short.class
|
||||
|| c == Short.class
|
||||
|| c == byte.class
|
||||
|| c == Byte.class) {
|
||||
out.put("type", "integer");
|
||||
} else if (c == float.class || c == Float.class || c == double.class || c == Double.class) {
|
||||
out.put("type", "number");
|
||||
} else if (c.isEnum()) {
|
||||
out.put("type", "string");
|
||||
ArrayNode values = out.putArray("enum");
|
||||
for (Object constant : c.getEnumConstants()) {
|
||||
values.add(constant.toString());
|
||||
}
|
||||
} else if (c == java.util.UUID.class) {
|
||||
out.put("type", "string");
|
||||
out.put("format", "uuid");
|
||||
} else if (java.time.temporal.Temporal.class.isAssignableFrom(c)
|
||||
|| c == java.util.Date.class) {
|
||||
out.put("type", "string");
|
||||
out.put("format", "date-time");
|
||||
} else {
|
||||
// Complex bean: recurse with the shared visited set.
|
||||
ObjectNode nested = toSchema(c, visited);
|
||||
out.setAll(nested);
|
||||
}
|
||||
}
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
package stirling.software.proprietary.mcp.engine;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationCategory;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationMeta;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Pulls the engine's capabilities manifest at boot and on a schedule, feeding it into the shared
|
||||
* {@link McpToolCatalog}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
|
||||
public class EngineCapabilityClient {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final McpToolCatalog catalog;
|
||||
private final ObjectMapper mapper;
|
||||
private final HttpClient httpClient;
|
||||
private final String sharedSecret;
|
||||
|
||||
private ScheduledExecutorService scheduler;
|
||||
|
||||
public EngineCapabilityClient(
|
||||
ApplicationProperties applicationProperties,
|
||||
McpToolCatalog catalog,
|
||||
ObjectMapper mapper) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.catalog = catalog;
|
||||
this.mapper = mapper;
|
||||
this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
|
||||
this.sharedSecret = System.getenv("STIRLING_ENGINE_SHARED_SECRET");
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void start() {
|
||||
scheduler =
|
||||
Executors.newSingleThreadScheduledExecutor(
|
||||
r -> {
|
||||
Thread t = new Thread(r, "mcp-engine-capability-refresh");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void onReady() {
|
||||
long minutes =
|
||||
Math.max(1, applicationProperties.getMcp().getEngineCapabilityRefreshMinutes());
|
||||
// First refresh immediately, then on the configured cadence.
|
||||
scheduler.schedule(this::refreshSafely, 0, TimeUnit.SECONDS);
|
||||
scheduler.scheduleAtFixedRate(this::refreshSafely, minutes, minutes, TimeUnit.MINUTES);
|
||||
log.info("MCP engine capability refresh scheduled every {} minute(s)", minutes);
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
void stop() {
|
||||
if (scheduler != null) {
|
||||
scheduler.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshSafely() {
|
||||
try {
|
||||
refresh();
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"MCP engine capability refresh failed ({}). AI tool enum stays at the last"
|
||||
+ " known state until the next successful pull.",
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Visible for testing. */
|
||||
public void refresh() throws IOException, InterruptedException {
|
||||
if (!applicationProperties.getAiEngine().isEnabled()) {
|
||||
log.debug("AI engine disabled; skipping MCP capability refresh");
|
||||
catalog.replaceAiCapabilities(Map.of());
|
||||
return;
|
||||
}
|
||||
// Trim whitespace and any trailing slash to avoid a malformed URI.
|
||||
String base = applicationProperties.getAiEngine().getUrl().strip().replaceAll("/+$", "");
|
||||
URI uri = URI.create(base + "/api/v1/agents/capabilities");
|
||||
HttpRequest.Builder reqBuilder =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(uri)
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.header("Accept", "application/json")
|
||||
.GET();
|
||||
if (sharedSecret != null && !sharedSecret.isBlank()) {
|
||||
reqBuilder.header("X-Engine-Auth", sharedSecret);
|
||||
}
|
||||
HttpResponse<String> response =
|
||||
httpClient.send(reqBuilder.build(), HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException(
|
||||
"Engine capabilities endpoint returned HTTP " + response.statusCode());
|
||||
}
|
||||
Map<String, OperationMeta> parsed = parseManifest(response.body());
|
||||
catalog.replaceAiCapabilities(parsed);
|
||||
}
|
||||
|
||||
private Map<String, OperationMeta> parseManifest(String body) throws IOException {
|
||||
JsonNode root = mapper.readTree(body);
|
||||
JsonNode capabilities = root.get("capabilities");
|
||||
if (capabilities == null || !capabilities.isArray()) {
|
||||
throw new IOException("Manifest missing 'capabilities' array");
|
||||
}
|
||||
Map<String, OperationMeta> out = new LinkedHashMap<>();
|
||||
for (JsonNode entry : capabilities) {
|
||||
JsonNode id = entry.get("id");
|
||||
JsonNode desc = entry.get("description");
|
||||
JsonNode schema = entry.get("input_schema");
|
||||
JsonNode scope = entry.get("required_scope");
|
||||
JsonNode route = entry.get("route");
|
||||
if (id == null || !id.isTextual() || schema == null || !schema.isObject()) {
|
||||
log.warn("Skipping malformed capability entry: {}", entry);
|
||||
continue;
|
||||
}
|
||||
String routeValue = route == null || !route.isTextual() ? null : route.asText();
|
||||
if (routeValue != null && !isSafeRelativeRoute(routeValue)) {
|
||||
// Defence in depth: a tampered manifest must not steer Java at an arbitrary
|
||||
// host/path.
|
||||
log.warn(
|
||||
"Skipping capability '{}' with unsafe route '{}' (must be a server-relative"
|
||||
+ " /api path with no scheme, authority, or '..')",
|
||||
id.asText(),
|
||||
routeValue);
|
||||
continue;
|
||||
}
|
||||
// Fail safe: default to the stricter write scope when the manifest omits one.
|
||||
String requiredScope =
|
||||
scope != null && scope.isTextual() && !scope.asText().isBlank()
|
||||
? scope.asText()
|
||||
: WRITE_SCOPE;
|
||||
ObjectNode schemaCopy = (ObjectNode) schema.deepCopy();
|
||||
out.put(
|
||||
id.asText(),
|
||||
new OperationMeta(
|
||||
id.asText(),
|
||||
OperationCategory.AI,
|
||||
desc == null ? id.asText() : desc.asText(),
|
||||
schemaCopy,
|
||||
requiredScope,
|
||||
OperationMeta.Target.ENGINE_CAPABILITY,
|
||||
routeValue,
|
||||
null));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static final String WRITE_SCOPE = "mcp.tools.write";
|
||||
|
||||
/**
|
||||
* True only for a server-relative {@code /api/} path with no scheme, authority, {@code ..}, or
|
||||
* control chars (blocks SSRF / path escape).
|
||||
*/
|
||||
static boolean isSafeRelativeRoute(String route) {
|
||||
if (route == null || route.isBlank() || !route.startsWith("/api/")) {
|
||||
return false;
|
||||
}
|
||||
if (route.startsWith("//")
|
||||
|| route.contains("..")
|
||||
|| route.contains("@")
|
||||
|| route.contains("\\")
|
||||
|| route.contains(":")) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < route.length(); i++) {
|
||||
char c = route.charAt(i);
|
||||
if (c <= ' ') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package stirling.software.proprietary.mcp.jsonrpc;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
|
||||
/** JSON-RPC 2.0 error object. */
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record JsonRpcError(int code, String message, JsonNode data) {
|
||||
|
||||
public static final int PARSE_ERROR = -32700;
|
||||
public static final int INVALID_REQUEST = -32600;
|
||||
public static final int METHOD_NOT_FOUND = -32601;
|
||||
public static final int INVALID_PARAMS = -32602;
|
||||
public static final int INTERNAL_ERROR = -32603;
|
||||
|
||||
public static JsonRpcError parseError(String message) {
|
||||
return new JsonRpcError(PARSE_ERROR, message, null);
|
||||
}
|
||||
|
||||
public static JsonRpcError invalidRequest(String message) {
|
||||
return new JsonRpcError(INVALID_REQUEST, message, null);
|
||||
}
|
||||
|
||||
public static JsonRpcError methodNotFound(String method) {
|
||||
return new JsonRpcError(METHOD_NOT_FOUND, "Method not found: " + method, null);
|
||||
}
|
||||
|
||||
public static JsonRpcError invalidParams(String message) {
|
||||
return new JsonRpcError(INVALID_PARAMS, message, null);
|
||||
}
|
||||
|
||||
public static JsonRpcError internalError(String message) {
|
||||
return new JsonRpcError(INTERNAL_ERROR, message, null);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package stirling.software.proprietary.mcp.jsonrpc;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
|
||||
/** JSON-RPC 2.0 request frame; a null {@code id} marks a notification. */
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record JsonRpcRequest(String jsonrpc, JsonNode id, String method, JsonNode params) {
|
||||
|
||||
public boolean isNotification() {
|
||||
return id == null || id.isNull();
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package stirling.software.proprietary.mcp.jsonrpc;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
|
||||
/** JSON-RPC 2.0 response; exactly one of {@code result} or {@code error} is non-null. */
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record JsonRpcResponse(String jsonrpc, JsonNode id, Object result, JsonRpcError error) {
|
||||
|
||||
public static JsonRpcResponse success(JsonNode id, Object result) {
|
||||
return new JsonRpcResponse("2.0", id, result, null);
|
||||
}
|
||||
|
||||
public static JsonRpcResponse failure(JsonNode id, JsonRpcError error) {
|
||||
return new JsonRpcResponse("2.0", id, null, error);
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package stirling.software.proprietary.mcp.security;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/**
|
||||
* API-key auth for the MCP endpoint: validates a Stirling per-user API key and binds the request to
|
||||
* that user with the MCP scopes.
|
||||
*/
|
||||
@Slf4j
|
||||
public class McpApiKeyAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final List<GrantedAuthority> MCP_SCOPES =
|
||||
List.of(
|
||||
new SimpleGrantedAuthority("SCOPE_mcp.tools.read"),
|
||||
new SimpleGrantedAuthority("SCOPE_mcp.tools.write"));
|
||||
|
||||
private final UserService userService;
|
||||
|
||||
public McpApiKeyAuthFilter(UserService userService) {
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
Authentication existing = SecurityContextHolder.getContext().getAuthentication();
|
||||
// Treat an anonymous token as not authenticated so the key is still processed.
|
||||
boolean unauthenticated =
|
||||
existing == null
|
||||
|| existing instanceof AnonymousAuthenticationToken
|
||||
|| !existing.isAuthenticated();
|
||||
if (unauthenticated) {
|
||||
String apiKey = extractKey(request);
|
||||
if (apiKey != null && !apiKey.isBlank()) {
|
||||
Optional<User> user = userService.getUserByApiKey(apiKey);
|
||||
if (user.isPresent() && user.get().isEnabled()) {
|
||||
UsernamePasswordAuthenticationToken auth =
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
user.get().getUsername(), null, MCP_SCOPES);
|
||||
SecurityContext context = SecurityContextHolder.createEmptyContext();
|
||||
context.setAuthentication(auth);
|
||||
SecurityContextHolder.setContext(context);
|
||||
} else {
|
||||
log.warn(
|
||||
"MCP access denied: presented API key did not match an active account");
|
||||
}
|
||||
}
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private String extractKey(HttpServletRequest request) {
|
||||
String headerKey = request.getHeader("X-API-KEY");
|
||||
if (headerKey != null && !headerKey.isBlank()) {
|
||||
return headerKey.trim();
|
||||
}
|
||||
String authz = request.getHeader("Authorization");
|
||||
if (authz != null && authz.regionMatches(true, 0, "Bearer ", 0, 7)) {
|
||||
return authz.substring(7).trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package stirling.software.proprietary.mcp.security;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
/**
|
||||
* RFC 8707 audience binding: a JWT at the MCP endpoint must list this server's resource id in its
|
||||
* {@code aud} claim. Fails closed when the resource id is unset.
|
||||
*/
|
||||
public class McpAudienceValidator implements OAuth2TokenValidator<Jwt> {
|
||||
|
||||
private final String expectedResourceId;
|
||||
|
||||
public McpAudienceValidator(String expectedResourceId) {
|
||||
this.expectedResourceId = expectedResourceId == null ? "" : expectedResourceId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidatorResult validate(Jwt token) {
|
||||
if (expectedResourceId.isBlank()) {
|
||||
return OAuth2TokenValidatorResult.failure(
|
||||
new OAuth2Error(
|
||||
"invalid_token",
|
||||
"MCP server has no resource id configured; rejecting all tokens"
|
||||
+ " until mcp.auth.resource-id is set.",
|
||||
null));
|
||||
}
|
||||
List<String> aud = token.getAudience();
|
||||
if (aud == null || !aud.contains(expectedResourceId)) {
|
||||
return OAuth2TokenValidatorResult.failure(
|
||||
new OAuth2Error(
|
||||
"invalid_token",
|
||||
"Token audience does not include this server's resource id ("
|
||||
+ expectedResourceId
|
||||
+ ").",
|
||||
null));
|
||||
}
|
||||
return OAuth2TokenValidatorResult.success();
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package stirling.software.proprietary.mcp.security;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
/**
|
||||
* Emits 401 + {@code WWW-Authenticate: Bearer resource_metadata="..."} (RFC 9728), preferring
|
||||
* X-Forwarded-* headers to build the public-facing metadata URL.
|
||||
*/
|
||||
public class McpAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
||||
|
||||
private final String metadataPath;
|
||||
|
||||
public McpAuthenticationEntryPoint(String metadataPath) {
|
||||
this.metadataPath =
|
||||
metadataPath == null ? "/.well-known/oauth-protected-resource" : metadataPath;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void commence(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
AuthenticationException authException)
|
||||
throws IOException {
|
||||
String scheme = firstForwarded(request, "X-Forwarded-Proto", request.getScheme());
|
||||
String authority = forwardedHost(request, scheme);
|
||||
String metadataUrl = scheme + "://" + authority + metadataPath;
|
||||
response.setHeader(
|
||||
"WWW-Authenticate",
|
||||
"Bearer error=\"invalid_token\", resource_metadata=\"" + metadataUrl + "\"");
|
||||
response.sendError(HttpStatus.UNAUTHORIZED.value(), "Unauthorized");
|
||||
}
|
||||
|
||||
/** host[:port] from forwarded headers when present, else the servlet host/port. */
|
||||
private static String forwardedHost(HttpServletRequest request, String scheme) {
|
||||
String host = firstForwarded(request, "X-Forwarded-Host", null);
|
||||
if (host != null && !host.isBlank()) {
|
||||
// X-Forwarded-Host may already carry a port.
|
||||
if (host.contains(":")) {
|
||||
return host;
|
||||
}
|
||||
String fwdPort = firstForwarded(request, "X-Forwarded-Port", null);
|
||||
if (fwdPort != null && !isDefaultPort(scheme, fwdPort)) {
|
||||
return host + ":" + fwdPort;
|
||||
}
|
||||
return host;
|
||||
}
|
||||
String authority = request.getServerName();
|
||||
int port = request.getServerPort();
|
||||
if (port > 0 && !isDefaultPort(scheme, Integer.toString(port))) {
|
||||
authority = authority + ":" + port;
|
||||
}
|
||||
return authority;
|
||||
}
|
||||
|
||||
/** First (client-most) value of a possibly comma-listed forwarded header, trimmed. */
|
||||
private static String firstForwarded(HttpServletRequest request, String name, String fallback) {
|
||||
String value = request.getHeader(name);
|
||||
if (value == null || value.isBlank()) {
|
||||
return fallback;
|
||||
}
|
||||
int comma = value.indexOf(',');
|
||||
return (comma >= 0 ? value.substring(0, comma) : value).trim();
|
||||
}
|
||||
|
||||
private static boolean isDefaultPort(String scheme, String port) {
|
||||
return ("http".equals(scheme) && "80".equals(port))
|
||||
|| ("https".equals(scheme) && "443".equals(port));
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package stirling.software.proprietary.mcp.security;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ReadListener;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.ServletInputStream;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletRequestWrapper;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
/**
|
||||
* Caps MCP request body size (via Content-Length and by buffering up to the cap) and rejects
|
||||
* oversized bodies with a clean 413 before JSON parsing.
|
||||
*/
|
||||
public class McpRequestSizeFilter extends OncePerRequestFilter {
|
||||
|
||||
private final long maxBodyBytes;
|
||||
|
||||
public McpRequestSizeFilter(long maxBodyBytes) {
|
||||
this.maxBodyBytes = maxBodyBytes > 0 ? maxBodyBytes : 256L * 1024L;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
long declared = request.getContentLengthLong();
|
||||
if (declared > maxBodyBytes) {
|
||||
tooLarge(response);
|
||||
return;
|
||||
}
|
||||
byte[] body;
|
||||
try {
|
||||
body = readUpTo(request.getInputStream(), maxBodyBytes);
|
||||
} catch (BodyTooLargeException e) {
|
||||
tooLarge(response);
|
||||
return;
|
||||
}
|
||||
filterChain.doFilter(new CachedBodyRequest(request, body), response);
|
||||
}
|
||||
|
||||
private static byte[] readUpTo(InputStream in, long max) throws IOException {
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
byte[] chunk = new byte[8192];
|
||||
long total = 0;
|
||||
int n;
|
||||
while ((n = in.read(chunk)) != -1) {
|
||||
total += n;
|
||||
if (total > max) {
|
||||
throw new BodyTooLargeException();
|
||||
}
|
||||
buffer.write(chunk, 0, n);
|
||||
}
|
||||
return buffer.toByteArray();
|
||||
}
|
||||
|
||||
private void tooLarge(HttpServletResponse response) throws IOException {
|
||||
response.setStatus(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter()
|
||||
.write(
|
||||
"{\"error\":\"payload_too_large\",\"message\":\"MCP request body exceeds the"
|
||||
+ " configured limit of "
|
||||
+ maxBodyBytes
|
||||
+ " bytes.\"}");
|
||||
}
|
||||
|
||||
private static final class BodyTooLargeException extends IOException {}
|
||||
|
||||
/** Re-serves the buffered body to the controller. */
|
||||
private static final class CachedBodyRequest extends HttpServletRequestWrapper {
|
||||
private final byte[] body;
|
||||
|
||||
CachedBodyRequest(HttpServletRequest request, byte[] body) {
|
||||
super(request);
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServletInputStream getInputStream() {
|
||||
ByteArrayInputStream source = new ByteArrayInputStream(body);
|
||||
return new ServletInputStream() {
|
||||
@Override
|
||||
public int read() {
|
||||
return source.read();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] b, int off, int len) {
|
||||
return source.read(b, off, len);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isFinished() {
|
||||
return source.available() == 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setReadListener(ReadListener readListener) {
|
||||
// Synchronous buffered body; no async reads.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedReader getReader() {
|
||||
String enc = getCharacterEncoding();
|
||||
Charset cs = enc == null ? StandardCharsets.UTF_8 : Charset.forName(enc);
|
||||
return new BufferedReader(new InputStreamReader(new ByteArrayInputStream(body), cs));
|
||||
}
|
||||
}
|
||||
}
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
package stirling.software.proprietary.mcp.security;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.core.convert.converter.Converter;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtValidators;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
|
||||
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.access.intercept.AuthorizationFilter;
|
||||
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/**
|
||||
* MCP security chain: validates JWTs (JWKS + RFC 8707 audience), maps scope claims to authorities,
|
||||
* and fails closed when the issuer is unset.
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
|
||||
public class McpSecurityConfig {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final UserService userService;
|
||||
|
||||
// Reuse the app's CORS config; ObjectProvider so the chain still wires when no CORS bean
|
||||
// exists.
|
||||
private final ObjectProvider<CorsConfigurationSource> corsConfigurationSource;
|
||||
|
||||
private static final String BASE_PATH = "/mcp";
|
||||
|
||||
public McpSecurityConfig(
|
||||
ApplicationProperties applicationProperties,
|
||||
@Lazy UserService userService,
|
||||
ObjectProvider<CorsConfigurationSource> corsConfigurationSource) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.userService = userService;
|
||||
this.corsConfigurationSource = corsConfigurationSource;
|
||||
}
|
||||
|
||||
/** Enable CORS on the MCP chain using the app-wide source when available. */
|
||||
private void applyCors(HttpSecurity http) throws Exception {
|
||||
CorsConfigurationSource source = corsConfigurationSource.getIfAvailable();
|
||||
if (source != null) {
|
||||
http.cors(cors -> cors.configurationSource(source));
|
||||
}
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void warnIfMisconfigured() {
|
||||
ApplicationProperties.Mcp mcp = applicationProperties.getMcp();
|
||||
if (isApiKeyMode()) {
|
||||
log.info(
|
||||
"MCP auth mode = apikey: clients authenticate with a Stirling per-user API key"
|
||||
+ " (X-API-KEY header). No OAuth issuer required.");
|
||||
} else {
|
||||
if (mcp.getAuth().getIssuerUri().isBlank()) {
|
||||
log.warn(
|
||||
"MCP enabled but mcp.auth.issuer-uri is blank - JWT decoder will reject"
|
||||
+ " every token (fail-closed). Set mcp.auth.issuer-uri and"
|
||||
+ " mcp.auth.resource-id before exposing /mcp to clients.");
|
||||
}
|
||||
if (mcp.getAuth().getResourceId().isBlank()) {
|
||||
log.warn(
|
||||
"MCP enabled but mcp.auth.resource-id is blank - audience validator will"
|
||||
+ " reject every token. Set this to the public URL of the MCP"
|
||||
+ " endpoint (RFC 8707).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(0)
|
||||
SecurityFilterChain mcpSecurityFilterChain(HttpSecurity http, JwtDecoder mcpJwtDecoder)
|
||||
throws Exception {
|
||||
ApplicationProperties.Mcp.Auth auth = applicationProperties.getMcp().getAuth();
|
||||
if (isApiKeyMode()) {
|
||||
return apiKeyFilterChain(http);
|
||||
}
|
||||
return oauthFilterChain(http, mcpJwtDecoder, auth);
|
||||
}
|
||||
|
||||
private boolean isApiKeyMode() {
|
||||
return "apikey".equalsIgnoreCase(applicationProperties.getMcp().getAuth().getMode());
|
||||
}
|
||||
|
||||
/**
|
||||
* API-key chain: a Stirling per-user API key is validated by {@link McpApiKeyAuthFilter};
|
||||
* otherwise 401.
|
||||
*/
|
||||
private SecurityFilterChain apiKeyFilterChain(HttpSecurity http) throws Exception {
|
||||
applyCors(http);
|
||||
http.securityMatcher(BASE_PATH, BASE_PATH + "/**")
|
||||
// CSRF intentionally disabled: /mcp is a stateless JSON-RPC API authenticated by an
|
||||
// out-of-band X-API-KEY header (or Authorization: Bearer <key>). No cookies, no
|
||||
// session, no form submissions; a browser cannot trick a victim into sending the
|
||||
// header cross-origin, so the CSRF attack model does not apply. CodeQL flags this
|
||||
// generically; the SessionCreationPolicy.STATELESS below is the relevant guarantee.
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(a -> a.anyRequest().authenticated())
|
||||
.exceptionHandling(
|
||||
e ->
|
||||
e.authenticationEntryPoint(
|
||||
(request, response, ex) -> {
|
||||
response.setStatus(401);
|
||||
response.setHeader(
|
||||
"WWW-Authenticate",
|
||||
"Bearer realm=\"Stirling MCP (API key)\"");
|
||||
response.setContentType("application/json");
|
||||
response.getWriter()
|
||||
.write(
|
||||
"{\"error\":\"unauthorized\",\"message\":\"Provide a valid Stirling API key via the X-API-KEY header (or Authorization: Bearer <key>).\"}");
|
||||
}))
|
||||
.addFilterBefore(
|
||||
new McpRequestSizeFilter(
|
||||
applicationProperties.getMcp().getMaxRequestBytes()),
|
||||
AuthorizationFilter.class)
|
||||
// Authenticate before the anonymous filter sets an anonymous token.
|
||||
.addFilterBefore(
|
||||
new McpApiKeyAuthFilter(userService), AnonymousAuthenticationFilter.class);
|
||||
return http.build();
|
||||
}
|
||||
|
||||
/** OAuth2 resource-server chain (JWT, RFC 8707 audience, RFC 9728 metadata). */
|
||||
private SecurityFilterChain oauthFilterChain(
|
||||
HttpSecurity http, JwtDecoder mcpJwtDecoder, ApplicationProperties.Mcp.Auth auth)
|
||||
throws Exception {
|
||||
String metadataPath = "/.well-known/oauth-protected-resource";
|
||||
applyCors(http);
|
||||
http.securityMatcher(BASE_PATH, BASE_PATH + "/**", metadataPath)
|
||||
// CSRF intentionally disabled: /mcp is a stateless JSON-RPC resource server
|
||||
// authenticated by OAuth2 Bearer JWTs (Authorization header). No cookies, no
|
||||
// session, no form submissions; CSRF requires browser-attached ambient credentials
|
||||
// and the bearer token is supplied per-request by the MCP client. CodeQL flags
|
||||
// this generically; the SessionCreationPolicy.STATELESS below is the actual
|
||||
// guarantee, and the .well-known metadata endpoint only serves GET.
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(
|
||||
a ->
|
||||
a.requestMatchers(HttpMethod.GET, metadataPath)
|
||||
.permitAll()
|
||||
.anyRequest()
|
||||
.authenticated())
|
||||
// Cap body size pre-auth, then bind the validated token to a Stirling user after
|
||||
// the bearer filter.
|
||||
.addFilterBefore(
|
||||
new McpRequestSizeFilter(
|
||||
applicationProperties.getMcp().getMaxRequestBytes()),
|
||||
BearerTokenAuthenticationFilter.class)
|
||||
.addFilterAfter(
|
||||
new McpUserBindingFilter(
|
||||
userService,
|
||||
auth.getUsernameClaim(),
|
||||
auth.isRequireExistingAccount()),
|
||||
BearerTokenAuthenticationFilter.class)
|
||||
.oauth2ResourceServer(
|
||||
oauth2 ->
|
||||
oauth2.authenticationEntryPoint(
|
||||
new McpAuthenticationEntryPoint(metadataPath))
|
||||
// RFC 9728 protected-resource metadata for OAuth discovery.
|
||||
.protectedResourceMetadata(
|
||||
prm ->
|
||||
prm.protectedResourceMetadataCustomizer(
|
||||
builder -> {
|
||||
if (!auth.getResourceId()
|
||||
.isBlank()) {
|
||||
builder.resource(
|
||||
auth
|
||||
.getResourceId());
|
||||
}
|
||||
if (!auth.getIssuerUri()
|
||||
.isBlank()) {
|
||||
builder.authorizationServer(
|
||||
auth
|
||||
.getIssuerUri());
|
||||
}
|
||||
builder.scope("mcp.tools.read");
|
||||
builder.scope(
|
||||
"mcp.tools.write");
|
||||
}))
|
||||
.jwt(
|
||||
jwt ->
|
||||
jwt.decoder(mcpJwtDecoder)
|
||||
.jwtAuthenticationConverter(
|
||||
mcpJwtAuthenticationConverter())));
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
JwtDecoder mcpJwtDecoder() {
|
||||
ApplicationProperties.Mcp.Auth auth = applicationProperties.getMcp().getAuth();
|
||||
if (auth.getIssuerUri().isBlank()) {
|
||||
// Fail-closed decoder: rejects every token until the issuer is set.
|
||||
return token -> {
|
||||
throw new org.springframework.security.oauth2.jwt.BadJwtException(
|
||||
"mcp.auth.issuer-uri is not configured");
|
||||
};
|
||||
}
|
||||
String jwksUri = auth.getJwksUri();
|
||||
NimbusJwtDecoder decoder =
|
||||
jwksUri.isBlank()
|
||||
? NimbusJwtDecoder.withIssuerLocation(auth.getIssuerUri()).build()
|
||||
: NimbusJwtDecoder.withJwkSetUri(jwksUri).build();
|
||||
OAuth2TokenValidator<Jwt> defaultValidators =
|
||||
JwtValidators.createDefaultWithIssuer(auth.getIssuerUri());
|
||||
OAuth2TokenValidator<Jwt> combined =
|
||||
new DelegatingOAuth2TokenValidator<>(
|
||||
defaultValidators, new McpAudienceValidator(auth.getResourceId()));
|
||||
decoder.setJwtValidator(combined);
|
||||
return decoder;
|
||||
}
|
||||
|
||||
private Converter<Jwt, AbstractAuthenticationToken> mcpJwtAuthenticationConverter() {
|
||||
JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();
|
||||
scopes.setAuthorityPrefix("SCOPE_");
|
||||
scopes.setAuthoritiesClaimName("scope");
|
||||
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
|
||||
converter.setJwtGrantedAuthoritiesConverter(
|
||||
jwt -> {
|
||||
Collection<GrantedAuthority> out = new ArrayList<>(scopes.convert(jwt));
|
||||
List<String> aud = jwt.getAudience();
|
||||
if (aud != null) {
|
||||
for (String a : aud) {
|
||||
out.add(new SimpleGrantedAuthority("AUDIENCE_" + a));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
return converter;
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package stirling.software.proprietary.mcp.security;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Binds an MCP-validated JWT to a provisioned Stirling user: optionally rejects subjects with no
|
||||
* enabled account, then rebinds the principal to the canonical Stirling username (scope authorities
|
||||
* only) so audit/metering attribute correctly.
|
||||
*/
|
||||
@Slf4j
|
||||
public class McpUserBindingFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private final UserService userService;
|
||||
private final String usernameClaim;
|
||||
private final boolean requireExistingAccount;
|
||||
|
||||
public McpUserBindingFilter(
|
||||
UserService userService, String usernameClaim, boolean requireExistingAccount) {
|
||||
this.userService = userService;
|
||||
this.usernameClaim =
|
||||
(usernameClaim == null || usernameClaim.isBlank()) ? "sub" : usernameClaim;
|
||||
this.requireExistingAccount = requireExistingAccount;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
Authentication current = SecurityContextHolder.getContext().getAuthentication();
|
||||
|
||||
// Only act on a JWT-authenticated request; everything else passes through.
|
||||
if (current instanceof JwtAuthenticationToken jwtAuth && jwtAuth.isAuthenticated()) {
|
||||
Jwt jwt = jwtAuth.getToken();
|
||||
String username = jwt.getClaimAsString(usernameClaim);
|
||||
|
||||
if (username == null || username.isBlank()) {
|
||||
reject(
|
||||
response,
|
||||
"Token is missing the '"
|
||||
+ usernameClaim
|
||||
+ "' claim used to map to a"
|
||||
+ " Stirling user.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer the canonical username from the account record; fall back to the claim when
|
||||
// binding is off.
|
||||
String boundUsername = username;
|
||||
if (requireExistingAccount) {
|
||||
Optional<User> account = userService.findByUsernameIgnoreCase(username);
|
||||
if (account.isEmpty() || !account.get().isEnabled()) {
|
||||
log.warn(
|
||||
"MCP access denied: token subject '{}' has no active Stirling account",
|
||||
sanitizeForLog(username));
|
||||
reject(
|
||||
response,
|
||||
"MCP access requires a provisioned, enabled Stirling account for this"
|
||||
+ " subject.");
|
||||
return;
|
||||
}
|
||||
boundUsername = account.get().getUsername();
|
||||
}
|
||||
|
||||
// Rebind to the Stirling username, carrying only the OAuth scope authorities.
|
||||
UsernamePasswordAuthenticationToken bound =
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
boundUsername, null, jwtAuth.getAuthorities());
|
||||
bound.setDetails(jwtAuth.getDetails());
|
||||
SecurityContext context = SecurityContextHolder.createEmptyContext();
|
||||
context.setAuthentication(bound);
|
||||
SecurityContextHolder.setContext(context);
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
/** Strip CR/LF so a crafted claim value can't forge log lines. */
|
||||
private static String sanitizeForLog(String value) {
|
||||
return value == null ? null : value.replace('\r', ' ').replace('\n', ' ');
|
||||
}
|
||||
|
||||
private void reject(HttpServletResponse response, String message) throws IOException {
|
||||
SecurityContextHolder.clearContext();
|
||||
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||
response.setContentType("application/json");
|
||||
ObjectNode body = MAPPER.createObjectNode();
|
||||
body.put("error", "insufficient_account");
|
||||
body.put("message", message);
|
||||
response.getWriter().write(MAPPER.writeValueAsString(body));
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
package stirling.software.proprietary.mcp.tools;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
|
||||
import stirling.software.proprietary.mcp.McpCallContext;
|
||||
import stirling.software.proprietary.mcp.McpTool;
|
||||
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationCategory;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationMeta;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ArrayNode;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Common scaffolding for the PDF category tools. Operation ids and summaries come from the live
|
||||
* {@link McpToolCatalog}.
|
||||
*/
|
||||
abstract class AbstractCategoryTool implements McpTool {
|
||||
|
||||
protected final ObjectMapper mapper;
|
||||
protected final ObjectProvider<McpToolCatalog> catalogProvider;
|
||||
protected final ObjectProvider<McpOperationExecutor> executorProvider;
|
||||
|
||||
protected AbstractCategoryTool(
|
||||
ObjectMapper mapper,
|
||||
ObjectProvider<McpToolCatalog> catalog,
|
||||
ObjectProvider<McpOperationExecutor> executor) {
|
||||
this.mapper = mapper;
|
||||
this.catalogProvider = catalog;
|
||||
this.executorProvider = executor;
|
||||
}
|
||||
|
||||
protected abstract OperationCategory category();
|
||||
|
||||
protected List<OperationMeta> enabledOperations() {
|
||||
McpToolCatalog catalog = catalogProvider.getIfAvailable();
|
||||
if (catalog == null) {
|
||||
return List.of();
|
||||
}
|
||||
return catalog.enabledOps(category());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectNode inputSchema() {
|
||||
ObjectNode schema = mapper.createObjectNode();
|
||||
schema.put("type", "object");
|
||||
schema.put("additionalProperties", false);
|
||||
|
||||
ObjectNode props = schema.putObject("properties");
|
||||
|
||||
ObjectNode op = props.putObject("operation");
|
||||
op.put("type", "string");
|
||||
List<OperationMeta> enabled = enabledOperations();
|
||||
StringBuilder opDesc = new StringBuilder();
|
||||
opDesc.append(
|
||||
"Operation id from this category. Call stirling_describe_operation first to learn"
|
||||
+ " the exact parameters schema. Available operations:\n");
|
||||
ArrayNode opEnum = op.putArray("enum");
|
||||
for (OperationMeta m : enabled) {
|
||||
opEnum.add(m.id());
|
||||
opDesc.append("- ").append(m.id()).append(" - ").append(m.summary()).append('\n');
|
||||
}
|
||||
op.put("description", opDesc.toString().trim());
|
||||
|
||||
ObjectNode params = props.putObject("parameters");
|
||||
params.put("type", "object");
|
||||
params.put(
|
||||
"description",
|
||||
"Per-operation parameters. Schema available via stirling_describe_operation.");
|
||||
params.put("additionalProperties", true);
|
||||
|
||||
McpToolSupport.stringProperty(
|
||||
props,
|
||||
"file",
|
||||
"Base64-encoded file content to process. The recommended way to provide a file for"
|
||||
+ " most uses. Bounded by the MCP request size limit; for very large files"
|
||||
+ " use 'fileId' instead.");
|
||||
McpToolSupport.stringProperty(
|
||||
props,
|
||||
"fileName",
|
||||
"Optional original filename (with extension) for the input; helps operations that"
|
||||
+ " key off file type.");
|
||||
McpToolSupport.stringProperty(
|
||||
props,
|
||||
"fileId",
|
||||
"Reference to a file already stored via stirling_upload. Recommended only for large"
|
||||
+ " files or multi-step workflows; most users should pass the file inline"
|
||||
+ " via 'file' instead.");
|
||||
|
||||
ArrayNode required = schema.putArray("required");
|
||||
required.add("operation");
|
||||
return schema;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectNode call(JsonNode arguments, McpCallContext context) {
|
||||
JsonNode opNode = arguments == null ? null : arguments.get("operation");
|
||||
// No operation chosen: return this category's operation list.
|
||||
if (opNode == null || !opNode.isTextual() || opNode.asText().isBlank()) {
|
||||
return operationListError(null);
|
||||
}
|
||||
String opId = opNode.asText();
|
||||
McpToolCatalog catalog = catalogProvider.getIfAvailable();
|
||||
if (catalog == null) {
|
||||
return McpResponses.error(mapper, "MCP catalog is not available");
|
||||
}
|
||||
OperationMeta meta = catalog.findByOperationId(opId).orElse(null);
|
||||
// Invalid/disabled/wrong-category op: return this category's operations.
|
||||
if (meta == null || meta.category() != category()) {
|
||||
return operationListError(opId);
|
||||
}
|
||||
if (!context.hasScope(meta.requiredScope())) {
|
||||
return McpResponses.error(
|
||||
mapper,
|
||||
"Insufficient scope: this operation requires '" + meta.requiredScope() + "'.");
|
||||
}
|
||||
McpOperationExecutor executor = executorProvider.getIfAvailable();
|
||||
if (executor == null) {
|
||||
return McpResponses.error(mapper, "MCP execution is not available.");
|
||||
}
|
||||
return executor.execute(meta, arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Error for a missing/unknown operation, listing this category's available operation ids and
|
||||
* summaries.
|
||||
*/
|
||||
private ObjectNode operationListError(String badOpId) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (badOpId == null) {
|
||||
sb.append("Missing required argument 'operation' for ").append(category().toolName());
|
||||
} else {
|
||||
sb.append("Unknown or disabled operation '")
|
||||
.append(badOpId)
|
||||
.append("' for ")
|
||||
.append(category().toolName());
|
||||
}
|
||||
List<OperationMeta> ops = enabledOperations();
|
||||
if (ops.isEmpty()) {
|
||||
sb.append(". No operations are currently available in this category.");
|
||||
} else {
|
||||
sb.append(". Available operations:");
|
||||
for (OperationMeta m : ops) {
|
||||
sb.append("\n- ").append(m.id()).append(" - ").append(m.summary());
|
||||
}
|
||||
sb.append("\nRe-call this tool with a valid 'operation'.");
|
||||
}
|
||||
return McpResponses.error(mapper, sb.toString());
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package stirling.software.proprietary.mcp.tools;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import stirling.software.proprietary.mcp.McpCallContext;
|
||||
import stirling.software.proprietary.mcp.McpTool;
|
||||
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationMeta;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ArrayNode;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/** Returns the JSON Schema for one operation's parameters, from the live {@link McpToolCatalog}. */
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
|
||||
public class DescribeOperationTool implements McpTool {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
private final ObjectProvider<McpToolCatalog> catalogProvider;
|
||||
|
||||
public DescribeOperationTool(ObjectMapper mapper, ObjectProvider<McpToolCatalog> catalog) {
|
||||
this.mapper = mapper;
|
||||
this.catalogProvider = catalog;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "stirling_describe_operation";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Return the full JSON Schema for one Stirling operation's parameters. Call this "
|
||||
+ "before invoking a category tool to learn the exact shape of `parameters`. "
|
||||
+ "Argument: { operation: <op-id> } where <op-id> appears in the enum of any "
|
||||
+ "category tool (stirling_convert, _pages, _misc, _security, _ai).";
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectNode inputSchema() {
|
||||
ObjectNode schema = mapper.createObjectNode();
|
||||
schema.put("type", "object");
|
||||
schema.put("additionalProperties", false);
|
||||
ObjectNode props = schema.putObject("properties");
|
||||
ObjectNode op = props.putObject("operation");
|
||||
op.put("type", "string");
|
||||
op.put(
|
||||
"description",
|
||||
"Operation id (e.g. compress-pdf, pdf-to-word, q-and-a). See category tool enums.");
|
||||
ArrayNode required = schema.putArray("required");
|
||||
required.add("operation");
|
||||
return schema;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectNode call(JsonNode arguments, McpCallContext context) {
|
||||
JsonNode opNode = arguments == null ? null : arguments.get("operation");
|
||||
if (opNode == null || !opNode.isTextual() || opNode.asText().isBlank()) {
|
||||
return McpResponses.error(mapper, "Missing required argument: operation");
|
||||
}
|
||||
String opId = opNode.asText();
|
||||
McpToolCatalog catalog = catalogProvider.getIfAvailable();
|
||||
if (catalog == null) {
|
||||
return McpResponses.error(mapper, "MCP catalog is not available");
|
||||
}
|
||||
OperationMeta meta = catalog.findByOperationId(opId).orElse(null);
|
||||
if (meta == null) {
|
||||
return McpResponses.error(mapper, "Unknown or disabled operation: " + opId);
|
||||
}
|
||||
|
||||
ObjectNode payload = mapper.createObjectNode();
|
||||
payload.put("operation", meta.id());
|
||||
payload.put("category", meta.category().toolName());
|
||||
payload.put("summary", meta.summary());
|
||||
payload.put("endpoint", meta.endpointPath());
|
||||
payload.put("requiredScope", meta.requiredScope());
|
||||
payload.set("parametersSchema", meta.paramSchema());
|
||||
return McpResponses.json(mapper, payload);
|
||||
}
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
package stirling.software.proprietary.mcp.tools;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RestClientResponseException;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
import stirling.software.common.service.InternalApiTimeoutException;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationMeta;
|
||||
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Runs a JAVA_ENDPOINT operation: resolves the input file (inline base64 or a fileId), dispatches
|
||||
* to the Stirling endpoint over the loopback via {@link InternalApiClient}, and stores the result.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
|
||||
public class McpOperationExecutor {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
private final InternalApiClient internalApiClient;
|
||||
private final FileStorage fileStorage;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
public McpOperationExecutor(
|
||||
ObjectMapper mapper,
|
||||
InternalApiClient internalApiClient,
|
||||
FileStorage fileStorage,
|
||||
ApplicationProperties applicationProperties) {
|
||||
this.mapper = mapper;
|
||||
this.internalApiClient = internalApiClient;
|
||||
this.fileStorage = fileStorage;
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
public ObjectNode execute(OperationMeta meta, JsonNode arguments) {
|
||||
String fileName = McpToolSupport.textArg(arguments, "fileName");
|
||||
String fileId = McpToolSupport.textArg(arguments, "fileId");
|
||||
byte[] inputBytes;
|
||||
String inputName;
|
||||
if (fileId != null) {
|
||||
try {
|
||||
if (!fileStorage.fileExists(fileId)) {
|
||||
return McpResponses.error(
|
||||
mapper,
|
||||
"Unknown or inaccessible fileId '"
|
||||
+ fileId
|
||||
+ "'. Re-upload with stirling_upload.");
|
||||
}
|
||||
inputBytes = fileStorage.retrieveBytes(fileId);
|
||||
} catch (SecurityException e) {
|
||||
return McpResponses.error(
|
||||
mapper,
|
||||
"Unknown or inaccessible fileId '"
|
||||
+ fileId
|
||||
+ "'. Re-upload with stirling_upload.");
|
||||
} catch (IOException e) {
|
||||
return McpResponses.error(mapper, "Could not read fileId '" + fileId + "'.");
|
||||
}
|
||||
inputName = fileName != null ? fileName : fileId;
|
||||
} else {
|
||||
String base64 = McpToolSupport.textArg(arguments, "file");
|
||||
if (base64 == null) {
|
||||
return McpResponses.error(
|
||||
mapper,
|
||||
"This operation needs an input file. Pass 'file' as base64 (recommended for"
|
||||
+ " most files), or 'fileId' from stirling_upload for large files.");
|
||||
}
|
||||
inputBytes = McpToolSupport.decodeBase64OrNull(base64);
|
||||
if (inputBytes == null) {
|
||||
return McpResponses.error(mapper, "The 'file' argument is not valid base64.");
|
||||
}
|
||||
inputName = fileName != null ? fileName : "input.pdf";
|
||||
}
|
||||
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add("fileInput", bytesResource(inputBytes, inputName));
|
||||
addParameters(body, arguments == null ? null : arguments.get("parameters"));
|
||||
|
||||
ResponseEntity<Resource> response;
|
||||
try {
|
||||
response = internalApiClient.post(meta.endpointPath(), body);
|
||||
} catch (InternalApiTimeoutException e) {
|
||||
return McpResponses.error(
|
||||
mapper,
|
||||
meta.id()
|
||||
+ " timed out after "
|
||||
+ e.getReadTimeout().toSeconds()
|
||||
+ "s. Try a smaller file or a different approach.");
|
||||
} catch (RestClientResponseException e) {
|
||||
log.warn(
|
||||
"MCP {} upstream error: HTTP {} - {}",
|
||||
meta.id(),
|
||||
e.getStatusCode().value(),
|
||||
snippet(e.getResponseBodyAsString()));
|
||||
return McpResponses.error(
|
||||
mapper, meta.id() + " failed: HTTP " + e.getStatusCode().value() + ".");
|
||||
} catch (SecurityException e) {
|
||||
return McpResponses.error(
|
||||
mapper, meta.id() + " endpoint is not permitted for MCP dispatch.");
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("MCP execution of {} failed", meta.id(), e);
|
||||
return McpResponses.error(
|
||||
mapper, meta.id() + " failed unexpectedly. See server logs for details.");
|
||||
}
|
||||
return buildResult(meta, response);
|
||||
}
|
||||
|
||||
private ObjectNode buildResult(OperationMeta meta, ResponseEntity<Resource> response) {
|
||||
Resource body = response.getBody();
|
||||
if (body == null) {
|
||||
return McpResponses.error(mapper, meta.id() + " returned an empty response.");
|
||||
}
|
||||
MediaType contentType = response.getHeaders().getContentType();
|
||||
|
||||
// A JSON body is a structured report (e.g. get-info), not a file.
|
||||
if (contentType != null && MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
|
||||
try (InputStream is = body.getInputStream()) {
|
||||
return McpResponses.text(
|
||||
mapper, new String(is.readAllBytes(), StandardCharsets.UTF_8));
|
||||
} catch (IOException e) {
|
||||
return McpResponses.error(mapper, "Failed to read " + meta.id() + " result.");
|
||||
}
|
||||
}
|
||||
|
||||
String filename =
|
||||
body.getFilename() == null || body.getFilename().isBlank()
|
||||
? meta.id()
|
||||
: body.getFilename();
|
||||
String mimeType =
|
||||
contentType != null
|
||||
? contentType.toString()
|
||||
: MediaType.APPLICATION_OCTET_STREAM_VALUE;
|
||||
long maxInline = applicationProperties.getMcp().getMaxInlineResponseBytes();
|
||||
try {
|
||||
long size = body.contentLength();
|
||||
byte[] inline = null;
|
||||
if (size >= 0 && size <= maxInline) {
|
||||
try (InputStream is = body.getInputStream()) {
|
||||
inline = is.readAllBytes();
|
||||
}
|
||||
}
|
||||
String fileId =
|
||||
inline != null
|
||||
? fileStorage.storeBytes(inline, filename)
|
||||
: storeStreamed(body, filename);
|
||||
String summary =
|
||||
meta.id()
|
||||
+ " succeeded. Result: "
|
||||
+ filename
|
||||
+ " ("
|
||||
+ size
|
||||
+ " bytes), fileId="
|
||||
+ fileId
|
||||
+ ". ";
|
||||
if (inline != null) {
|
||||
return McpResponses.result(
|
||||
mapper,
|
||||
false,
|
||||
McpResponses.textBlock(
|
||||
mapper, summary + "The file is included inline below."),
|
||||
McpResponses.resourceBlock(
|
||||
mapper,
|
||||
"stirling://file/" + fileId,
|
||||
mimeType,
|
||||
Base64.getEncoder().encodeToString(inline)));
|
||||
}
|
||||
return McpResponses.result(
|
||||
mapper,
|
||||
false,
|
||||
McpResponses.textBlock(
|
||||
mapper,
|
||||
summary
|
||||
+ "Large result - fetch it with stirling_download {\"fileId\":\""
|
||||
+ fileId
|
||||
+ "\"}, or pass this fileId to another operation."));
|
||||
} catch (IOException e) {
|
||||
return McpResponses.error(mapper, "Failed to store " + meta.id() + " result.");
|
||||
}
|
||||
}
|
||||
|
||||
private String storeStreamed(Resource body, String filename) throws IOException {
|
||||
try (InputStream is = body.getInputStream()) {
|
||||
return fileStorage.storeInputStream(is, filename).fileId();
|
||||
}
|
||||
}
|
||||
|
||||
private void addParameters(MultiValueMap<String, Object> body, JsonNode params) {
|
||||
if (params == null || !params.isObject()) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> map =
|
||||
mapper.convertValue(params, new TypeReference<Map<String, Object>>() {});
|
||||
for (Map.Entry<String, Object> entry : map.entrySet()) {
|
||||
Object value = entry.getValue();
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
if (value instanceof List<?> list) {
|
||||
if (containsStructured(list)) {
|
||||
body.add(entry.getKey(), mapper.writeValueAsString(list));
|
||||
} else {
|
||||
list.forEach(item -> body.add(entry.getKey(), item));
|
||||
}
|
||||
} else if (value instanceof Map<?, ?>) {
|
||||
body.add(entry.getKey(), mapper.writeValueAsString(value));
|
||||
} else {
|
||||
body.add(entry.getKey(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean containsStructured(List<?> list) {
|
||||
return list.stream().anyMatch(item -> item instanceof Map<?, ?> || item instanceof List<?>);
|
||||
}
|
||||
|
||||
private static Resource bytesResource(byte[] bytes, String filename) {
|
||||
return new ByteArrayResource(bytes) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return filename;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static String snippet(String body) {
|
||||
if (body == null || body.isBlank()) {
|
||||
return "(no body)";
|
||||
}
|
||||
String trimmed = body.strip();
|
||||
return trimmed.length() > 300 ? trimmed.substring(0, 300) + "..." : trimmed;
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package stirling.software.proprietary.mcp.tools;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ArrayNode;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/** Helpers for the MCP {@code CallToolResult} response shape. */
|
||||
public final class McpResponses {
|
||||
|
||||
private McpResponses() {}
|
||||
|
||||
/** Plain-text content block. */
|
||||
public static ObjectNode text(ObjectMapper mapper, String text) {
|
||||
ObjectNode block = mapper.createObjectNode();
|
||||
block.put("type", "text");
|
||||
block.put("text", text);
|
||||
return wrap(mapper, block, false);
|
||||
}
|
||||
|
||||
/** Plain-text error ({@code isError:true}). */
|
||||
public static ObjectNode error(ObjectMapper mapper, String message) {
|
||||
ObjectNode block = mapper.createObjectNode();
|
||||
block.put("type", "text");
|
||||
block.put("text", message);
|
||||
return wrap(mapper, block, true);
|
||||
}
|
||||
|
||||
/** JSON payload as embedded text. */
|
||||
public static ObjectNode json(ObjectMapper mapper, ObjectNode payload) {
|
||||
ObjectNode block = mapper.createObjectNode();
|
||||
block.put("type", "text");
|
||||
block.put("text", payload.toString());
|
||||
return wrap(mapper, block, false);
|
||||
}
|
||||
|
||||
/** A text content block (unwrapped). */
|
||||
public static ObjectNode textBlock(ObjectMapper mapper, String text) {
|
||||
ObjectNode block = mapper.createObjectNode();
|
||||
block.put("type", "text");
|
||||
block.put("text", text);
|
||||
return block;
|
||||
}
|
||||
|
||||
/** An embedded-resource content block carrying base64 file content. */
|
||||
public static ObjectNode resourceBlock(
|
||||
ObjectMapper mapper, String uri, String mimeType, String base64) {
|
||||
ObjectNode block = mapper.createObjectNode();
|
||||
block.put("type", "resource");
|
||||
ObjectNode res = block.putObject("resource");
|
||||
res.put("uri", uri);
|
||||
if (mimeType != null) {
|
||||
res.put("mimeType", mimeType);
|
||||
}
|
||||
res.put("blob", base64);
|
||||
return block;
|
||||
}
|
||||
|
||||
/** Build a result from explicit content blocks. */
|
||||
public static ObjectNode result(ObjectMapper mapper, boolean isError, ObjectNode... blocks) {
|
||||
ObjectNode result = mapper.createObjectNode();
|
||||
ArrayNode content = result.putArray("content");
|
||||
for (ObjectNode b : blocks) {
|
||||
content.add(b);
|
||||
}
|
||||
if (isError) {
|
||||
result.put("isError", true);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ObjectNode wrap(ObjectMapper mapper, ObjectNode block, boolean isError) {
|
||||
ObjectNode result = mapper.createObjectNode();
|
||||
ArrayNode content = result.putArray("content");
|
||||
content.add(block);
|
||||
if (isError) {
|
||||
result.put("isError", true);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package stirling.software.proprietary.mcp.tools;
|
||||
|
||||
import java.util.Base64;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/** Shared helpers for MCP tools: argument parsing and JSON-Schema building. */
|
||||
final class McpToolSupport {
|
||||
|
||||
private McpToolSupport() {}
|
||||
|
||||
/** Trimmed text value of an argument, or null if absent, blank, or not a string. */
|
||||
static String textArg(JsonNode args, String field) {
|
||||
if (args == null) {
|
||||
return null;
|
||||
}
|
||||
JsonNode node = args.get(field);
|
||||
if (node == null || !node.isTextual()) {
|
||||
return null;
|
||||
}
|
||||
String value = node.asText().trim();
|
||||
return value.isEmpty() ? null : value;
|
||||
}
|
||||
|
||||
/** Decode base64 content, or null if the input is not valid base64. */
|
||||
static byte[] decodeBase64OrNull(String base64) {
|
||||
try {
|
||||
return Base64.getDecoder().decode(base64);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a {@code string} property with a description to a JSON-Schema {@code properties} node.
|
||||
*/
|
||||
static void stringProperty(ObjectNode properties, String name, String description) {
|
||||
ObjectNode prop = properties.putObject(name);
|
||||
prop.put("type", "string");
|
||||
prop.put("description", description);
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
package stirling.software.proprietary.mcp.tools;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.mcp.McpCallContext;
|
||||
import stirling.software.proprietary.mcp.McpTool;
|
||||
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationCategory;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationMeta;
|
||||
import stirling.software.proprietary.service.AiEngineClient;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ArrayNode;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Exposes curated Python agent capabilities as a single MCP tool, sourced from the engine
|
||||
* capabilities manifest.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
|
||||
public class StirlingAiTool implements McpTool {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
private final ObjectProvider<McpToolCatalog> catalogProvider;
|
||||
private final ObjectProvider<AiEngineClient> engineClientProvider;
|
||||
|
||||
public StirlingAiTool(
|
||||
ObjectMapper mapper,
|
||||
ObjectProvider<McpToolCatalog> catalog,
|
||||
ObjectProvider<AiEngineClient> engineClient) {
|
||||
this.mapper = mapper;
|
||||
this.catalogProvider = catalog;
|
||||
this.engineClientProvider = engineClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "stirling_ai";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Invoke a Stirling AI agent capability (Q&A about a PDF, edit-plan generation,"
|
||||
+ " inline comments, math audit, draft-spec helper). Call"
|
||||
+ " stirling_describe_operation with the chosen capability id to get its"
|
||||
+ " parameters schema before invoking this tool. Some capabilities return content"
|
||||
+ " inline; others return a job reference that resolves to a file when ready.";
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectNode inputSchema() {
|
||||
ObjectNode schema = mapper.createObjectNode();
|
||||
schema.put("type", "object");
|
||||
schema.put("additionalProperties", false);
|
||||
ObjectNode props = schema.putObject("properties");
|
||||
|
||||
ObjectNode op = props.putObject("operation");
|
||||
op.put("type", "string");
|
||||
StringBuilder desc = new StringBuilder();
|
||||
desc.append("Capability id from the engine manifest. Available capabilities:\n");
|
||||
ArrayNode opEnum = op.putArray("enum");
|
||||
for (OperationMeta m : aiOps()) {
|
||||
opEnum.add(m.id());
|
||||
desc.append("- ").append(m.id()).append(" - ").append(m.summary()).append('\n');
|
||||
}
|
||||
op.put("description", desc.toString().trim());
|
||||
|
||||
ObjectNode params = props.putObject("parameters");
|
||||
params.put("type", "object");
|
||||
params.put("description", "Per-capability parameters.");
|
||||
params.put("additionalProperties", true);
|
||||
|
||||
ObjectNode fileId = props.putObject("fileId");
|
||||
fileId.put("type", "string");
|
||||
fileId.put(
|
||||
"description",
|
||||
"Reference to a previously-uploaded PDF in Stirling's job store. Required for"
|
||||
+ " capabilities that consume a document.");
|
||||
|
||||
ArrayNode required = schema.putArray("required");
|
||||
required.add("operation");
|
||||
return schema;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectNode call(JsonNode arguments, McpCallContext context) {
|
||||
JsonNode opNode = arguments == null ? null : arguments.get("operation");
|
||||
if (opNode == null || !opNode.isTextual() || opNode.asText().isBlank()) {
|
||||
return McpResponses.error(mapper, "Missing required argument: operation");
|
||||
}
|
||||
String opId = opNode.asText();
|
||||
McpToolCatalog catalog = catalogProvider.getIfAvailable();
|
||||
if (catalog == null) {
|
||||
return McpResponses.error(mapper, "MCP catalog is not available");
|
||||
}
|
||||
OperationMeta meta = catalog.findByOperationId(opId).orElse(null);
|
||||
if (meta == null || meta.category() != OperationCategory.AI) {
|
||||
return McpResponses.error(
|
||||
mapper,
|
||||
"Unknown AI capability '"
|
||||
+ opId
|
||||
+ "'. The engine manifest may not be loaded yet - retry shortly or"
|
||||
+ " confirm the engine is reachable.");
|
||||
}
|
||||
if (!context.hasScope(meta.requiredScope())) {
|
||||
return McpResponses.error(
|
||||
mapper,
|
||||
"Insufficient scope: this capability requires '" + meta.requiredScope() + "'.");
|
||||
}
|
||||
AiEngineClient client = engineClientProvider.getIfAvailable();
|
||||
if (client == null) {
|
||||
return McpResponses.error(
|
||||
mapper, "AI engine client is not configured - enable aiEngine in settings.");
|
||||
}
|
||||
if (meta.endpointPath() == null) {
|
||||
return McpResponses.error(
|
||||
mapper,
|
||||
"Capability '" + opId + "' has no route configured in the engine manifest.");
|
||||
}
|
||||
JsonNode params = arguments.get("parameters");
|
||||
String body = (params == null ? mapper.createObjectNode() : params).toString();
|
||||
try {
|
||||
String response = client.post(meta.endpointPath(), body, context.stirlingUserId());
|
||||
return McpResponses.text(mapper, response);
|
||||
} catch (IOException e) {
|
||||
log.warn("MCP AI capability '{}' engine request failed", opId, e);
|
||||
return McpResponses.error(
|
||||
mapper, "Engine request failed for capability '" + opId + "'.");
|
||||
}
|
||||
}
|
||||
|
||||
private List<OperationMeta> aiOps() {
|
||||
McpToolCatalog catalog = catalogProvider.getIfAvailable();
|
||||
if (catalog == null) {
|
||||
return List.of();
|
||||
}
|
||||
return catalog.enabledOps(OperationCategory.AI);
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package stirling.software.proprietary.mcp.tools;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationCategory;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/** Exposes the {@code /api/v1/convert/*} namespace as a single MCP tool. */
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
|
||||
public class StirlingConvertTool extends AbstractCategoryTool {
|
||||
|
||||
public StirlingConvertTool(
|
||||
ObjectMapper mapper,
|
||||
ObjectProvider<McpToolCatalog> catalog,
|
||||
ObjectProvider<McpOperationExecutor> executor) {
|
||||
super(mapper, catalog, executor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "stirling_convert";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Convert files between PDF and other formats (PDF<->Word, PDF<->image, HTML->PDF,"
|
||||
+ " etc.). Inspect the `operation` enum, then call stirling_describe_operation"
|
||||
+ " with the chosen op to get its parameters JSON Schema before calling this"
|
||||
+ " tool.";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected OperationCategory category() {
|
||||
return OperationCategory.CONVERT;
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package stirling.software.proprietary.mcp.tools;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Base64;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.proprietary.mcp.McpCallContext;
|
||||
import stirling.software.proprietary.mcp.McpTool;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Fetches a stored file's content by fileId, returned inline as base64. For large results that were
|
||||
* not returned inline by an operation.
|
||||
*/
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
|
||||
public class StirlingDownloadTool implements McpTool {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
private final FileStorage fileStorage;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
public StirlingDownloadTool(
|
||||
ObjectMapper mapper,
|
||||
FileStorage fileStorage,
|
||||
ApplicationProperties applicationProperties) {
|
||||
this.mapper = mapper;
|
||||
this.fileStorage = fileStorage;
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "stirling_download";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Fetch a stored file's content by fileId (e.g. an operation result), returned inline"
|
||||
+ " as base64. Recommended only when a result was too large to be returned inline."
|
||||
+ " Argument: { fileId: <id> }.";
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectNode inputSchema() {
|
||||
ObjectNode schema = mapper.createObjectNode();
|
||||
schema.put("type", "object");
|
||||
schema.put("additionalProperties", false);
|
||||
ObjectNode props = schema.putObject("properties");
|
||||
McpToolSupport.stringProperty(
|
||||
props, "fileId", "Id of a stored file (e.g. an operation result's fileId).");
|
||||
schema.putArray("required").add("fileId");
|
||||
return schema;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectNode call(JsonNode arguments, McpCallContext context) {
|
||||
if (!context.hasScope("mcp.tools.read")) {
|
||||
return McpResponses.error(
|
||||
mapper, "Insufficient scope: stirling_download requires 'mcp.tools.read'.");
|
||||
}
|
||||
String fileId = McpToolSupport.textArg(arguments, "fileId");
|
||||
if (fileId == null) {
|
||||
return McpResponses.error(mapper, "Missing required argument: fileId.");
|
||||
}
|
||||
long maxInline = applicationProperties.getMcp().getMaxInlineResponseBytes();
|
||||
try {
|
||||
if (!fileStorage.fileExists(fileId)) {
|
||||
return McpResponses.error(
|
||||
mapper, "Unknown or inaccessible fileId '" + fileId + "'.");
|
||||
}
|
||||
long size = fileStorage.getFileSize(fileId);
|
||||
if (size > maxInline) {
|
||||
return McpResponses.error(
|
||||
mapper,
|
||||
"File is "
|
||||
+ size
|
||||
+ " bytes, over the inline limit of "
|
||||
+ maxInline
|
||||
+ " bytes. Raise mcp.maxInlineResponseBytes or retrieve it via the"
|
||||
+ " Stirling UI/API.");
|
||||
}
|
||||
byte[] bytes = fileStorage.retrieveBytes(fileId);
|
||||
return McpResponses.result(
|
||||
mapper,
|
||||
false,
|
||||
McpResponses.textBlock(
|
||||
mapper,
|
||||
"File "
|
||||
+ fileId
|
||||
+ " ("
|
||||
+ bytes.length
|
||||
+ " bytes) included inline below."),
|
||||
McpResponses.resourceBlock(
|
||||
mapper,
|
||||
"stirling://file/" + fileId,
|
||||
MediaType.APPLICATION_OCTET_STREAM_VALUE,
|
||||
Base64.getEncoder().encodeToString(bytes)));
|
||||
} catch (SecurityException e) {
|
||||
return McpResponses.error(mapper, "Unknown or inaccessible fileId '" + fileId + "'.");
|
||||
} catch (IOException e) {
|
||||
return McpResponses.error(mapper, "Failed to read fileId '" + fileId + "'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package stirling.software.proprietary.mcp.tools;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationCategory;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/** Exposes the {@code /api/v1/misc/*} namespace as a single MCP tool. */
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
|
||||
public class StirlingMiscTool extends AbstractCategoryTool {
|
||||
|
||||
public StirlingMiscTool(
|
||||
ObjectMapper mapper,
|
||||
ObjectProvider<McpToolCatalog> catalog,
|
||||
ObjectProvider<McpOperationExecutor> executor) {
|
||||
super(mapper, catalog, executor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "stirling_misc";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Miscellaneous PDF operations: compress, OCR, stamp / watermark, edit metadata,"
|
||||
+ " flatten, repair, and similar utilities. Call stirling_describe_operation with"
|
||||
+ " the chosen op to get its parameters schema before invoking this tool.";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected OperationCategory category() {
|
||||
return OperationCategory.MISC;
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package stirling.software.proprietary.mcp.tools;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationCategory;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/** Exposes the {@code /api/v1/general/*} (page operations) namespace as a single MCP tool. */
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
|
||||
public class StirlingPagesTool extends AbstractCategoryTool {
|
||||
|
||||
public StirlingPagesTool(
|
||||
ObjectMapper mapper,
|
||||
ObjectProvider<McpToolCatalog> catalog,
|
||||
ObjectProvider<McpOperationExecutor> executor) {
|
||||
super(mapper, catalog, executor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "stirling_pages";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Manipulate PDF pages: merge, split, rotate, rearrange, crop, delete, overlay,"
|
||||
+ " add blank pages. Call stirling_describe_operation with the chosen op to get"
|
||||
+ " its parameters schema before invoking this tool.";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected OperationCategory category() {
|
||||
return OperationCategory.PAGES;
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package stirling.software.proprietary.mcp.tools;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import stirling.software.proprietary.mcp.catalog.McpToolCatalog;
|
||||
import stirling.software.proprietary.mcp.catalog.OperationCategory;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/** Exposes the {@code /api/v1/security/*} namespace as a single MCP tool. */
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
|
||||
public class StirlingSecurityTool extends AbstractCategoryTool {
|
||||
|
||||
public StirlingSecurityTool(
|
||||
ObjectMapper mapper,
|
||||
ObjectProvider<McpToolCatalog> catalog,
|
||||
ObjectProvider<McpOperationExecutor> executor) {
|
||||
super(mapper, catalog, executor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "stirling_security";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Security-related PDF operations: password add/remove, redact, sanitize, certify"
|
||||
+ " / sign with cert, validate signature, add watermark. Call"
|
||||
+ " stirling_describe_operation with the chosen op to get its parameters schema"
|
||||
+ " before invoking this tool.";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected OperationCategory category() {
|
||||
return OperationCategory.SECURITY;
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package stirling.software.proprietary.mcp.tools;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.proprietary.mcp.McpCallContext;
|
||||
import stirling.software.proprietary.mcp.McpTool;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Stores a file server-side and returns a fileId. For large files or multi-step workflows only -
|
||||
* most operations accept the file inline via their {@code file} argument.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true")
|
||||
public class StirlingUploadTool implements McpTool {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
private final FileStorage fileStorage;
|
||||
|
||||
public StirlingUploadTool(ObjectMapper mapper, FileStorage fileStorage) {
|
||||
this.mapper = mapper;
|
||||
this.fileStorage = fileStorage;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "stirling_upload";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Store a file server-side and get back a fileId to reuse across operations."
|
||||
+ " Recommended only for large files or multi-step workflows; for a single"
|
||||
+ " operation on a typical file, pass the file inline via the operation's `file`"
|
||||
+ " argument instead. Argument: { file: <base64>, fileName?: <name> }.";
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectNode inputSchema() {
|
||||
ObjectNode schema = mapper.createObjectNode();
|
||||
schema.put("type", "object");
|
||||
schema.put("additionalProperties", false);
|
||||
ObjectNode props = schema.putObject("properties");
|
||||
McpToolSupport.stringProperty(props, "file", "Base64-encoded file content.");
|
||||
McpToolSupport.stringProperty(
|
||||
props, "fileName", "Optional original filename (with extension).");
|
||||
schema.putArray("required").add("file");
|
||||
return schema;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectNode call(JsonNode arguments, McpCallContext context) {
|
||||
if (!context.hasScope("mcp.tools.write")) {
|
||||
return McpResponses.error(
|
||||
mapper, "Insufficient scope: stirling_upload requires 'mcp.tools.write'.");
|
||||
}
|
||||
String base64 = McpToolSupport.textArg(arguments, "file");
|
||||
if (base64 == null) {
|
||||
return McpResponses.error(
|
||||
mapper, "Missing required argument: file (base64-encoded content).");
|
||||
}
|
||||
byte[] bytes = McpToolSupport.decodeBase64OrNull(base64);
|
||||
if (bytes == null) {
|
||||
return McpResponses.error(mapper, "The 'file' argument is not valid base64.");
|
||||
}
|
||||
String name = McpToolSupport.textArg(arguments, "fileName");
|
||||
if (name == null) {
|
||||
name = "upload.bin";
|
||||
}
|
||||
try {
|
||||
String fileId = fileStorage.storeBytes(bytes, name);
|
||||
return McpResponses.text(
|
||||
mapper,
|
||||
"Stored '"
|
||||
+ name
|
||||
+ "' ("
|
||||
+ bytes.length
|
||||
+ " bytes) as fileId="
|
||||
+ fileId
|
||||
+ ". Pass this fileId to a Stirling operation's 'fileId' argument.");
|
||||
} catch (IOException e) {
|
||||
log.warn("MCP upload failed to store file", e);
|
||||
return McpResponses.error(mapper, "Failed to store the uploaded file.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -618,6 +618,8 @@ public class AdminSettingsController {
|
||||
case "autopipeline", "autoPipeline" -> applicationProperties.getAutoPipeline();
|
||||
case "legal" -> applicationProperties.getLegal();
|
||||
case "telegram" -> applicationProperties.getTelegram();
|
||||
case "aiengine", "aiEngine" -> applicationProperties.getAiEngine();
|
||||
case "mcp" -> applicationProperties.getMcp();
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
@@ -641,7 +643,10 @@ public class AdminSettingsController {
|
||||
"autoPipeline",
|
||||
"autopipeline",
|
||||
"legal",
|
||||
"telegram");
|
||||
"telegram",
|
||||
"aiEngine",
|
||||
"aiengine",
|
||||
"mcp");
|
||||
|
||||
// Pattern to validate safe property paths - only alphanumeric, dots, and underscores
|
||||
private static final Pattern SAFE_KEY_PATTERN =
|
||||
|
||||
+22
-3
@@ -25,6 +25,7 @@ public class AiEngineClient {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final HttpClient httpClient;
|
||||
private final String engineSharedSecret;
|
||||
|
||||
@Autowired
|
||||
public AiEngineClient(ApplicationProperties applicationProperties) {
|
||||
@@ -39,8 +40,17 @@ public class AiEngineClient {
|
||||
|
||||
/** Package-private constructor that accepts an HttpClient directly; intended for tests. */
|
||||
AiEngineClient(ApplicationProperties applicationProperties, HttpClient httpClient) {
|
||||
this(applicationProperties, httpClient, System.getenv("STIRLING_ENGINE_SHARED_SECRET"));
|
||||
}
|
||||
|
||||
/** Package-private constructor that also injects the engine shared secret; for tests. */
|
||||
AiEngineClient(
|
||||
ApplicationProperties applicationProperties,
|
||||
HttpClient httpClient,
|
||||
String engineSharedSecret) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.httpClient = httpClient;
|
||||
this.engineSharedSecret = engineSharedSecret;
|
||||
}
|
||||
|
||||
public String post(String path, String jsonBody, String userId) throws IOException {
|
||||
@@ -78,6 +88,7 @@ public class AiEngineClient {
|
||||
.timeout(timeout)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
|
||||
addUserHeader(builder, userId);
|
||||
addEngineAuthHeader(builder);
|
||||
HttpResponse<String> response = sendRequest(builder.build());
|
||||
|
||||
log.debug("AI engine responded with status {}", response.statusCode());
|
||||
@@ -86,10 +97,15 @@ public class AiEngineClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the X-User-Id header so the engine can scope per-user storage (RAG documents, search
|
||||
* results) to the caller. Skipped when {@code userId} is blank: the engine treats the request
|
||||
* as anonymous and refuses any route that requires tenancy.
|
||||
* Attach the {@code X-Engine-Auth} shared secret when configured so the engine trusts this
|
||||
* backend request.
|
||||
*/
|
||||
private void addEngineAuthHeader(HttpRequest.Builder builder) {
|
||||
if (engineSharedSecret != null && !engineSharedSecret.isBlank()) {
|
||||
builder.header("X-Engine-Auth", engineSharedSecret);
|
||||
}
|
||||
}
|
||||
|
||||
private static void addUserHeader(HttpRequest.Builder builder, String userId) {
|
||||
if (userId != null && !userId.isBlank()) {
|
||||
builder.header("X-User-Id", userId);
|
||||
@@ -129,6 +145,7 @@ public class AiEngineClient {
|
||||
.timeout(timeout)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
|
||||
addUserHeader(builder, userId);
|
||||
addEngineAuthHeader(builder);
|
||||
HttpRequest request = builder.build();
|
||||
|
||||
HttpResponse<Stream<String>> response;
|
||||
@@ -184,6 +201,7 @@ public class AiEngineClient {
|
||||
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()))
|
||||
.DELETE();
|
||||
addUserHeader(builder, userId);
|
||||
addEngineAuthHeader(builder);
|
||||
HttpResponse<String> response = sendRequest(builder.build());
|
||||
|
||||
log.debug("AI engine responded with status {}", response.statusCode());
|
||||
@@ -208,6 +226,7 @@ public class AiEngineClient {
|
||||
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()))
|
||||
.GET();
|
||||
addUserHeader(builder, userId);
|
||||
addEngineAuthHeader(builder);
|
||||
HttpResponse<String> response = sendRequest(builder.build());
|
||||
|
||||
log.debug("AI engine responded with status {}", response.statusCode());
|
||||
|
||||
Reference in New Issue
Block a user