作者:小傅哥 博客:https://bugstack.cn
❝沉淀、分享、成长,让自己和他人都能有所收获!😜 ❞
大家好,我是技术UP主小傅哥。
刚刚过去两个月,市面的 MCP 服务,如雨后春笋一般不断涌现出来,包括;百度
、高德
、网盘
、支付宝
。这些 MCP 服务,可以让我们基于 Spring AI 框架构建的 Agent 具备非常丰富的使用功能。同时这也说明,程序员👨🏻💻,应该具备开发 MCP 服务的能力,Spring AI 让 Java 再次牛逼!
关于 RAG、MCP、Agent 是什么,这里小傅哥已经编写过了全套的教程,可以进入学习;https://bugstack.cn/md/project/ai-knowledge/ai-knowledge.html
本节小傅哥主要给大家分享,关于市面上这些标准的带有验证权限的 MCP 服务,怎么使用 Spring AI 进行对接。同时我们自己开发的 MCP 服务,怎么加上权限校验。
高德地图 MCP Server;
{
"mcpServers": {
"amap-amap-sse": {
"url": "https://mcp.amap.com/sse?key=您在高德官网上申请的key"
}
}
}
官网提供了创建对接 Key
@Configuration
publicclass McpConfig {
@Bean
public List<NamedClientMcpTransport> mcpClientTransport() {
McpClientTransport transport = HttpClientSseClientTransport
.builder("https://mcp.amap.com")
.sseEndpoint("/sse?key=<your_key>")
.objectMapper(new ObjectMapper())
.build();
return Collections.singletonList(new NamedClientMcpTransport("amap", transport));
}
}
/sse
的。小傅哥,带着大家做的 Ai Agent,也支持了外部的这些带有权限校验的 MCP 服务。你可以,以多种方式进行配置。如;
{
"baseUri":"https://mcp.amap.com",
"sseEndpoint":"/sse?key=801aabf79ed0ff78603cfe85****"
}
{
"baseUri":"https://mcp.amap.com",
"sseEndpoint":"/sse?key=801aabf79ed0ff78603cfe85****"
}
@Slf4j
@Component
publicclass AiClientToolMcpNode extends AbstractArmorySupport {
// ... 省略部分代码
protected McpSyncClient createMcpSyncClient(AiClientToolMcpVO aiClientToolMcpVO) {
String transportType = aiClientToolMcpVO.getTransportType();
switch (transportType) {
case"sse" -> {
AiClientToolMcpVO.TransportConfigSse transportConfigSse = aiClientToolMcpVO.getTransportConfigSse();
// http://127.0.0.1:9999/sse?apikey=DElk89iu8Ehhnbu
String originalBaseUri = transportConfigSse.getBaseUri();
String baseUri;
String sseEndpoint;
int queryParamStartIndex = originalBaseUri.indexOf("sse");
if (queryParamStartIndex != -1) {
baseUri = originalBaseUri.substring(0, queryParamStartIndex - 1);
sseEndpoint = originalBaseUri.substring(queryParamStartIndex - 1);
} else {
baseUri = originalBaseUri;
sseEndpoint = transportConfigSse.getSseEndpoint();
}
sseEndpoint = StringUtils.isBlank(sseEndpoint) ? "/sse" : sseEndpoint;
HttpClientSseClientTransport sseClientTransport = HttpClientSseClientTransport
.builder(baseUri) // 使用截取后的 baseUri
.sseEndpoint(sseEndpoint) // 使用截取或默认的 sseEndpoint
.build();
McpSyncClient mcpSyncClient = McpClient.sync(sseClientTransport).requestTimeout(Duration.ofMinutes(aiClientToolMcpVO.getRequestTimeout())).build();
var init_sse = mcpSyncClient.initialize();
log.info("Tool SSE MCP Initialized {}", init_sse);
return mcpSyncClient;
}
case"stdio" -> {
AiClientToolMcpVO.TransportConfigStdio transportConfigStdio = aiClientToolMcpVO.getTransportConfigStdio();
Map<String, AiClientToolMcpVO.TransportConfigStdio.Stdio> stdioMap = transportConfigStdio.getStdio();
AiClientToolMcpVO.TransportConfigStdio.Stdio stdio = stdioMap.get(aiClientToolMcpVO.getMcpName());
// https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem
var stdioParams = ServerParameters.builder(stdio.getCommand())
.args(stdio.getArgs())
.build();
var mcpClient = McpClient.sync(new StdioClientTransport(stdioParams))
.requestTimeout(Duration.ofSeconds(aiClientToolMcpVO.getRequestTimeout())).build();
var init_stdio = mcpClient.initialize();
log.info("Tool Stdio MCP Initialized {}", init_stdio);
return mcpClient;
}
}
thrownew RuntimeException("err! transportType " + transportType + " not exist!");
}
}
以上是关于带有权限校验的 MCP 服务配置的问题,接下来,我们要说下怎么自己开发一个带有权限校验
首先,Spring AI 是有意提供基于自家的 OAuth2 框架,完成 MCP 服务的多样性权限校验的。不过目前提供的方案能用,但不算成熟。
官网:https://spring.io/blog/2025/05/19/spring-ai-mcp-client-oauth2
面向于学习 ai-agent-station 的伙伴
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
@Slf4j
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = "server.shutdown=immediate")
publicclass ApiTest {
@LocalServerPort
privateint port;
privatefinal ObjectMapper objectMapper = new ObjectMapper();
@Test
public void test_access_token() throws IOException, InterruptedException {
String token = obtainAccessToken();
log.info("token:{}", token);
// eyJraWQiOiJiMWQ0MGIxNi1hOTYzLTQ2NmYtYTVkOC02NGRjMzg0ODljYWEiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJtY3AtY2xpZW50IiwiYXVkIjoibWNwLWNsaWVudCIsIm5iZiI6MTc0ODA1MTc1NiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1ODA5OCIsImV4cCI6MTc0ODA1MjA1NiwiaWF0IjoxNzQ4MDUxNzU2LCJqdGkiOiI5NjY4ZmZkMi0wNjQ2LTRiNmItODQ4Ni1jYzk3ZjMxNTdmOTEifQ.CG4GYai_NYkmfcqmNi-_HYG06Kan04uNSsC2ivn_eC9Ra6xMKYTs9KIT7k5lKxSFRUOPI7K0zJNVvNXrrIe0iFl-csrG2vGukNTGTPMxtUi2hheBMRbnvjvuojW4DeOEE8UOpdA6uow67ucwcymTlDXE-k7OjRZeyp7UdVz2WyoDFQhLB6ihLbDSj5puAZxfNocirRzo36gmW243aW9f1gugPUcpND-oobc2q8xyBG2cX2AlGXUSS-v9PLjHr2W2smFTKHHGwu7FpMMBnJLUT5gZD0llIg6yqro91nFaAFOpGHXjRZYgVjkRlzxx08Zuquva9PbStxbUl2j8hI43_Q
var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + port + "/sse"))
.header("Accept", "text/event-stream")
.header("Authorization", "Bearer " + token)
.GET()
.build();
var responseCode = new AtomicInteger(-1);
var sseRequest = client.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()).thenApply(response -> {
responseCode.set(response.statusCode());
if (response.statusCode() == 200) {
log.info("response:{}", JSON.toJSONString(response));
return response;
}
else {
thrownew RuntimeException("Failed to connect to SSE endpoint: " + response.statusCode());
}
});
await().atMost(Duration.ofSeconds(1)).until(sseRequest::isDone);
assertThat(sseRequest).isCompleted();
assertThat(responseCode).hasValue(200);
}
private String obtainAccessToken() throws IOException, InterruptedException {
var client = HttpClient.newHttpClient();
var clearTextCredentials = "mcp-client:secret".getBytes(StandardCharsets.UTF_8);
var credentials = new String(Base64.getUrlEncoder().encode(clearTextCredentials));
var request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + port + "/oauth2/token"))
.header("Authorization", "Basic " + credentials)
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(ofString("grant_type=client_credentials"))
.build();
var rawResponse = client.send(request, HttpResponse.BodyHandlers.ofString()).body();
Map<String, String> response = objectMapper.readValue(rawResponse, Map.class);
return response.get("access_token");
}
}
其实我们到不非得依赖于 Spring OAuth2 往 MCP 服务里在添加一些其他的东西。倒不如直接走网关,让网关来管理权限,MCP 服务只做服务的事情就好。
这里我们基于 Nginx 来配置验证功能,当然你可以在学习本节的案例后,配置任何其他的网关来管理你的 MCP 服务。
注意,这里的前置条件为你已经跟着小傅哥,至少完成了一个 MCP 服务。课程;https://t.zsxq.com/GwNZp
当我们有了一套基于 sse 形式访问的 mcp 后,我们是可以给这套 mcp 基于 nginx 转发的形式进行访问后面真实的 mcp 服务的。在转发的过程中,拿到用户在地址 http://127.0.0.1:9999/sse?apikey=DElk89iu8Ehhnbu
mcp 服务后面拼接的 apikey,并对 apikey 进行验证。
https://fatie.mcp.bugstack.cn/sse/apikey=*******
# 可以负载服务
upstream backend_servers {
server 192.168.1.108:8101;
}
server {
listen 80;
server_name 192.168.1.104; # 修改为你的实际服务器 IP 或域名【域名需要备案】
location /sse {
# 验证apikey参数,这个apikey也可以对接服务端接口来处理。
if ($arg_apikey != "DElk89iu8Ehhnbu") {
return403; # 如果apikey不正确,返回403禁止访问
}
# 重写URL,去掉apikey参数
rewrite ^(/sse/)\?apikey=.* $1break;
proxy_pass http://backend_servers; # 将请求代理到上游服务器组
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
chunked_transfer_encoding off;
proxy_buffering off;
proxy_cache off;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /mcp/message {
proxy_pass http://backend_servers; # 将请求代理到上游服务器组
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
/sse
请求路径,需要会提取 apikey 与 nginx 配置的值进行对比,如果不正确则会返回一个 403 禁止访问,通过则放行。http://127.0.0.1:9999/sse?apikey=DElk89iu8Ehhnbu
验证转发后为 http://192.168.1.108:8101/sse
首先,要确保你的 mcp 服务是可以使用的。如,访问;http://192.168.1.108:8101/sse
可以获得到结果。
- 如图,验证成功。我们可以通过转发的方式进行验证和使用。 - 另外,有了转发和验证,你原本的服务,sse 8101 就不用对外了。只有你的网关(nginx)可以访问即可。这样就可以控制权限了。
那么,目前我们配置的nginx 转发这不是一个固定的权限账号吗,怎么让不同的接入方都申请一个秘钥key来使用呢?这里我们需要使用到 nginx 的 auth 认证模块。
# 可以负载服务
upstream backend_servers {
server 192.168.1.108:8101;
}
server {
listen 80;
server_name 192.168.1.104; # 修改为你的实际服务器 IP 或域名【域名需要备案】
location /sse {
auth_request /auth;
# 重写URL,去掉apikey参数
rewrite ^(/sse/)\?apikey=.* $1break;
proxy_pass http://backend_servers; # 将请求代理到上游服务器组
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
chunked_transfer_encoding off;
proxy_buffering off;
proxy_cache off;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /mcp/message {
proxy_pass http://backend_servers; # 将请求代理到上游服务器组
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /auth {
# 发送子请求到HTTP服务,验证客户端的凭据,返回响应码
internal;
# 设置参数
set $query '';
if ($request_uri ~* "[^\?]+\?(.*)$") {
set $query $1;
}
# 验证成功,返回200 OK
proxy_pass http://207.246.123.*:8090/auth/token?$query;
# 发送原始请求
proxy_pass_request_body off;
# 清空 Content-Type
proxy_set_header Content-Type "";
}
}
/sse
的时候,增加 auth 认证,auth 来访问本地一个 http 接口。你可以是 SpringBoot 实现的接口。这个接口负责验证你的秘钥是否正确。同时你的 SpringBoot 服务还可以提供出一个创建秘钥的平台,让接入方使用。小傅哥,已经为你准备好了一套 AI RAG、MCP、Agent 实践编程课程,使用 Java + Spring AI 框架,增强自己的 AI 应用开发能力,迅速囤积编程技能,满足各个公司招聘时对AI应用类开发的要求!如下,课程目录,全程文档小册 + 视频带着你从0到1学习。