Skip to content
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Progress notifications for long-running tool calls. While an HTTP API call is in progress, the server sends periodic
`notifications/progress` messages to MCP clients that request them (via `_meta.progressToken`). Only works with
stateful MCP servers. You can disable it by setting `infobip.openapi.mcp.progress-notifications-enabled: false`.
Notification content can be customized by implementing a Spring bean of type `ProgressUpdateProvider`.

### Changed

- `McpRequestContext` now stores the MCP server exchange and tool request directly, deriving session
ID, client info, tool name, and the progress notification consumer on demand via accessor methods.

## 0.1.13

### Changed
Expand Down
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ sdk use java <identifier>
behind it. It is fine to mention configuration properties needed to enable or customize a feature, or java interfaces
that users can implement such as `OpenApiFilter` or `ApiRequestEnricher`. Avoid class names, method names, test names,
and other implementation details.
**Before adding a new sub-section** (`### Changed`, `### Fixed`, etc.) to `[Unreleased]`, ask: has any part of the
thing being documented ever appeared in a released version? If not, it all belongs under `### Added` — update or
extend the existing bullet rather than creating a new sub-section. The `Changed`/`Fixed`/`Removed` categories are
meaningful only relative to a released version, not between commits on an unreleased branch.
- If you added or changed an external configuration property, add or update its row in the properties table in
`README.md`.
- Check and update `CLAUDE.md` to reflect the new state of the project.
Expand Down Expand Up @@ -96,6 +100,7 @@ HTTP call via `ToolHandler`) → optional `JsonDoubleSerializationCorrector` ret
| `NamingStrategy` | Custom tool name generation; replace the default bean |
| `ErrorModelProvider` | Custom error response format returned to MCP clients |
| `CredentialProvider` | Supply credentials from any source (HTTP header, vault, env, etc.); replace the default bean |
| `ProgressUpdateProvider` | Controls the `progress`, `total`, and `message` fields of each `notifications/progress` message; replace the default bean |

Important: filters, enrichers, strategies and providers can be implemented by application code, which is outside the
framework. You will not see those implementations in this project's source code. This is the supported way to extend and
Expand Down Expand Up @@ -153,6 +158,9 @@ extension on the Operation and from YAML config properties (`infobip.openapi.mcp
the class contains any method that builds strings conditionally or in a loop; prefer `+` concatenation in classes
where all string building is simple and unconditional. Assigning a plain variable (`x = someString`) does not count as
string manipulation and does not influence the choice.
- **Markdown links in `README.md`**: Use reference-style links. Place the full URL at the bottom of the file as a
numbered entry (e.g., `[16]: https://... "Title"`), and reference it in the body as `[link text][16]`. Never embed
raw URLs inline in `README.md` prose.

## Testing Conventions

Expand Down
139 changes: 95 additions & 44 deletions README.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,35 +1,38 @@
package com.infobip.openapi.mcp;

import com.infobip.openapi.mcp.enricher.ApiRequestEnricher;
import com.infobip.openapi.mcp.openapi.tool.FullOperation;
import io.modelcontextprotocol.server.McpAsyncServerExchange;
import io.modelcontextprotocol.server.McpSyncServerExchange;
import io.modelcontextprotocol.spec.McpSchema;
import jakarta.servlet.http.HttpServletRequest;
import java.util.function.Consumer;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;

/**
* Context object that encapsulates request-level information for MCP operations.
* <p>
* This record provides access to both HTTP servlet request context (headers, IP addresses)
* and MCP transport-specific metadata (session IDs, client information). The context is
* created at the transport layer using {@link McpRequestContextFactory} and passed explicitly
* through the call chain to enrichers and handlers.
* </p>
* This record provides access to the HTTP servlet request, the MCP tool request, the MCP
* server exchange (for stateful transports), and the OpenAPI operation that backs the tool.
* It is created at the transport layer using {@link McpRequestContextFactory} and passed
* explicitly through the call chain to enrichers and handlers.
*
* @param httpServletRequest the HTTP servlet request, or null if not available
* @param sessionId the MCP session ID for stateful transports, or null
* @param clientInfo the MCP client implementation information, or null
* @param toolName the MCP tool name being invoked, or null if not in tool invocation context
* @param openApiOperation the set of information from OpenAPI specification that
* defines the API endpoint backing this tool
* @see com.infobip.openapi.mcp.enricher.ApiRequestEnricher
* @param httpServletRequest the HTTP servlet request, or null if not available
* @param callToolRequest the MCP tool invocation request, or null outside tool invocation context
* @param asyncServerExchange the async MCP server exchange for async transports, or null
* @param syncServerExchange the sync MCP server exchange for sync transports, or null
* @param openApiOperation the set of information from OpenAPI specification that
* defines the API endpoint backing this tool
* @see ApiRequestEnricher
* @see McpRequestContextFactory
*/
@NullMarked
public record McpRequestContext(
@Nullable HttpServletRequest httpServletRequest,
@Nullable String sessionId,
McpSchema.@Nullable Implementation clientInfo,
@Nullable String toolName,
McpSchema.@Nullable CallToolRequest callToolRequest,
@Nullable McpAsyncServerExchange asyncServerExchange,
@Nullable McpSyncServerExchange syncServerExchange,
@Nullable FullOperation openApiOperation) {
public McpRequestContext() {
this(null, null, null, null, null);
Expand All @@ -38,4 +41,26 @@ public McpRequestContext() {
public McpRequestContext(HttpServletRequest httpServletRequest) {
this(httpServletRequest, null, null, null, null);
}

public @Nullable String sessionId() {
return asyncServerExchange != null
? asyncServerExchange.sessionId()
: syncServerExchange != null ? syncServerExchange.sessionId() : null;
}

public McpSchema.@Nullable Implementation clientInfo() {
return asyncServerExchange != null
? asyncServerExchange.getClientInfo()
: syncServerExchange != null ? syncServerExchange.getClientInfo() : null;
}

public @Nullable String toolName() {
return callToolRequest != null ? callToolRequest.name() : null;
}

public @Nullable Consumer<McpSchema.ProgressNotification> progressNotification() {
return asyncServerExchange != null
? asyncServerExchange::progressNotification
: syncServerExchange != null ? syncServerExchange::progressNotification : null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.infobip.openapi.mcp.openapi.tool.FullOperation;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.server.McpSyncServerExchange;
import io.modelcontextprotocol.spec.McpSchema;
import jakarta.servlet.http.HttpServletRequest;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
Expand Down Expand Up @@ -33,45 +34,37 @@ public class McpRequestContextFactory {

/**
* Creates an MCP request context for stateful transport protocols (SSE, Streamable, Stdio).
* <p>
* Stateful transports maintain a persistent connection/session with the client, so this
* factory method extracts session ID and client information from the exchange using reflection.
* The exchange parameter is typed as Object to avoid compile-time dependencies on specific
* MCP transport types.
* </p>
*
* @param exchange the MCP server exchange containing session information
* @param toolName the name of the MCP tool being invoked, or null if not in tool invocation context
* @param exchange the MCP server exchange for the current session
* @param toolRequest the MCP tool invocation request
* @param fullOperation the set of information from OpenAPI specification that
* defines the API endpoint backing this tool
* @return a new context instance with stateful transport metadata and tool name
* @return a new context instance with the exchange and tool request stored for later use
*/
public McpRequestContext forStatefulTransport(
McpSyncServerExchange exchange, @Nullable String toolName, FullOperation fullOperation) {
var httpServletRequest = getCurrentHttpServletRequest();
var sessionId = exchange.sessionId();
var clientInfo = exchange.getClientInfo();
return new McpRequestContext(httpServletRequest, sessionId, clientInfo, toolName, fullOperation);
McpSyncServerExchange exchange, McpSchema.CallToolRequest toolRequest, FullOperation fullOperation) {
return new McpRequestContext(getCurrentHttpServletRequest(), toolRequest, null, exchange, fullOperation);
}

/**
* Creates an MCP request context for stateless transport protocol (HTTP).
* <p>
* Stateless transports don't maintain persistent sessions, so session ID and client
* information are not available.
* Stateless transports don't maintain a persistent server exchange, so session ID, client
* info, and progress notifications are not available.
* </p>
*
* @param transportContext the MCP transport context (may be null, currently unused)
* @param toolName the name of the MCP tool being invoked, or null if not in tool invocation context
* @param toolRequest the MCP tool invocation request
* @param fullOperation the set of information from OpenAPI specification that
* defines the API endpoint backing this tool
* @return a new context instance without session metadata but with tool name
* @return a new context instance without an exchange but with the tool request
*/
public McpRequestContext forStatelessTransport(
@Nullable McpTransportContext transportContext, @Nullable String toolName, FullOperation fullOperation) {
var httpServletRequest = getCurrentHttpServletRequest();
// Stateless transport doesn't have session or client info (yet)
return new McpRequestContext(httpServletRequest, null, null, toolName, fullOperation);
@Nullable McpTransportContext transportContext,
McpSchema.CallToolRequest toolRequest,
FullOperation fullOperation) {
// Stateless transport doesn't have a persistent server exchange
return new McpRequestContext(getCurrentHttpServletRequest(), toolRequest, null, null, fullOperation);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,28 @@
/**
* Configuration properties for OpenAPI MCP Server.
*
* @param openApiUrl URL to the OpenAPI specification. This should point to a valid OpenAPI document (e.g., JSON or YAML).
* @param apiBaseUrl Base URL for the API. Supports three formats:
* - String URL: Use the provided URL directly (e.g., "https://api.example.com")
* - Integer: Use the i-th server from OpenAPI servers array, 0-indexed (e.g., "0", "1")
* - Empty/null: Use the first server from OpenAPI servers array (default behavior)
* @param connectTimeout Connection timeout for HTTP requests to the downstream API. The default is set to 5 seconds.
* @param readTimeout Read timeout for HTTP requests to the downstream API. The default is set to 5 seconds.
* @param userAgent User agent string for HTTP requests to the downstream API. If not specified, no User-Agent header will be set.
* @param filters Filters to apply to the OpenAPI specification. This can be used to include or exclude specific operations or tags.
* The keys are the filter names, and the values are booleans indicating whether to include (true) or exclude (false).
* By default, all filters are enabled.
* @param tools Tool configuration.
* @param liveReload Live reload configuration for automatic OpenAPI spec refresh.
* @param openApiUrl URL to the OpenAPI specification. This should point to a valid OpenAPI document (e.g., JSON or YAML).
* @param apiBaseUrl Base URL for the API. Supports three formats:
* - String URL: Use the provided URL directly (e.g., "https://api.example.com")
* - Integer: Use the i-th server from OpenAPI servers array, 0-indexed (e.g., "0", "1")
* - Empty/null: Use the first server from OpenAPI servers array (default behavior)
* @param connectTimeout Connection timeout for HTTP requests to the downstream API. The default is set to 5 seconds.
* @param readTimeout Read timeout for HTTP requests to the downstream API. The default is set to 5 seconds.
* @param progressNotificationsEnabled Whether progress notifications are enabled. When enabled, the server sends
* periodic progress notifications to MCP clients while HTTP API calls are in
* progress. Default is true.
* @param progressNotificationsInterval Interval at which notifications/progress messages will be sent to MCP clients
* that request progress notifications. Progress is reported while HTTP API call is
* ongoing. This mechanism can be used to prevent MCP client timeouts for tools
* backed by APIs with high response latency. This value should be less than
* readTimeout, otherwise no notification will be sent. Only relevant when
* progressNotificationsEnabled is true.
* @param userAgent User agent string for HTTP requests to the downstream API. If not specified, no User-Agent header will be set.
* @param filters Filters to apply to the OpenAPI specification. This can be used to include or exclude specific operations or tags.
* The keys are the filter names, and the values are booleans indicating whether to include (true) or exclude (false).
* By default, all filters are enabled.
* @param tools Tool configuration.
* @param liveReload Live reload configuration for automatic OpenAPI spec refresh.
*/
@Validated
@ConfigurationProperties(prefix = OpenApiMcpProperties.PREFIX)
Expand All @@ -39,6 +48,8 @@ public record OpenApiMcpProperties(
String apiBaseUrl,
Duration connectTimeout,
Duration readTimeout,
Boolean progressNotificationsEnabled,
Duration progressNotificationsInterval,
String userAgent,
Map<String, Boolean> filters,
@NestedConfigurationProperty @Valid Tools tools,
Expand All @@ -47,6 +58,8 @@ public record OpenApiMcpProperties(
public static final String PREFIX = "infobip.openapi.mcp";
public static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(5);
public static final Duration DEFAULT_READ_TIMEOUT = Duration.ofSeconds(5);
public static final boolean DEFAULT_PROGRESS_NOTIFICATIONS_ENABLED = true;
public static final Duration DEFAULT_PROGRESS_NOTIFICATIONS_INTERVAL = Duration.ofSeconds(1);
public static final String DEFAULT_USER_AGENT = "openapi-mcp";

/**
Expand All @@ -59,6 +72,12 @@ public record OpenApiMcpProperties(
if (readTimeout == null) {
readTimeout = DEFAULT_READ_TIMEOUT;
}
if (progressNotificationsEnabled == null) {
progressNotificationsEnabled = DEFAULT_PROGRESS_NOTIFICATIONS_ENABLED;
}
if (progressNotificationsInterval == null) {
progressNotificationsInterval = DEFAULT_PROGRESS_NOTIFICATIONS_INTERVAL;
}
if (userAgent == null) {
userAgent = DEFAULT_USER_AGENT;
}
Expand All @@ -79,7 +98,7 @@ public record OpenApiMcpProperties(
* @return a new OpenApiMcpProperties instance with defaults
*/
public static OpenApiMcpProperties withDefaults() {
return new OpenApiMcpProperties(null, null, null, null, null, null, null, null);
return new OpenApiMcpProperties(null, null, null, null, null, null, null, null, null, null);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.infobip.openapi.mcp.openapi.tool;

import org.jspecify.annotations.NullMarked;

/**
* Controls the pause between successive progress notification ticks while an HTTP API call is in flight.
*
* <p>The production implementation sleeps for the configured interval. Tests can inject an alternative
* to make tick firing deterministic without relying on wall-clock time.
*/
@NullMarked
@FunctionalInterface
interface NotificationTicker {

/**
* Blocks until the next tick should fire.
*
* @throws InterruptedException if the calling thread is interrupted, signalling the notification
* loop to stop
*/
void tick() throws InterruptedException;
}
Loading