MCP介绍
Model Context Protocol (MCP) 是一个开放协议,它使 LLM 应用与外部数据源和工具之间的无缝集成成为可能。无论你是构建 AI 驱动的 IDE、改善 chat 交互,还是构建自定义的 AI 工作流,MCP 提供了一种标准化的方式,将 LLM 与它们所需的上下文连接起来。
参考 modelcontextprotocol.io/introductio…
实现一个查询天气的简单功能
注册DeepSeek,获取apikey(需要充值)
springboot 3 MCP服务端
springboot需要3.4以上
核心依赖
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId> </dependency>以上依赖需要增加如下maven库到项目中
<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个返回固定值的 日本,中国,江苏城市天气用于测试。
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;@Slf4j@Servicepublic 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 (your@email.com)") .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) { } } @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 "天气一般好"; } @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
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
npm install -g tsxnpm install -g typescriptmcp client代码
client.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';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); } const completion = await this.openai.chat.completions.create({ model: model, messages, tools: availableTools, tool_choice: "auto" }); const finalText: string[] = []; 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
const config = [ { name: 'spingboot-sse', type: 'sse', url: 'http://localhost:8080/sse', isOpen: true }];export default config;package.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
{ "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的服务端后
在客户端工程目录运行
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…
以及网上其他代码片段
