Using Filters and Initial Checks
This guide explains how to use filters and initial checks to control feature visibility and validate connections in your MCP server.
Overview
Filters and initial checks provide two complementary mechanisms for access control:
-
Filters - Control which features (tools, resources, prompts) are visible to specific clients
-
Initial Checks - Validate connections during the initialization handshake before exposing capabilities
Together, they enable fine-grained control over feature availability based on authentication, authorization, client capabilities, or any other criteria.
Using Filters
Filters control which MCP features are visible and accessible to specific clients. They enable fine-grained control based on connection context, client capabilities, request headers, or any other criteria.
Implementing a Filter
Filters are CDI beans that implement one or more filter interfaces:
-
ToolFilter- Controls tool visibility -
ResourceFilter- Controls resource visibility -
ResourceTemplateFilter- Controls resource template visibility -
PromptFilter- Controls prompt visibility
Each filter implements a test() method that returns true if the feature should be accessible, false otherwise.
Here’s a simple filter that only shows tools to clients that support sampling:
import jakarta.inject.Singleton;
import io.quarkiverse.mcp.server.ToolFilter;
import io.quarkiverse.mcp.server.ToolManager.ToolInfo;
import io.quarkiverse.mcp.server.FilterContext;
@Singleton
public class SamplingToolFilter implements ToolFilter {
@Override
public boolean test(ToolInfo tool, FilterContext context) {
// Only show tools if client supports sampling
return context.connection().initialRequest().supportsSampling();
}
}
How Filters Work
Understanding filter behavior:
-
Filters are automatically discovered as CDI beans
-
Multiple filters execute in order of
@Priority(higher values execute first) -
A feature is only visible if ALL filters return
true(logical AND) -
Filters should be lightweight as they execute on the event loop (no blocking operations)
-
If a filter throws an exception, it’s ignored and the next filter executes
For API details on filter interfaces and context objects, see Filters and Checks Reference.
Controlling Execution Order
Use @Priority to control filter execution order when you have multiple filters:
@Singleton
@Priority(1000) // Higher priority - executes first
public class SecurityFilter implements ToolFilter {
@Override
public boolean test(ToolInfo tool, FilterContext context) {
// Security check: block admin tools for non-admin clients
if (tool.name().startsWith("admin_")) {
return isAdmin(context.connection());
}
return true;
}
private boolean isAdmin(McpConnection connection) {
// Check authorization (implementation specific)
return false;
}
}
@Singleton
@Priority(500) // Lower priority - executes second
public class FeatureFlagFilter implements ToolFilter {
@Override
public boolean test(ToolInfo tool, FilterContext context) {
// Only show beta features if enabled
if (tool.name().startsWith("beta_")) {
return isBetaEnabled(context.connection());
}
return true;
}
private boolean isBetaEnabled(McpConnection connection) {
// Check feature flag
return false;
}
}
In this example, SecurityFilter executes first. If it returns false, the feature is blocked regardless of what FeatureFlagFilter would return.
Request Context Filtering
Filters can access request-scoped beans to make decisions based on HTTP headers, authentication, or other request context:
@Singleton
public class HeaderBasedFilter implements ResourceFilter {
@Inject
HttpServerRequest request; // Available for HTTP transports
@Override
public boolean test(ResourceInfo resource, FilterContext context) {
// Only show sensitive resources if authenticated
if (resource.uri().contains("/sensitive/")) {
String authHeader = request.getHeader("Authorization");
return authHeader != null && isValidToken(authHeader);
}
return true;
}
private boolean isValidToken(String token) {
// Validate authentication token
return false;
}
}
Request-scoped dependencies like HttpServerRequest are only available for HTTP-based transports (SSE, Streamable, WebSocket), not for STDIO transport.
|
Using Initial Checks
Initial checks are validations performed during the MCP connection initialization handshake, before the server sends its capabilities back to the client. They provide a way to reject connections that don’t meet certain requirements.
Implementing an Initial Check
Initial checks are CDI beans that implement the io.quarkiverse.mcp.server.InitialCheck interface.
Here’s a simple check that requires clients to support sampling:
import jakarta.inject.Singleton;
import io.quarkiverse.mcp.server.InitialCheck;
import io.quarkiverse.mcp.server.InitialRequest;
import io.smallrye.mutiny.Uni;
@Singleton
public class SamplingRequiredCheck implements InitialCheck {
@Override
public Uni<CheckResult> perform(InitialRequest initialRequest) {
if (!initialRequest.supportsSampling()) {
return CheckResult.error("This server requires sampling support");
}
return CheckResult.success();
}
}
How Initial Checks Work
Understanding check behavior:
-
Executed during the
initializerequest, before capabilities are sent -
Failed checks prevent the connection from being established
-
Multiple checks execute sequentially by
@Priority(higher values first) -
Returns
Uni<CheckResult>for async validation -
First failed check stops execution and returns error to client
When a check fails:
-
The connection is not initialized
-
The error message is sent back to the client
-
No
initializednotification is received -
No server capabilities are exposed
For API details, see InitialCheck Interface.
Authentication Check
Validate authentication before allowing connections:
@Singleton
public class AuthenticationCheck implements InitialCheck {
@Inject
HttpServerRequest request; // For HTTP transports
@Override
public Uni<CheckResult> perform(InitialRequest initialRequest) {
String authHeader = request.getHeader("Authorization");
if (authHeader == null) {
return CheckResult.error("Missing Authorization header");
}
if (!isValidToken(authHeader)) {
return CheckResult.error("Invalid authentication token");
}
return CheckResult.success();
}
private boolean isValidToken(String token) {
// Validate token (implementation specific)
return token.startsWith("Bearer ");
}
}
Async/Blocking Checks
For checks that need to perform I/O or blocking operations, use io.quarkus.vertx.VertxContextSupport:
@Singleton
public class DatabaseAuthCheck implements InitialCheck {
@Inject
VertxContextSupport contextSupport;
@Inject
UserDatabase userDatabase;
@Override
public Uni<CheckResult> perform(InitialRequest initialRequest) {
String clientId = extractClientId(initialRequest);
// Offload blocking database call to worker thread
return contextSupport.executeBlocking(() -> {
boolean isAuthorized = userDatabase.isAuthorized(clientId);
return isAuthorized
? CheckResult.SUCCESS
: new CheckResult(true, "Client not authorized");
});
}
private String extractClientId(InitialRequest request) {
return request.clientInfo().name();
}
}
Priority with Multiple Checks
Use @Priority to control the order of check execution:
@Singleton
@Priority(1000) // Execute first
public class RateLimitCheck implements InitialCheck {
@Inject
RateLimiter rateLimiter;
@Override
public Uni<CheckResult> perform(InitialRequest initialRequest) {
String clientId = initialRequest.clientInfo().name();
if (rateLimiter.isExceeded(clientId)) {
return CheckResult.error("Rate limit exceeded");
}
return CheckResult.success();
}
}
@Singleton
@Priority(500) // Execute second (if first succeeds)
public class CapabilityCheck implements InitialCheck {
@Override
public Uni<CheckResult> perform(InitialRequest initialRequest) {
// Require at least one client capability
if (!initialRequest.supportsSampling() &&
!initialRequest.supportsElicitation()) {
return CheckResult.error(
"Client must support sampling or elicitation");
}
return CheckResult.success();
}
}
In this example:
-
RateLimitCheckexecutes first (priority 1000) -
If rate limit is exceeded, connection fails immediately
-
CapabilityCheckonly executes if rate limit check passes -
Both must succeed for the connection to be established
Common Use Cases
This section provides practical examples of how to use filters and initial checks to solve common requirements.
Multi-Tenant Feature Isolation
Different clients see different features based on their tenant:
@Singleton
public class TenantFilter implements ToolFilter, ResourceFilter {
@Inject
HttpServerRequest request;
@Override
public boolean test(ToolInfo tool, FilterContext context) {
String tenant = request.getHeader("X-Tenant-ID");
// Admin tools only for admin tenant
if (tool.name().startsWith("admin_")) {
return "admin".equals(tenant);
}
// Premium tools for premium tenants
if (tool.name().startsWith("premium_")) {
return isPremiumTenant(tenant);
}
// Standard tools for everyone
return true;
}
@Override
public boolean test(ResourceInfo resource, FilterContext context) {
String tenant = request.getHeader("X-Tenant-ID");
// Each tenant only sees their own resources
return resource.uri().contains("/" + tenant + "/");
}
private boolean isPremiumTenant(String tenant) {
// Check tenant subscription level
return false;
}
}
Role-Based Access Control (RBAC)
Control feature visibility based on user roles:
@Singleton
public class RoleBasedFilter implements ToolFilter {
@Inject
SecurityIdentity securityIdentity; // Quarkus security
@Override
public boolean test(ToolInfo tool, FilterContext context) {
// Read-only tools for everyone
if (tool.name().startsWith("read_")) {
return true;
}
// Write tools require writer role
if (tool.name().startsWith("write_")) {
return securityIdentity.hasRole("writer");
}
// Delete tools require admin role
if (tool.name().startsWith("delete_")) {
return securityIdentity.hasRole("admin");
}
return true;
}
}
Feature Flags and A/B Testing
Show experimental features to selected clients:
@Singleton
public class FeatureFlagFilter implements ToolFilter, PromptFilter {
@Inject
FeatureFlagService featureFlags;
@Override
public boolean test(ToolInfo tool, FilterContext context) {
String clientId = context.connection().initialRequest().clientInfo().name();
// Beta features only if flag enabled for this client
if (tool.name().startsWith("beta_")) {
return featureFlags.isEnabled("beta-tools", clientId);
}
// Experimental features for internal testing
if (tool.name().startsWith("experimental_")) {
return featureFlags.isEnabled("experimental", clientId);
}
return true;
}
@Override
public boolean test(PromptInfo prompt, FilterContext context) {
String clientId = context.connection().initialRequest().clientInfo().name();
// A/B test: show new prompts to 50% of users
if (prompt.name().startsWith("new_")) {
return featureFlags.isInVariant("new-prompts", clientId, "B");
}
return true;
}
}
Connection Quota and Rate Limiting
Limit connections using initial checks:
@Singleton
@Priority(2000) // Check this first
public class ConnectionQuotaCheck implements InitialCheck {
@Inject
ConnectionTracker connectionTracker;
@Override
public Uni<CheckResult> perform(InitialRequest initialRequest) {
String clientId = initialRequest.clientInfo().name();
// Check concurrent connections
int activeConnections = connectionTracker.getActiveConnections(clientId);
if (activeConnections >= getMaxConnections(clientId)) {
return CheckResult.error(
"Maximum concurrent connections reached: " + activeConnections);
}
// Track this connection
connectionTracker.addConnection(clientId);
return CheckResult.success();
}
private int getMaxConnections(String clientId) {
// Different limits per client
return 10;
}
}
Protocol Version Enforcement
Require specific protocol versions or client capabilities:
@Singleton
@Priority(1500)
public class ProtocolVersionCheck implements InitialCheck {
private static final String MINIMUM_VERSION = "2025-11-25";
private static final Set<String> SUPPORTED_VERSIONS =
Set.of("2025-11-25", "2025-03-26");
@Override
public Uni<CheckResult> perform(InitialRequest initialRequest) {
String version = initialRequest.protocolVersion();
// Check if version is supported
if (!SUPPORTED_VERSIONS.contains(version)) {
return CheckResult.error(
"Unsupported protocol version: " + version +
". Minimum required: " + MINIMUM_VERSION);
}
// Require sampling for this server
if (!initialRequest.supportsSampling()) {
return CheckResult.error(
"This server requires sampling capability");
}
return CheckResult.success();
}
}
Environment-Based Filtering
Show different features in different environments:
@Singleton
public class EnvironmentFilter implements ToolFilter {
@ConfigProperty(name = "quarkus.profile")
String profile;
@Override
public boolean test(ToolInfo tool, FilterContext context) {
// Debug tools only in dev environment
if (tool.name().startsWith("debug_")) {
return "dev".equals(profile);
}
// Mock tools only in test environment
if (tool.name().startsWith("mock_")) {
return "test".equals(profile);
}
// Production tools
return true;
}
}
Combining Filters and Checks
Use both for comprehensive access control:
// Initial check validates authentication
@Singleton
@Priority(2000)
public class AuthCheck implements InitialCheck {
@Override
public Uni<CheckResult> perform(InitialRequest initialRequest) {
// Validate token, check license, etc.
return CheckResult.success();
}
}
// Filter controls feature visibility based on subscription
@Singleton
public class SubscriptionFilter implements ToolFilter {
@Inject
SubscriptionService subscriptionService;
@Override
public boolean test(ToolInfo tool, FilterContext context) {
String clientId = context.connection().initialRequest().clientInfo().name();
String tier = subscriptionService.getTier(clientId);
// Basic tier: limited tools
if ("basic".equals(tier)) {
return tool.name().startsWith("basic_");
}
// Pro tier: all tools
return true;
}
}
This combination ensures:
-
Only authenticated clients can connect (initial check)
-
Connected clients only see features their subscription allows (filter)
Related Documentation
-
Filters and Checks Reference - API reference for interfaces and context objects