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
1 change: 1 addition & 0 deletions sentinel-adapter/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
<module>sentinel-zuul2-adapter</module>
<module>sentinel-okhttp-adapter</module>
<module>sentinel-spring-restclient-adapter</module>
<module>sentinel-spring-ai-mcp-adapter</module>
<module>sentinel-jax-rs-adapter</module>
<module>sentinel-quarkus-adapter</module>
<module>sentinel-motan-adapter</module>
Expand Down
22 changes: 22 additions & 0 deletions sentinel-adapter/sentinel-spring-ai-mcp-adapter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Sentinel Spring AI MCP Adapter

This module adds tool-level Sentinel protection to MCP tools exposed by Spring AI.
It decorates MCP tool specifications instead of intercepting an HTTP transport, so the
same resource works with stdio, SSE, Streamable HTTP, WebMVC, and WebFlux servers.

The default resource name is `mcp:tool:<tool-name>`. Stateful and stateless tool
specifications are supported in both synchronous and asynchronous forms. Asynchronous
entries remain active until completion, error, or cancellation.

```java
SyncToolSpecification protectedTool =
SentinelMcpToolSpecificationDecorator.decorate(toolSpecification);
```

```java
AsyncToolSpecification protectedTool =
SentinelMcpToolSpecificationDecorator.decorate(toolSpecification);
```

The build baseline is Spring AI 2.0.1 and MCP Java SDK 2.0.0. The module requires
Java 17.
65 changes: 65 additions & 0 deletions sentinel-adapter/sentinel-spring-ai-mcp-adapter/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-adapter</artifactId>
<version>${revision}</version>
<relativePath>../pom.xml</relativePath>
</parent>

<artifactId>sentinel-spring-ai-mcp-adapter</artifactId>
<name>${project.groupId}:${project.artifactId}</name>

<properties>
<java.source.version>17</java.source.version>
<java.target.version>17</java.target.version>
<spring-ai.version>2.0.1</spring-ai.version>
<reactor.version>3.7.12</reactor.version>
</properties>

<dependencies>
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-mcp</artifactId>
<version>${spring-ai.version}</version>
<scope>provided</scope>
</dependency>

<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-inline</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<version>${reactor.version}</version>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<release>17</release>
</configuration>
</plugin>
</plugins>
</build>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
/*
* Copyright 1999-2026 Alibaba Group Holding Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.alibaba.csp.sentinel.adapter.spring.ai.mcp;

import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;

import com.alibaba.csp.sentinel.AsyncEntry;
import com.alibaba.csp.sentinel.Entry;
import com.alibaba.csp.sentinel.EntryType;
import com.alibaba.csp.sentinel.ResourceTypeConstants;
import com.alibaba.csp.sentinel.SphU;
import com.alibaba.csp.sentinel.Tracer;
import com.alibaba.csp.sentinel.slots.block.BlockException;

import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolSpecification;
import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification;
import io.modelcontextprotocol.server.McpStatelessServerFeatures;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import reactor.core.publisher.Mono;

/**
* Decorates Spring AI MCP tool specifications with tool-level Sentinel protection.
*
* @author chengliyao
*/
public final class SentinelMcpToolSpecificationDecorator {

public static final String DEFAULT_RESOURCE_PREFIX = "mcp:tool:";

private static final InvocationFactory DEFAULT_INVOCATION_FACTORY = new SentinelInvocationFactory();

/**
* Decorates a synchronous MCP tool specification.
*
* @param specification original tool specification
* @return protected tool specification
*/
public static SyncToolSpecification decorate(SyncToolSpecification specification) {
return decorate(specification, DEFAULT_INVOCATION_FACTORY);
}

/**
* Decorates an asynchronous MCP tool specification.
*
* @param specification original tool specification
* @return protected tool specification
*/
public static AsyncToolSpecification decorate(AsyncToolSpecification specification) {
return decorate(specification, DEFAULT_INVOCATION_FACTORY);
}

/**
* Decorates a synchronous stateless MCP tool specification.
*
* @param specification original tool specification
* @return protected tool specification
*/
public static McpStatelessServerFeatures.SyncToolSpecification decorate(
McpStatelessServerFeatures.SyncToolSpecification specification) {
return decorate(specification, DEFAULT_INVOCATION_FACTORY);
}

/**
* Decorates an asynchronous stateless MCP tool specification.
*
* @param specification original tool specification
* @return protected tool specification
*/
public static McpStatelessServerFeatures.AsyncToolSpecification decorate(
McpStatelessServerFeatures.AsyncToolSpecification specification) {
return decorate(specification, DEFAULT_INVOCATION_FACTORY);
}

static SyncToolSpecification decorate(SyncToolSpecification specification, InvocationFactory factory) {
Objects.requireNonNull(specification, "specification");
Objects.requireNonNull(factory, "factory");
String resourceName = resourceName(specification.tool().name());
return SyncToolSpecification.builder().tool(specification.tool())
.callHandler((exchange, arguments) -> protectSync(resourceName, factory,
() -> specification.callHandler().apply(exchange, arguments)))
.build();
}

static AsyncToolSpecification decorate(AsyncToolSpecification specification, InvocationFactory factory) {
Objects.requireNonNull(specification, "specification");
Objects.requireNonNull(factory, "factory");
String resourceName = resourceName(specification.tool().name());
return AsyncToolSpecification.builder().tool(specification.tool())
.callHandler((exchange, arguments) -> protectAsync(resourceName, factory,
() -> specification.callHandler().apply(exchange, arguments)))
.build();
}

static McpStatelessServerFeatures.SyncToolSpecification decorate(
McpStatelessServerFeatures.SyncToolSpecification specification, InvocationFactory factory) {
Objects.requireNonNull(specification, "specification");
Objects.requireNonNull(factory, "factory");
String resourceName = resourceName(specification.tool().name());
return McpStatelessServerFeatures.SyncToolSpecification.builder().tool(specification.tool())
.callHandler((context, arguments) -> protectSync(resourceName, factory,
() -> specification.callHandler().apply(context, arguments)))
.build();
}

static McpStatelessServerFeatures.AsyncToolSpecification decorate(
McpStatelessServerFeatures.AsyncToolSpecification specification, InvocationFactory factory) {
Objects.requireNonNull(specification, "specification");
Objects.requireNonNull(factory, "factory");
String resourceName = resourceName(specification.tool().name());
return McpStatelessServerFeatures.AsyncToolSpecification.builder().tool(specification.tool())
.callHandler((context, arguments) -> protectAsync(resourceName, factory,
() -> specification.callHandler().apply(context, arguments)))
.build();
}

private static CallToolResult protectSync(String resourceName, InvocationFactory factory,
Supplier<CallToolResult> toolCall) {
Invocation invocation;
try {
invocation = factory.enter(resourceName, false);
} catch (BlockException ex) {
throw ex.toRuntimeException();
}
try {
return toolCall.get();
} catch (Throwable throwable) {
invocation.trace(throwable);
throw throwable;
} finally {
invocation.exit();
}
}

private static Mono<CallToolResult> protectAsync(String resourceName, InvocationFactory factory,
Supplier<Mono<CallToolResult>> toolCall) {
return Mono.defer(() -> {
Invocation invocation;
try {
invocation = factory.enter(resourceName, true);
} catch (BlockException ex) {
return Mono.error(ex);
}

try {
Mono<CallToolResult> result = Objects.requireNonNull(toolCall.get(),
"MCP async tool returned null");
return result.doOnError(invocation::trace).doFinally(signalType -> invocation.exit());
} catch (Throwable throwable) {
invocation.trace(throwable);
invocation.exit();
return Mono.error(throwable);
}
});
}

private static String resourceName(String toolName) {
return DEFAULT_RESOURCE_PREFIX + Objects.requireNonNull(toolName, "toolName");
}

interface InvocationFactory {
Invocation enter(String resourceName, boolean async) throws BlockException;
}

interface Invocation {
void trace(Throwable throwable);

void exit();
}

private static final class SentinelInvocationFactory implements InvocationFactory {

@Override
public Invocation enter(String resourceName, boolean async) throws BlockException {
if (async) {
AsyncEntry entry = SphU.asyncEntry(resourceName, ResourceTypeConstants.COMMON_RPC, EntryType.IN);
return new AsyncSentinelInvocation(entry);
}
Entry entry = SphU.entry(resourceName, ResourceTypeConstants.COMMON_RPC, EntryType.IN);
return new SyncSentinelInvocation(entry);
}
}

private abstract static class AbstractSentinelInvocation implements Invocation {

private final AtomicBoolean exited = new AtomicBoolean();

@Override
public final void exit() {
if (exited.compareAndSet(false, true)) {
doExit();
}
}

abstract void doExit();
}

private static final class SyncSentinelInvocation extends AbstractSentinelInvocation {

private final Entry entry;

private SyncSentinelInvocation(Entry entry) {
this.entry = entry;
}

@Override
public void trace(Throwable throwable) {
Tracer.traceEntry(throwable, entry);
}

@Override
void doExit() {
entry.exit();
}
}

private static final class AsyncSentinelInvocation extends AbstractSentinelInvocation {

private final AsyncEntry entry;

private AsyncSentinelInvocation(AsyncEntry entry) {
this.entry = entry;
}

@Override
public void trace(Throwable throwable) {
Tracer.traceContext(throwable, entry.getAsyncContext());
}

@Override
void doExit() {
entry.exit();
}
}

private SentinelMcpToolSpecificationDecorator() {}
}
Loading