Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,40 @@ Other Quarkus properties that are specifically relevant for the service:
| `stargate.jsonapi.logging.only-results-with-errors` | `boolean` | `true` | Setting it to `true` prints the command level info only for the commands where the command result has errors. |
| `stargate.jsonapi.logging.enabled-tenants` | `string` | `ALL` | Comma separated list of tenants for which command level logging should be enabled. Default is a special keyword called `ALL` which prints this log for all tenants |

## MCP logging

Normal MCP logs contain generated request IDs, fixed protocol/command categories, HTTP status,
tool error flags, logger names and exception class names. They exclude document/query contents,
request/response bodies, client-supplied RPC IDs, credential headers, exception messages and
user-controlled logging context. MCP access URLs are excluded from the standard access log and
replaced with request summaries. Structured billing events keep their existing logging path.

The server generates one request ID before authentication and uses it in completion logs and
the command's diagnostic trace. Client headers and metadata cannot choose that ID.

Raw framework traffic logging is disabled. The `mcp-safe-logging` filter also discards that category
if `quarkus.mcp.server.traffic-logging.enabled` is overridden. It is attached to the standard console,
file, syslog and socket handlers. Any added handler or log exporter must apply the same filter
before publishing records. Keep these filters in deployment overrides. The MCP endpoint and its
access-log exclusion are derived together from `quarkus.http.root-path` and
`quarkus.mcp.server.http.root-path`. Changing either path requires no separate logging configuration.
Keep the authentication path configuration aligned with the endpoint. Explicit
`quarkus.http.access-log.exclude-pattern` overrides replace the generated exclusion and must also
exclude the MCP endpoint and its descendants. Ordinary HTTP API logging is unaffected.

For a diagnostic session, set `stargate.jsonapi.mcp.diagnostics-until` (environment variable
`STARGATE_JSONAPI_MCP_DIAGNOSTICS_UNTIL`) to an absolute ISO-8601 UTC timestamp no more than 15 minutes
in the future at startup. Diagnostics add at most 32 stack frames per exception. They expire at
that timestamp, including across restarts. Payloads, credentials, exception messages, causes and
suppressed exceptions remain excluded. There is no payload-capture mode.

Before enabling diagnostics, restrict access to the log destination and configure diagnostic log
retention to at most 24 hours. Remove the setting after the session and delete diagnostic exports
when the investigation ends. Retention is enforced by the log destination, not by this service.
Deployment verification must check the effective handlers, access-log exclusion and retention
through the public service. OAuth authorization-service logs require separate verification when
that service is integrated.

## API Feature enabling configuration
*Configuration for enabling Features, defined by [FeaturesConfig.java](src/main/java/io/stargate/sgv2/jsonapi/config/CommandLoggingConfig.java).*

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
public class RequestContext implements LoggingMDCContext {

private static final NoArgGenerator UUID_V7_GENERATOR = Generators.timeBasedEpochGenerator();
private static final String ROUTING_REQUEST_ID = RequestContext.class.getName() + ".requestId";

private final String authToken;
private final String requestId;
Expand Down Expand Up @@ -91,7 +92,7 @@ public RequestContext(
securityIdentity.getPrincipal() != null
? securityIdentity.getPrincipal().getName()
: null);
this.requestId = generateRequestId();
this.requestId = getOrCreateRequestId(routingContext);
this.userAgent = new UserAgent(httpHeaders.getHeader(HttpHeaders.USER_AGENT));
this.tenant = tenantResolver.get().resolve(routingContext, null);

Expand Down Expand Up @@ -135,6 +136,19 @@ private static String generateRequestId() {
return UUID_V7_GENERATOR.generate().toString();
}

/**
* Returns the server-generated ID shared by early MCP logging and the command's diagnostic trace.
* Routing data retains it across callbacks without reading client headers or metadata.
*/
public static String getOrCreateRequestId(RoutingContext routingContext) {
String requestId = routingContext.get(ROUTING_REQUEST_ID);
if (requestId == null) {
requestId = generateRequestId();
routingContext.put(ROUTING_REQUEST_ID, requestId);
}
return requestId;
}

/**
* String correlation ID for the request, generated at the start of the request processing.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,22 @@ public Uni<ToolResponse> createKeyspace(
CreateKeyspaceCommand.Options options) {

var command = new CreateKeyspaceCommand(name, options);
return mcpResource.processCommand(mcpResource.buildDatabaseContext(command), command);
return mcpResource.processCommand(() -> mcpResource.buildDatabaseContext(command), command);
}

@Tool(description = "Command that deletes a Keyspace.")
public Uni<ToolResponse> dropKeyspace(
@ToolArg(description = "Name of the keyspace to drop") @NotEmpty String name) {

var command = new DropKeyspaceCommand(name);
return mcpResource.processCommand(mcpResource.buildDatabaseContext(command), command);
return mcpResource.processCommand(() -> mcpResource.buildDatabaseContext(command), command);
}

@Tool(description = "Command that lists all available keyspaces")
public Uni<ToolResponse> findKeyspaces() {

var command = new FindKeyspacesCommand();
return mcpResource.processCommand(mcpResource.buildDatabaseContext(command), command);
return mcpResource.processCommand(() -> mcpResource.buildDatabaseContext(command), command);
}

@Tool(description = "Lists the available Embedding Providers for this database.")
Expand All @@ -54,7 +54,7 @@ public Uni<ToolResponse> findEmbeddingProviders(
FindEmbeddingProvidersCommand.Options options) {

var command = new FindEmbeddingProvidersCommand(options);
return mcpResource.processCommand(mcpResource.buildDatabaseContext(command), command);
return mcpResource.processCommand(() -> mcpResource.buildDatabaseContext(command), command);
}

@Tool(description = "Lists the available Reranking Providers for this database.")
Expand All @@ -66,6 +66,6 @@ public Uni<ToolResponse> findRerankingProviders(
FindRerankingProvidersCommand.Options options) {

var command = new FindRerankingProvidersCommand(options);
return mcpResource.processCommand(mcpResource.buildDatabaseContext(command), command);
return mcpResource.processCommand(() -> mcpResource.buildDatabaseContext(command), command);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ public Uni<ToolResponse> createCollection(
CreateCollectionCommand.Options options) {

var command = new CreateCollectionCommand(collection, options);
return mcpResource.processCommand(mcpResource.buildKeyspaceContext(keyspace, command), command);
return mcpResource.processCommand(
() -> mcpResource.buildKeyspaceContext(keyspace, command), command);
}

@Tool(description = "Command that deletes a collection if one exists.")
Expand All @@ -42,7 +43,8 @@ public Uni<ToolResponse> deleteCollection(
@ToolArg(description = "Name of the collection to delete") String collection) {

var command = new DeleteCollectionCommand(collection);
return mcpResource.processCommand(mcpResource.buildKeyspaceContext(keyspace, command), command);
return mcpResource.processCommand(
() -> mcpResource.buildKeyspaceContext(keyspace, command), command);
}

@Tool(description = "Command that lists all available collections in a keyspace.")
Expand All @@ -54,7 +56,8 @@ public Uni<ToolResponse> findCollections(
FindCollectionsCommand.Options options) {

var command = new FindCollectionsCommand(options);
return mcpResource.processCommand(mcpResource.buildKeyspaceContext(keyspace, command), command);
return mcpResource.processCommand(
() -> mcpResource.buildKeyspaceContext(keyspace, command), command);
}

/**
Expand All @@ -76,7 +79,8 @@ public Uni<ToolResponse> alterType(
TypeDefinitionDesc add) {

var command = new AlterTypeCommand(name, rename, add);
return mcpResource.processCommand(mcpResource.buildKeyspaceContext(keyspace, command), command);
return mcpResource.processCommand(
() -> mcpResource.buildKeyspaceContext(keyspace, command), command);
}

@Tool(description = "Command that creates an API Table.")
Expand All @@ -93,7 +97,8 @@ public Uni<ToolResponse> createTable(
CreateTableCommand.Options options) {

var command = new CreateTableCommand(table, definition, options);
return mcpResource.processCommand(mcpResource.buildKeyspaceContext(keyspace, command), command);
return mcpResource.processCommand(
() -> mcpResource.buildKeyspaceContext(keyspace, command), command);
}

@Tool(description = "Command that creates a user defined type.")
Expand All @@ -110,7 +115,8 @@ public Uni<ToolResponse> createType(
CreateTypeCommand.Options options) {

var command = new CreateTypeCommand(name, definition, options);
return mcpResource.processCommand(mcpResource.buildKeyspaceContext(keyspace, command), command);
return mcpResource.processCommand(
() -> mcpResource.buildKeyspaceContext(keyspace, command), command);
}

@Tool(description = "Command that drops an index for a column.")
Expand All @@ -123,7 +129,8 @@ public Uni<ToolResponse> dropIndex(
DropIndexCommand.Options options) {

var command = new DropIndexCommand(name, options);
return mcpResource.processCommand(mcpResource.buildKeyspaceContext(keyspace, command), command);
return mcpResource.processCommand(
() -> mcpResource.buildKeyspaceContext(keyspace, command), command);
}

@Tool(description = "Command that drops a table if one exists.")
Expand All @@ -136,7 +143,8 @@ public Uni<ToolResponse> dropTable(
DropTableCommand.Options options) {

var command = new DropTableCommand(table, options);
return mcpResource.processCommand(mcpResource.buildKeyspaceContext(keyspace, command), command);
return mcpResource.processCommand(
() -> mcpResource.buildKeyspaceContext(keyspace, command), command);
}

@Tool(description = "Command that drops a user defined type if one exists.")
Expand All @@ -149,7 +157,8 @@ public Uni<ToolResponse> dropType(
DropTypeCommand.Options options) {

var command = new DropTypeCommand(name, options);
return mcpResource.processCommand(mcpResource.buildKeyspaceContext(keyspace, command), command);
return mcpResource.processCommand(
() -> mcpResource.buildKeyspaceContext(keyspace, command), command);
}

@Tool(description = "Command that lists all available tables in a keyspace")
Expand All @@ -161,7 +170,8 @@ public Uni<ToolResponse> listTables(
ListTablesCommand.Options options) {

var command = new ListTablesCommand(options);
return mcpResource.processCommand(mcpResource.buildKeyspaceContext(keyspace, command), command);
return mcpResource.processCommand(
() -> mcpResource.buildKeyspaceContext(keyspace, command), command);
}

@Tool(description = "Command that lists all available types in a keyspace")
Expand All @@ -173,6 +183,7 @@ public Uni<ToolResponse> listTypes(
ListTypesCommand.Options options) {

var command = new ListTypesCommand(options);
return mcpResource.processCommand(mcpResource.buildKeyspaceContext(keyspace, command), command);
return mcpResource.processCommand(
() -> mcpResource.buildKeyspaceContext(keyspace, command), command);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package io.stargate.sgv2.jsonapi.api.v1.mcp;

import io.quarkus.runtime.annotations.StaticInitSafe;
import io.smallrye.config.ConfigSourceContext;
import io.smallrye.config.ConfigSourceFactory;
import io.smallrye.config.PropertiesConfigSource;
import java.net.URI;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import org.eclipse.microprofile.config.spi.ConfigSource;

/** Shares the normalized MCP endpoint with request logging and Quarkus access logging. */
@StaticInitSafe
public class McpEndpointConfigSourceFactory implements ConfigSourceFactory {
static final String ENDPOINT_PATH = "stargate.jsonapi.mcp.endpoint-path";

@Override
public Iterable<ConfigSource> getConfigSources(ConfigSourceContext context) {
String mcpRoot = context.getValue("quarkus.mcp.server.http.root-path").getValue();
if (mcpRoot == null) {
// Quarkus also builds bootstrap configurations before application configuration is loaded.
return List.of();
}
String httpRoot = context.getValue("quarkus.http.root-path").getValue();
httpRoot = httpRoot == null ? "/" : httpRoot.replace("//", "/");
httpRoot = httpRoot.endsWith("/") ? httpRoot : httpRoot + "/";
mcpRoot = mcpRoot.startsWith("/") ? mcpRoot.substring(1) : mcpRoot;
// Match Quarkus route normalization, including its treatment of repeated leading slashes.
URI mcpUri = URI.create(mcpRoot.replace("//", "/")).normalize();
if (mcpUri.getPath().isEmpty()) {
mcpUri = URI.create("");
}
String endpointPath = URI.create(httpRoot).resolve(mcpUri).normalize().getPath();
if (endpointPath.endsWith("/") && endpointPath.length() > 1) {
endpointPath = endpointPath.substring(0, endpointPath.length() - 1);
}
String excludePattern =
Pattern.quote(endpointPath) + (endpointPath.equals("/") ? ".*" : "(/.*)?");
// A config source is needed because Quarkus creates its access logger before CDI startup.
// The default source ordinal leaves explicit operator exclusions at their normal precedence.
return List.of(
new PropertiesConfigSource(
Map.of(
ENDPOINT_PATH,
endpointPath,
"quarkus.http.access-log.exclude-pattern",
excludePattern),
"MCP endpoint"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package io.stargate.sgv2.jsonapi.api.v1.mcp;

import io.quarkus.logging.LoggingFilter;
import jakarta.inject.Inject;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Optional;
import java.util.logging.Filter;
import java.util.logging.LogRecord;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logmanager.ExtLogRecord;

/**
* Keeps free-form messages, parameters, exception messages and context out of MCP logs. Applying
* this at the handler also covers framework parsing errors and asynchronous command failures.
*/
@LoggingFilter(name = "mcp-safe-logging")
public final class McpLoggingFilter implements Filter {
private static final String SAFE_LOGGER = McpRequestLogging.class.getName();
static final String EXCEPTION_TYPE = "mcpExceptionType";
private final Instant diagnosticsUntil;
private final Clock clock;

@Inject
public McpLoggingFilter(
@ConfigProperty(name = "stargate.jsonapi.mcp.diagnostics-until")
Optional<String> diagnosticsUntil) {
this(diagnosticsUntil, Clock.systemUTC());
}

McpLoggingFilter(Optional<String> diagnosticsUntil, Clock clock) {
this.clock = clock;
try {
this.diagnosticsUntil = diagnosticsUntil.map(Instant::parse).orElse(Instant.MIN);
} catch (DateTimeParseException e) {
// Do not include an invalid configuration value in an error message.
throw new IllegalArgumentException("MCP diagnostics-until must be an ISO-8601 timestamp");
}
if (this.diagnosticsUntil.isAfter(clock.instant().plus(Duration.ofMinutes(15)))) {
throw new IllegalArgumentException("MCP diagnostics may be enabled for at most 15 minutes");
}
}

@Override
public boolean isLoggable(LogRecord record) {
String logger = record.getLoggerName();
// Raw traffic logging must stay off even if an operator enables the framework setting.
if ("io.quarkus.mcp.server.traffic".equals(logger)) {
return false;
}
var extended = record instanceof ExtLogRecord r ? r : null;
String requestId = extended == null ? null : extended.getMdc(McpRequestLogging.REQUEST_ID);
boolean framework = logger != null && logger.startsWith("io.quarkiverse.mcp.server.");
if ((!framework && requestId == null) || "billing.events".equals(logger)) {
return true;
}

Throwable failure = record.getThrown();
// A record can pass through this filter on multiple handlers. Preserve the original type
// after the first handler has removed or replaced the throwable.
String exceptionType = extended == null ? null : extended.getMdc(EXCEPTION_TYPE);
if (exceptionType == null && failure != null) {
exceptionType = failure.getClass().getName();
}

// MDC/NDC may include user-controlled tenant, collection and other input values.
if (extended != null) {
var safeContext = new HashMap<String, String>();
if (requestId != null) {
safeContext.put(McpRequestLogging.REQUEST_ID, requestId);
}
if (exceptionType != null) {
safeContext.put(EXCEPTION_TYPE, exceptionType);
}
extended.setMdc(safeContext);
extended.setNdc("");
extended.setMarker(null);
}
if (SAFE_LOGGER.equals(logger)) {
return true;
}

record.setMessage(
"MCP event details redacted"
+ (exceptionType == null ? "" : "; exception=" + exceptionType));
record.setParameters(null);
record.setResourceBundle(null);
record.setResourceBundleName(null);
record.setThrown(null);
if (failure != null && clock.instant().isBefore(diagnosticsUntil)) {
// Preserve stack locations without copying messages, causes or suppressed exceptions.
Throwable safeFailure = new Throwable("MCP diagnostic stack (details redacted)");
safeFailure.setStackTrace(
Arrays.stream(failure.getStackTrace()).limit(32).toArray(StackTraceElement[]::new));
record.setThrown(safeFailure);
}
return true;
}
}
Loading
Loading