第2282篇:MCP服务器开发实战——用Java为Claude构建自定义工具
第2282篇:MCP服务器开发实战——用Java为Claude构建自定义工具
适读人群:想给AI应用扩展工具能力的Java后端工程师 | 阅读时长:约16分钟 | 核心价值:掌握从零开发一个生产级MCP Server的完整流程
上篇讲了MCP协议的架构设计,有几个读者在后台问:道理都懂,但具体怎么用Java写一个MCP Server?
这篇就来回答这个问题,而且要写一个有实际价值的例子。
我选的场景是:给Claude接入公司内部的Confluence知识库。这个需求在企业里极其常见——员工想问"我们公司的报销流程是什么"、"这个项目的技术规范文档在哪",AI需要能实时检索内部文档,而不是靠训练数据里根本不可能有的内部知识。
MCP Server的骨架结构
一个MCP Server本质上就是一个遵循MCP协议的进程,它:
- 监听stdin,读取JSON-RPC请求
- 处理请求(列工具、调工具、列资源等)
- 把结果写入stdout
用Java实现,核心代码结构如下:
@SpringBootApplication
public class ConfluenceMcpServer {
public static void main(String[] args) {
// 注意:MCP Server通过stdio通信,不启动HTTP服务器
// 禁用Spring Boot的web服务器
SpringApplication app = new SpringApplication(ConfluenceMcpServer.class);
app.setWebApplicationType(WebApplicationType.NONE);
app.run(args);
}
}
@Component
public class McpServerRunner implements ApplicationRunner {
private final McpRequestDispatcher dispatcher;
private final ObjectMapper objectMapper;
@Override
public void run(ApplicationArguments args) throws Exception {
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
PrintWriter writer = new PrintWriter(System.out, true);
log.info("Confluence MCP Server 已启动,等待请求...");
String line;
while ((line = reader.readLine()) != null) {
try {
JsonNode request = objectMapper.readTree(line);
String response = dispatcher.dispatch(request);
if (response != null) {
// 只有请求(有id字段)才需要响应,通知不需要
writer.println(response);
}
} catch (Exception e) {
// 发送JSON-RPC错误响应
writer.println(buildErrorResponse(null, -32700, "解析请求失败: " + e.getMessage()));
}
}
log.info("stdin关闭,MCP Server退出");
}
}请求分发器:MCP的核心路由逻辑
@Component
public class McpRequestDispatcher {
private final ServerInfoProvider serverInfoProvider;
private final ToolsHandler toolsHandler;
private final ResourcesHandler resourcesHandler;
private final ObjectMapper objectMapper;
// 服务器能力声明
private static final Map<String, Object> SERVER_CAPABILITIES = Map.of(
"tools", Map.of("listChanged", false),
"resources", Map.of("subscribe", false, "listChanged", false)
);
public String dispatch(JsonNode request) throws JsonProcessingException {
String method = request.path("method").asText();
JsonNode id = request.has("id") ? request.get("id") : null;
JsonNode params = request.path("params");
// 通知消息(没有id字段)不需要响应
if (id == null || id.isNull()) {
handleNotification(method, params);
return null;
}
Object result;
try {
result = switch (method) {
case "initialize" -> handleInitialize(params);
case "tools/list" -> toolsHandler.listTools();
case "tools/call" -> toolsHandler.callTool(params);
case "resources/list" -> resourcesHandler.listResources();
case "resources/read" -> resourcesHandler.readResource(params);
default -> throw new McpException(-32601, "不支持的方法: " + method);
};
} catch (McpException e) {
return buildErrorResponse(id, e.getCode(), e.getMessage());
} catch (Exception e) {
log.error("处理请求失败: method={}", method, e);
return buildErrorResponse(id, -32603, "内部错误: " + e.getMessage());
}
return buildSuccessResponse(id, result);
}
private Map<String, Object> handleInitialize(JsonNode params) {
String clientProtocolVersion = params.path("protocolVersion").asText();
log.info("客户端协议版本: {}", clientProtocolVersion);
return Map.of(
"protocolVersion", "2024-11-05",
"capabilities", SERVER_CAPABILITIES,
"serverInfo", Map.of(
"name", "confluence-mcp-server",
"version", "1.0.0"
)
);
}
private void handleNotification(String method, JsonNode params) {
if ("notifications/initialized".equals(method)) {
log.info("客户端初始化完成通知收到");
}
}
private String buildSuccessResponse(JsonNode id, Object result) throws JsonProcessingException {
Map<String, Object> response = new LinkedHashMap<>();
response.put("jsonrpc", "2.0");
response.put("id", id);
response.put("result", result);
return objectMapper.writeValueAsString(response);
}
private String buildErrorResponse(JsonNode id, int code, String message) {
try {
Map<String, Object> response = new LinkedHashMap<>();
response.put("jsonrpc", "2.0");
response.put("id", id);
response.put("error", Map.of("code", code, "message", message));
return objectMapper.writeValueAsString(response);
} catch (JsonProcessingException e) {
// 最后防线,返回最简单的错误格式
return "{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32603,\"message\":\"序列化错误\"}}";
}
}
}工具定义:Confluence检索工具
这是MCP Server最核心的部分——定义工具的schema和实现工具调用逻辑:
@Component
public class ToolsHandler {
private final ConfluenceClient confluenceClient;
private final ObjectMapper objectMapper;
// 工具定义:使用JSON Schema描述输入参数
private static final String SEARCH_TOOL_SCHEMA = """
{
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "搜索关键词或自然语言问题"
},
"space_key": {
"type": "string",
"description": "限定搜索的Confluence空间,不填则搜索全部空间"
},
"limit": {
"type": "integer",
"description": "返回结果数量,默认5,最大20",
"default": 5,
"minimum": 1,
"maximum": 20
}
},
"required": ["query"]
}
""";
private static final String GET_PAGE_TOOL_SCHEMA = """
{
"type": "object",
"properties": {
"page_id": {
"type": "string",
"description": "Confluence页面ID"
}
},
"required": ["page_id"]
}
""";
public Map<String, Object> listTools() {
List<Map<String, Object>> tools = List.of(
buildToolDef(
"confluence_search",
"搜索Confluence知识库,返回相关文档列表",
SEARCH_TOOL_SCHEMA
),
buildToolDef(
"confluence_get_page",
"获取指定Confluence页面的完整内容",
GET_PAGE_TOOL_SCHEMA
)
);
return Map.of("tools", tools);
}
public Map<String, Object> callTool(JsonNode params) {
String toolName = params.path("name").asText();
JsonNode arguments = params.path("arguments");
return switch (toolName) {
case "confluence_search" -> executeSearch(arguments);
case "confluence_get_page" -> executeGetPage(arguments);
default -> buildErrorResult("未知工具: " + toolName);
};
}
private Map<String, Object> executeSearch(JsonNode arguments) {
String query = arguments.path("query").asText();
String spaceKey = arguments.has("space_key") ?
arguments.path("space_key").asText() : null;
int limit = arguments.path("limit").asInt(5);
try {
List<ConfluenceSearchResult> results =
confluenceClient.search(query, spaceKey, limit);
if (results.isEmpty()) {
return buildTextResult("未找到相关文档。查询关键词: " + query);
}
StringBuilder sb = new StringBuilder();
sb.append("找到 ").append(results.size()).append(" 篇相关文档:\n\n");
for (int i = 0; i < results.size(); i++) {
ConfluenceSearchResult r = results.get(i);
sb.append(i + 1).append(". **").append(r.getTitle()).append("**\n");
sb.append(" - 空间: ").append(r.getSpaceName()).append("\n");
sb.append(" - 页面ID: ").append(r.getPageId()).append("\n");
sb.append(" - 摘要: ").append(r.getExcerpt()).append("\n");
sb.append(" - 最后更新: ").append(r.getLastModified()).append("\n\n");
}
return buildTextResult(sb.toString());
} catch (ConfluenceException e) {
log.error("Confluence搜索失败", e);
return buildErrorResult("搜索失败: " + e.getMessage());
}
}
private Map<String, Object> executeGetPage(JsonNode arguments) {
String pageId = arguments.path("page_id").asText();
try {
ConfluencePage page = confluenceClient.getPage(pageId);
String content = String.format("""
# %s
**空间**: %s
**最后更新**: %s
**作者**: %s
---
%s
""",
page.getTitle(),
page.getSpaceName(),
page.getLastModified(),
page.getLastModifiedBy(),
page.getMarkdownContent() // 需要把Confluence的HTML转为Markdown
);
return buildTextResult(content);
} catch (ConfluenceException e) {
log.error("获取Confluence页面失败: pageId={}", pageId, e);
return buildErrorResult("获取页面失败: " + e.getMessage());
}
}
// MCP工具结果格式:content数组 + isError标志
private Map<String, Object> buildTextResult(String text) {
return Map.of(
"content", List.of(Map.of("type", "text", "text", text)),
"isError", false
);
}
private Map<String, Object> buildErrorResult(String errorMessage) {
return Map.of(
"content", List.of(Map.of("type", "text", "text", errorMessage)),
"isError", true
);
}
private Map<String, Object> buildToolDef(String name, String description, String schemaJson) {
try {
return Map.of(
"name", name,
"description", description,
"inputSchema", objectMapper.readTree(schemaJson)
);
} catch (JsonProcessingException e) {
throw new RuntimeException("工具schema定义有误: " + name, e);
}
}
}Confluence API客户端
@Component
public class ConfluenceClient {
private final RestTemplate restTemplate;
private final String baseUrl;
private final String username;
private final String apiToken;
public ConfluenceClient(
@Value("${confluence.base-url}") String baseUrl,
@Value("${confluence.username}") String username,
@Value("${confluence.api-token}") String apiToken) {
this.baseUrl = baseUrl;
this.username = username;
this.apiToken = apiToken;
// 配置Basic Auth
this.restTemplate = new RestTemplateBuilder()
.basicAuthentication(username, apiToken)
.setConnectTimeout(Duration.ofSeconds(5))
.setReadTimeout(Duration.ofSeconds(30))
.build();
}
public List<ConfluenceSearchResult> search(String query, String spaceKey, int limit) {
String cqlQuery = buildCqlQuery(query, spaceKey);
String url = baseUrl + "/rest/api/content/search" +
"?cql=" + URLEncoder.encode(cqlQuery, StandardCharsets.UTF_8) +
"&limit=" + limit +
"&expand=excerpt,space,history";
ResponseEntity<ConfluenceSearchResponse> response =
restTemplate.getForEntity(url, ConfluenceSearchResponse.class);
return response.getBody().getResults().stream()
.map(this::mapToSearchResult)
.collect(Collectors.toList());
}
public ConfluencePage getPage(String pageId) {
String url = baseUrl + "/rest/api/content/" + pageId +
"?expand=body.storage,space,history,version";
ResponseEntity<ConfluencePageResponse> response =
restTemplate.getForEntity(url, ConfluencePageResponse.class);
ConfluencePageResponse pageData = response.getBody();
// 将Confluence的storage格式转换为Markdown
String markdownContent = convertStorageToMarkdown(
pageData.getBody().getStorage().getValue()
);
return ConfluencePage.builder()
.pageId(pageId)
.title(pageData.getTitle())
.spaceName(pageData.getSpace().getName())
.markdownContent(markdownContent)
.lastModified(pageData.getHistory().getLastUpdated().getWhen())
.lastModifiedBy(pageData.getHistory().getLastUpdated().getBy().getDisplayName())
.build();
}
private String buildCqlQuery(String query, String spaceKey) {
String cql = "text ~ \"" + query.replace("\"", "\\\"") +
"\" AND type = \"page\"";
if (spaceKey != null && !spaceKey.isBlank()) {
cql += " AND space = \"" + spaceKey + "\"";
}
cql += " ORDER BY lastModified DESC";
return cql;
}
}打包和部署
MCP Server需要打成可执行jar,Claude Desktop通过配置文件来启动它:
// Claude Desktop配置文件: ~/Library/Application Support/Claude/claude_desktop_config.json
{
"mcpServers": {
"confluence": {
"command": "java",
"args": [
"-jar",
"/path/to/confluence-mcp-server.jar",
"--confluence.base-url=https://your-company.atlassian.net",
"--confluence.username=your-email@company.com"
],
"env": {
"CONFLUENCE_API_TOKEN": "your-api-token"
}
}
}
}<!-- pom.xml 关键配置 -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!-- 禁止打入web服务器依赖,减小jar包体积 -->
<excludes>
<exclude>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>工程中踩的坑
坑1:日志不能写stdout。MCP通过stdout通信,如果你的日志也输出到stdout,Claude Desktop会收到混乱的消息。一定要把日志输出到stderr或者文件:
# application.properties
logging.file.name=/var/log/confluence-mcp-server.log
# 或者输出到stderr
logging.pattern.console=坑2:HTML转Markdown的坑。Confluence的内容是以storage格式(XHTML变体)存储的,直接返回给AI效果很差。我用了flexmark库做转换,但Confluence有些自定义宏(比如jira issue宏、代码块宏)flexmark不认识,需要自己写预处理逻辑去掉或转换这些宏。
坑3:内容太长被截断。一篇Confluence文档可能有几万字,超出上下文窗口。建议在工具里做智能截断:对内容做TF-IDF打分,只返回和查询最相关的段落,而不是全文。
坑4:搜索召回率低。Confluence的CQL全文搜索对中文支持不好。改用向量搜索后效果好很多:先把文档分片并向量化存到pgvector,搜索时计算语义相似度,再去Confluence拉原文。
这个MCP Server在我们内部上线后,员工问内部流程类问题的满足率从原来的40%提升到了85%,主要原因就是AI终于能访问公司的知识库了。
