MCP介绍
Model Context Protocol (MCP) 是一个开放协议,它使 LLM 应用与外部数据源和工具之间的无缝集成成为可能。无论你是构建 AI 驱动的 IDE、改善 chat 交互,还是构建自定义的 AI 工作流,MCP 提供了一种标准化的方式,将 LLM 与它们所需的上下文连接起来。
参考 modelcontextprotocol.io/introductio...
实现一个查询天气的简单功能
注册DeepSeek,获取apikey(需要充值)
springboot 3 MCP服务端
springboot需要3.4以上
核心依赖
xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
以上依赖需要增加如下maven库到项目中
xml
<repository>
<name>Central Portal Snapshots</name>
<id>central-portal-snapshots</id>
<url>https://central.sonatype.com/repository/maven-snapshots/</url>
<releases>
<enabled>false</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
<repository>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/snapshot</url>
<releases>
<enabled>false</enabled>
</releases>
</repository>
服务端代码
实现查询天气,api只支持美国城市,额外定义了3个返回固定值的 日本,中国,江苏城市天气用于测试。
java
package com.hally.ai.mcp.mcpserverspringboot.services;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* @author 海狸 Hally.W
* Created on 2025/3/28 13:52
* Updated on 2025/3/28 13:52
*/
@Slf4j
@Service
public class WeatherService {
private static final String BASE_URL = "https://api.weather.gov";
private final RestClient restClient;
public WeatherService() {
this.restClient = RestClient.builder()
.baseUrl(BASE_URL)
.defaultHeader("Accept", "application/geo+json")
.defaultHeader("User-Agent", "WeatherApiClient/1.0 ([email protected])")
.build();
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Points(@JsonProperty("properties") Props properties) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record Props(@JsonProperty("forecast") String forecast) {
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Forecast(@JsonProperty("properties") Props properties) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record Props(@JsonProperty("periods") List<Period> periods) {
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Period(@JsonProperty("number") Integer number, @JsonProperty("name") String name,
@JsonProperty("startTime") String startTime, @JsonProperty("endTime") String endTime,
@JsonProperty("isDaytime") Boolean isDayTime,
@JsonProperty("temperature") Integer temperature,
@JsonProperty("temperatureUnit") String temperatureUnit,
@JsonProperty("temperatureTrend") String temperatureTrend,
@JsonProperty("probabilityOfPrecipitation") Map probabilityOfPrecipitation,
@JsonProperty("windSpeed") String windSpeed,
@JsonProperty("windDirection") String windDirection,
@JsonProperty("icon") String icon, @JsonProperty("shortForecast") String shortForecast,
@JsonProperty("detailedForecast") String detailedForecast) {
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Alert(@JsonProperty("features") List<Feature> features) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record Feature(@JsonProperty("properties") Properties properties) {
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Properties(@JsonProperty("event") String event, @JsonProperty("areaDesc") String areaDesc,
@JsonProperty("severity") String severity,
@JsonProperty("description") String description,
@JsonProperty("instruction") String instruction) {
}
}
/**
* Get forecast for a specific latitude/longitude
*
* @param latitude Latitude
* @param longitude Longitude
* @return The forecast for the given location
*/
@Tool(description = "Get weather forecast for a specific latitude/longitude")
public String getWeatherForecastByLocation(double latitude, double longitude) {
var points = restClient.get()
.uri("/points/{latitude},{longitude}", latitude, longitude)
.retrieve()
.body(Points.class);
var forecast = restClient.get().uri(points.properties().forecast()).retrieve().body(Forecast.class);
String forecastText = forecast.properties().periods().stream().map(p -> {
return String.format("""
%s:
Temperature: %s %s
Wind: %s %s
Forecast: %s
""", p.name(), p.temperature(), p.temperatureUnit(), p.windSpeed(), p.windDirection(),
p.detailedForecast());
}).collect(Collectors.joining());
return forecastText;
}
@Tool(description = "日本城市的天气")
public String getJapanWeatherForecastByLocation(double latitude, double longitude) {
System.out.println("日本城市的天气");
return "天气一塌糊涂";
}
@Tool(description = "中国城市的天气")
public String getChinaWeatherForecastByLocation(double latitude, double longitude) {
System.out.println("中国城市的天气:"+longitude+","+latitude);
return "天气非常好";
}
@Tool(description = "江苏城市的天气")
public String getChinajsWeatherForecastByLocation(double latitude, double longitude) {
System.out.println("江苏城市的天气:"+longitude+","+latitude);
return "天气一般好";
}
/**
* Get alerts for a specific area
*
* @param state Area code. Two-letter US state code (e.g. CA, NY)
* @return Human readable alert information
* @throws RestClientException if the request fails
*/
@Tool(description = "Get weather alerts for a US state. Input is Two-letter US state code (e.g. CA, NY)")
public String getAlerts(String state) {
Alert alert = restClient.get().uri("/alerts/active/area/{state}", state).retrieve().body(Alert.class);
return alert.features()
.stream()
.map(f -> String.format("""
Event: %s
Area: %s
Severity: %s
Description: %s
Instructions: %s
""", f.properties().event(), f.properties.areaDesc(), f.properties.severity(),
f.properties.description(), f.properties.instruction()))
.collect(Collectors.joining("\n"));
}
public static void main(String[] args) {
WeatherService client = new WeatherService();
System.out.println(client.getWeatherForecastByLocation(47.6062, -122.3321));
System.out.println(client.getAlerts("NY"));
}
}
application.yml
yaml
# Using spring-ai-mcp-server-webmvc-spring-boot-starter
spring:
application:
name: mcp-server-springboot
ai:
mcp:
server:
name: weather-mcp-server
version: 1.0.0
main:
banner-mode: off
启动服务端后,默认sse服务地址:http://localhost:8080/sse
nodejs实现MCP客户端
搭建typescpit环境
node版本建议 v22.14.0
shell
npm install -g tsx
npm install -g typescript
mcp client代码
client.ts
ts
import {Client} from "@modelcontextprotocol/sdk/client/index.js";
import {StdioClientTransport, StdioServerParameters} from "@modelcontextprotocol/sdk/client/stdio.js";
import {SSEClientTransport} from "@modelcontextprotocol/sdk/client/sse.js";
import OpenAI from "openai";
import {Tool} from "@modelcontextprotocol/sdk/types.js";
import {ChatCompletionMessageParam} from "openai/resources/chat/completions.js";
import {createInterface} from "readline";
import {homedir} from 'os';
// @ts-ignore
import config from "./config/mcp-server-config.js";
// 初始化环境变量
const DEEPSEEK_API_KEY = "sk-xxx";
const model = "deepseek-chat";
if (!DEEPSEEK_API_KEY) {
throw new Error("DEEPSEEK_API_KEY is required");
}
interface MCPToolResult {
content: string;
}
interface ServerConfig {
name: string;
type: 'command' | 'sse';
command?: string;
url?: string;
isOpen?: boolean;
}
class MCPClient {
static getOpenServers(): string[] {
return config.filter((cfg: { isOpen: any; }) => cfg.isOpen).map((cfg: { name: any; }) => cfg.name);
}
private sessions: Map<string, Client> = new Map();
private transports: Map<string, StdioClientTransport | SSEClientTransport> = new Map();
private openai: OpenAI;
constructor() {
this.openai = new OpenAI({
apiKey: DEEPSEEK_API_KEY,
baseURL: 'https://api.deepseek.com'
});
}
async connectToServer(serverName: string): Promise<void> {
const serverConfig = config.find((cfg: { name: string; }) => cfg.name === serverName) as ServerConfig;
if (!serverConfig) {
throw new Error(`Server configuration not found for: ${serverName}`);
}
let transport: StdioClientTransport | SSEClientTransport;
if (serverConfig.type === 'command' && serverConfig.command) {
transport = await this.createCommandTransport(serverConfig.command);
} else if (serverConfig.type === 'sse' && serverConfig.url) {
transport = await this.createSSETransport(serverConfig.url);
} else {
throw new Error(`Invalid server configuration for: ${serverName}`);
}
const client = new Client(
{
name: "mcp-client",
version: "1.0.0"
},
{
capabilities: {
prompts: {},
resources: {},
tools: {}
}
}
);
await client.connect(transport);
this.sessions.set(serverName, client);
this.transports.set(serverName, transport);
// 列出可用工具
const response = await client.listTools();
console.log(`\nConnected to server '${serverName}' with tools:`, response.tools.map((tool: Tool) => tool.name));
}
private async createCommandTransport(shell: string): Promise<StdioClientTransport> {
const [command, ...shellArgs] = shell.split(' ');
if (!command) {
throw new Error("Invalid shell command");
}
// 处理参数中的波浪号路径
const args = shellArgs.map(arg => {
if (arg.startsWith('~/')) {
return arg.replace('~', homedir());
}
return arg;
});
const serverParams: StdioServerParameters = {
command,
args,
env: Object.fromEntries(
Object.entries(process.env).filter(([_, v]) => v !== undefined)
) as Record<string, string>
};
return new StdioClientTransport(serverParams);
}
private async createSSETransport(url: string): Promise<SSEClientTransport> {
return new SSEClientTransport(new URL(url));
}
async processQuery(query: string): Promise<string> {
if (this.sessions.size === 0) {
throw new Error("Not connected to any server");
}
const messages: ChatCompletionMessageParam[] = [
{
role: "user",
content: query
}
];
// 获取所有服务器的工具列表
const availableTools: any[] = [];
for (const [serverName, session] of this.sessions) {
const response = await session.listTools();
const tools = response.tools.map((tool: Tool) => ({
type: "function" as const,
function: {
name: `${serverName}__${tool.name}`,
description: `[${serverName}] ${tool.description}`,
parameters: tool.inputSchema
}
}));
availableTools.push(...tools);
}
// 调用OpenAI API
const completion = await this.openai.chat.completions.create({
model: model,
messages,
tools: availableTools,
tool_choice: "auto"
});
const finalText: string[] = [];
// 处理OpenAI的响应
for (const choice of completion.choices) {
const message = choice.message;
if (message.content) {
finalText.push(message.content);
}
console.log("message====" + JSON.stringify(message));
if (message.tool_calls) {
for (const toolCall of message.tool_calls) {
const [serverName, toolName] = toolCall.function.name.split('__');
const session = this.sessions.get(serverName);
if (!session) {
finalText.push(`[Error: Server ${serverName} not found]`);
continue;
}
const toolArgs = JSON.parse(toolCall.function.arguments);
// 执行工具调用
const result = await session.callTool({
name: toolName,
arguments: toolArgs
});
const toolResult = result as unknown as MCPToolResult;
finalText.push(`[Calling tool ${toolName} on server ${serverName} with args ${JSON.stringify(toolArgs)}]`);
console.log(`[Calling tool ${toolName} on server ${serverName} with args ${JSON.stringify(toolArgs)}]`)
console.log("toolResult.content====" + JSON.stringify(toolResult.content));
finalText.push(toolResult.content);
// 继续与工具结果的对话
messages.push({
role: "assistant",
content: "",
tool_calls: [toolCall]
});
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: JSON.stringify(toolResult.content)
});
console.log(`messages: ${JSON.stringify(messages)}`)
// 获取下一个响应
const nextCompletion = await this.openai.chat.completions.create({
model: model,
messages,
tools: availableTools,
tool_choice: "auto"
});
if (nextCompletion.choices[0].message.content) {
finalText.push(nextCompletion.choices[0].message.content);
}
}
}
}
return finalText.join("\n");
}
async chatLoop(): Promise<void> {
console.log("\nMCP Client Started!");
console.log("Type your queries or 'quit' to exit.");
const readline = createInterface({
input: process.stdin,
output: process.stdout
});
const askQuestion = () => {
return new Promise<string>((resolve) => {
readline.question("\nQuery: ", resolve);
});
};
try {
while (true) {
const query = (await askQuestion()).trim();
if (query.toLowerCase() === 'quit') {
break;
}
try {
const response = await this.processQuery(query);
console.log("\n======" + response);
} catch (error) {
console.error("\nError:", error);
}
}
} finally {
readline.close();
}
}
async cleanup(): Promise<void> {
for (const transport of this.transports.values()) {
await transport.close();
}
this.transports.clear();
this.sessions.clear();
}
hasActiveSessions(): boolean {
return this.sessions.size > 0;
}
}
// 主函数
async function main() {
const openServers = MCPClient.getOpenServers();
console.log("Connecting to servers:", openServers.join(", "));
const client = new MCPClient();
try {
// 连接所有开启的服务器
for (const serverName of openServers) {
try {
await client.connectToServer(serverName);
} catch (error) {
console.error(`Failed to connect to server '${serverName}':`, error);
}
}
if (!client.hasActiveSessions()) {
throw new Error("Failed to connect to any server");
}
await client.chatLoop();
} finally {
await client.cleanup();
}
}
// 运行主函数
main().catch(console.error);
MCP服务端的配置放在统一的 config.js
js
const config = [
{
name: 'spingboot-sse',
type: 'sse',
url: 'http://localhost:8080/sse',
isOpen: true
}
];
export default config;
package.json
json
{
"name": "mcp-test",
"version": "1.0.0",
"main": "index.js",
"type": "module",
"scripts": {
"start": "npx tsx --env-file=.env --watch src/index.ts",
"client": "npx tsx --env-file=.env --watch src/client.ts",
"build": "tsc"
},
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.8.0",
"express": "^5.0.1",
"openai": "^4.90.0"
},
"devDependencies": {
"@types/express": "^5.0.1",
"@types/node": "^22.13.14"
}
}
tsconfig.json
json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
运行效果
启动springboot的服务端后
在客户端工程目录运行
shell
npm run client
打印如下信息,说明已经连接上MCP服务
Query 输入 纽约天气、南京天气、北京天气、东京天气,查看服务端日志,发现调用了不同的方法,输出了不同的结果
自动匹配后端方法:中国城市的天气
输入南京天气
自动匹配调用后端江苏城市天气方法
完整代码
springboot服务端 github.com/hallywang/m...
ts客户端 github.com/hallywang/m...
参考
springai github.com/spring-proj... mcp tssdk: github.com/modelcontex...
以及网上其他代码片段