如何給自研 MCP 服務(wù),加上安全認(rèn)證?
一、舉例,對接高德地圖 MCP
高德地圖 MCP Server;
{
"mcpServers": {
"amap-amap-sse": {
"url": "https://mcp.amap.com/sse?key=您在高德官網(wǎng)上申請的key"
}
}
}
- 官網(wǎng):https://lbs.amap.com/api/mcp-server/gettingstarted -
官網(wǎng)提供了創(chuàng)建對接 Key
1. 代碼使用示例
@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));
}
}
- 對接時(shí),需要設(shè)定 sseEndpoint 如果不設(shè)定個(gè),Spring AI 默認(rèn)是對 builder 的 baseUrl 值添加
/sse
的。 - 所以,如果你要對接外部帶有驗(yàn)證權(quán)限的 MCP 服務(wù),需要手動(dòng)設(shè)置下 sseEndpoint 值。
2. 項(xiàng)目中的配置
小傅哥,帶著大家做的 Ai Agent,也支持了外部的這些帶有權(quán)限校驗(yàn)的 MCP 服務(wù)。你可以,以多種方式進(jìn)行配置。如;
{
"baseUri":"https://mcp.amap.com",
"sseEndpoint":"/sse?key=801aabf79ed0ff78603cfe85****"
}
{
"baseUri":"https://mcp.amap.com",
"sseEndpoint":"/sse?key=801aabf79ed0ff78603cfe85****"
}
- 以上兩種配置方式,在 ai-agent-station 都做了兼容處理。以下是兼容代碼,學(xué)習(xí)這部分項(xiàng)目的伙伴,可以直接閱讀課程代碼。
@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) // 使用截取或默認(rèn)的 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!");
}
}
- 以上代碼,是為了自動(dòng)化構(gòu)建 MCP 服務(wù)的,其中 case sse 的部分,會(huì)對 url 進(jìn)行拆分,如果本身 url 配置了校驗(yàn)權(quán)限,則不會(huì)從另外一個(gè)參數(shù)獲取,否則從另外參數(shù)拼接。這樣就可以很好的擴(kuò)展用戶配置時(shí)的多樣性問題了。
以上是關(guān)于帶有權(quán)限校驗(yàn)的 MCP 服務(wù)配置的問題,接下來,我們要說下怎么自己開發(fā)一個(gè)帶有權(quán)限校驗(yàn)
二、實(shí)現(xiàn),帶有權(quán)限校驗(yàn)的 MCP 服務(wù)
首先,Spring AI 是有意提供基于自家的 OAuth2 框架,完成 MCP 服務(wù)的多樣性權(quán)限校驗(yàn)的。不過目前提供的方案能用,但不算成熟。
圖片
官網(wǎng):https://spring.io/blog/2025/05/19/spring-ai-mcp-client-oauth2
1. 基于 OAuth2 認(rèn)證
1.1 工程結(jié)構(gòu)
圖片
- 工程:https://gitcode.net/KnowledgePlanet/mcp-server-auth -
面向于學(xué)習(xí) ai-agent-station 的伙伴
- 使用 OAuth2 基于 Spring MVC 的方式到也簡單,知道添加配置即可。
1.2 所需的 POM 文件
<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>
1.3 測試驗(yàn)證
@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");
}
}
- 加上 OAuth2 以后,就需要獲取并設(shè)置 accessToken 才能訪問 sse 服務(wù)了。
2. 基于網(wǎng)關(guān)實(shí)現(xiàn)
其實(shí)我們到不非得依賴于 Spring OAuth2 往 MCP 服務(wù)里在添加一些其他的東西。倒不如直接走網(wǎng)關(guān),讓網(wǎng)關(guān)來管理權(quán)限,MCP 服務(wù)只做服務(wù)的事情就好。
這里我們基于 Nginx 來配置驗(yàn)證功能,當(dāng)然你可以在學(xué)習(xí)本節(jié)的案例后,配置任何其他的網(wǎng)關(guān)來管理你的 MCP 服務(wù)。
注意,這里的前置條件為你已經(jīng)跟著小傅哥,至少完成了一個(gè) MCP 服務(wù)。課程;https://t.zsxq.com/GwNZp
當(dāng)我們有了一套基于 sse 形式訪問的 mcp 后,我們是可以給這套 mcp 基于 nginx 轉(zhuǎn)發(fā)的形式進(jìn)行訪問后面真實(shí)的 mcp 服務(wù)的。在轉(zhuǎn)發(fā)的過程中,拿到用戶在地址 http://127.0.0.1:9999/sse?apikey=DElk89iu8Ehhnbu
mcp 服務(wù)后面拼接的 apikey,并對 apikey 進(jìn)行驗(yàn)證。
2.1 配置工程
圖片
- 在 ai-agent-station 項(xiàng)目下,提供了 dev-ops-v2 配置 mcp 服務(wù)轉(zhuǎn)發(fā)和驗(yàn)證能力。
- 注意,部署的時(shí)候,要把 mcp.localhost.conf 轉(zhuǎn)發(fā)的 mcp 服務(wù)的地址,更換為你的地址。
- 另外,每一個(gè) mcp.localhost.conf 還可以配置域名,這樣就達(dá)到了高德地圖 mcp 訪問的效果。舉例;
https://fatie.mcp.bugstack.cn/sse/apikey=*******
2.2 服務(wù)轉(zhuǎn)發(fā)&校驗(yàn)
# 可以負(fù)載服務(wù)
upstream backend_servers {
server 192.168.1.108:8101;
}
server {
listen 80;
server_name 192.168.1.104; # 修改為你的實(shí)際服務(wù)器 IP 或域名【域名需要備案】
location /sse {
# 驗(yàn)證apikey參數(shù),這個(gè)apikey也可以對接服務(wù)端接口來處理。
if ($arg_apikey != "DElk89iu8Ehhnbu") {
return403; # 如果apikey不正確,返回403禁止訪問
}
# 重寫URL,去掉apikey參數(shù)
rewrite ^(/sse/)\?apikey=.* $1break;
proxy_pass http://backend_servers; # 將請求代理到上游服務(wù)器組
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; # 將請求代理到上游服務(wù)器組
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;
}
}
- 特別注意,mcp 服務(wù)是有2個(gè)步驟的,一個(gè)是 sse 訪問,還有一個(gè) mcp/message 的處理。我們只需要對 sse 的請求進(jìn)行驗(yàn)證即可。
/sse
請求路徑,需要會(huì)提取 apikey 與 nginx 配置的值進(jìn)行對比,如果不正確則會(huì)返回一個(gè) 403 禁止訪問,通過則放行。- 之后重寫 url 地址,讓轉(zhuǎn)發(fā)到本身 mcp 的地址是干凈的。從
http://127.0.0.1:9999/sse?apikey=DElk89iu8Ehhnbu
驗(yàn)證轉(zhuǎn)發(fā)后為http://192.168.1.108:8101/sse
2.3 功能驗(yàn)證
首先,要確保你的 mcp 服務(wù)是可以使用的。如,訪問;http://192.168.1.108:8101/sse
可以獲得到結(jié)果。
圖片
- 如圖,驗(yàn)證成功。我們可以通過轉(zhuǎn)發(fā)的方式進(jìn)行驗(yàn)證和使用。 - 另外,有了轉(zhuǎn)發(fā)和驗(yàn)證,你原本的服務(wù),sse 8101 就不用對外了。只有你的網(wǎng)關(guān)(nginx)可以訪問即可。這樣就可以控制權(quán)限了。
2.4 動(dòng)態(tài)驗(yàn)證
那么,目前我們配置的nginx 轉(zhuǎn)發(fā)這不是一個(gè)固定的權(quán)限賬號嗎,怎么讓不同的接入方都申請一個(gè)秘鑰key來使用呢?這里我們需要使用到 nginx 的 auth 認(rèn)證模塊。
# 可以負(fù)載服務(wù)
upstream backend_servers {
server 192.168.1.108:8101;
}
server {
listen 80;
server_name 192.168.1.104; # 修改為你的實(shí)際服務(wù)器 IP 或域名【域名需要備案】
location /sse {
auth_request /auth;
# 重寫URL,去掉apikey參數(shù)
rewrite ^(/sse/)\?apikey=.* $1break;
proxy_pass http://backend_servers; # 將請求代理到上游服務(wù)器組
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; # 將請求代理到上游服務(wù)器組
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 {
# 發(fā)送子請求到HTTP服務(wù),驗(yàn)證客戶端的憑據(jù),返回響應(yīng)碼
internal;
# 設(shè)置參數(shù)
set $query '';
if ($request_uri ~* "[^\?]+\?(.*)$") {
set $query $1;
}
# 驗(yàn)證成功,返回200 OK
proxy_pass http://207.246.123.*:8090/auth/token?$query;
# 發(fā)送原始請求
proxy_pass_request_body off;
# 清空 Content-Type
proxy_set_header Content-Type "";
}
}
- 在訪問
/sse
的時(shí)候,增加 auth 認(rèn)證,auth 來訪問本地一個(gè) http 接口。你可以是 SpringBoot 實(shí)現(xiàn)的接口。這個(gè)接口負(fù)責(zé)驗(yàn)證你的秘鑰是否正確。同時(shí)你的 SpringBoot 服務(wù)還可以提供出一個(gè)創(chuàng)建秘鑰的平臺,讓接入方使用。 - 其實(shí)類似這樣的場景,使用功能更加豐富的 api 網(wǎng)關(guān)都是自帶的,或者 github 一些專門為 mcp 做網(wǎng)關(guān)服務(wù)的也都有。