MCP 开发实战:Git 信息查询 MCP 服务开发

**摘要:**本文围绕MCP开发实战展开,详细介绍了 Git 信息查询 MCP 服务的完整开发流程,包括服务端与客户端的具体实现。

MCP 开发实战 - 信息查询服务

MCP 服务端开发

可以使用 JGit 或直接调用系统 Git 命令来构建 Git 信息查询服务。

1)首先在项目根目录下新建 module,名称为 git-query-mcp-server


2)引入必要依赖,包括 Lombok、hutool、Spring AI MCP 服务端依赖,以及 Git 操作依赖:

有 Stdio、WebMVC SSE 和 WebFlux SSE 三种服务端依赖可以选择,开发时只需要填写不同的配置,开发流程都是一样的。此处我们选择引入 WebMVC:

XML 复制代码
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-mcp-server-webmvc-spring-boot-starter</artifactId>
    <version>1.0.0-M6</version>
</dependency>


<dependency>
    <groupId>org.eclipse.jgit</groupId>
    <artifactId>org.eclipse.jgit</artifactId>
    <version>6.10.0.202406071900-r</version>
</dependency>

引入这个依赖后,会自动注册 SSE 端点,供客户端调用,包括消息和 SSE 传输端点。


3)在 resources 目录下编写服务端配置文件,两套配置方案分别实现 stdio 和 SSE 模式的传输。

stdio 配置文件 application-stdio.yml(需关闭 web 支持):

bash 复制代码
spring:
  ai:
    mcp:
      server:
        name: git-query-mcp-server
        version: 0.0.1
        type: SYNC
        # stdio
        stdio: true
  # stdio
  main:
    web-application-type: none
    banner-mode: off

SSE 配置文件 application-sse.yml(需关闭 stdio 模式):

bash 复制代码
spring:
  ai:
    mcp:
      server:
        name: git-query-mcp-server
        version: 0.0.1
        type: SYNC
        # sse
        stdio: false

然后编写主配置文件 application.yml,可以灵活指定激活哪套配置:

bash 复制代码
spring:
  application:
    name: git-query-mcp-server
  profiles:
    active: stdio
server:
  port: 8128

4)编写 Git 查询服务类,在 tools 包下新建 GitQueryTool,使用 @Tool 注解标注方法,作为 MCP 服务提供的工具。

java 复制代码
@Service
public class GitQueryTool {
​
    // 项目Git仓库根路径(可配置化)
    private static final String REPO_PATH = System.getProperty("user.dir");
​
    @Tool(description = "查询Git仓库当前状态")
    public String getGitStatus() {
        try {
            return GitUtil.getGitStatus(REPO_PATH);
        } catch (Exception e) {
            return "查询Git状态失败:" + e.getMessage();
        }
    }
​
    @Tool(description = "查询最近N条提交记录")
    public String getRecentCommits(@ToolParam(description = "查询的提交条数") int count) {
        try {
            return GitUtil.getRecentCommits(REPO_PATH, count);
        } catch (Exception e) {
            return "查询提交记录失败:" + e.getMessage();
        }
    }
​
    @Tool(description = "查询指定文件的变更历史")
    public String getFileHistory(@ToolParam(description = "文件相对路径") String filePath) {
        try {
            return GitUtil.getFileHistory(REPO_PATH, filePath);
        } catch (Exception e) {
            return "查询文件历史失败:" + e.getMessage();
        }
    }
​
    @Tool(description = "查询当前Git分支信息")
    public String getBranchInfo() {
        try {
            return GitUtil.getBranchInfo(REPO_PATH);
        } catch (Exception e) {
            return "查询分支信息失败:" + e.getMessage();
        }
    }
}
java 复制代码
public class GitUtil {

    private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");


    /**
     * 获取 Git 状态
     */
    public static String getGitStatus(String repoPath) throws IOException, GitAPIException {
        try (Repository repository = openRepository(repoPath)) {
            try (Git git = new Git(repository)) {
                Status status = git.status().call();
                StringBuilder sb = new StringBuilder();

                sb.append("=== Git 工作区状态 ===\n");
                sb.append("分支: ").append(repository.getBranch()).append("\n\n");

                Set<String> added = status.getAdded();
                Set<String> changed = status.getChanged();
                Set<String> modified = status.getModified();
                Set<String> removed = status.getRemoved();
                Set<String> untracked = status.getUntracked();
                Set<String> conflicting = status.getConflicting();

                if (!added.isEmpty()) {
                    sb.append("【已暂存的新文件】(").append(added.size()).append(")\n");
                    added.forEach(f -> sb.append("  + ").append(f).append("\n"));
                    sb.append("\n");
                }

                if (!changed.isEmpty()) {
                    sb.append("【已暂存的修改】(").append(changed.size()).append(")\n");
                    changed.forEach(f -> sb.append("  ~ ").append(f).append("\n"));
                    sb.append("\n");
                }

                if (!modified.isEmpty()) {
                    sb.append("【未暂存的修改】(").append(modified.size()).append(")\n");
                    modified.forEach(f -> sb.append("  M ").append(f).append("\n"));
                    sb.append("\n");
                }

                if (!removed.isEmpty()) {
                    sb.append("【已删除的文件】(").append(removed.size()).append(")\n");
                    removed.forEach(f -> sb.append("  - ").append(f).append("\n"));
                    sb.append("\n");
                }

                if (!untracked.isEmpty()) {
                    sb.append("【未跟踪的文件】(").append(untracked.size()).append(")\n");
                    untracked.stream().limit(20).forEach(f -> sb.append("  ? ").append(f).append("\n"));
                    if (untracked.size() > 20) {
                        sb.append("  ... 还有 ").append(untracked.size() - 20).append(" 个文件\n");
                    }
                    sb.append("\n");
                }

                if (!conflicting.isEmpty()) {
                    sb.append("【冲突文件】(").append(conflicting.size()).append(")\n");
                    conflicting.forEach(f -> sb.append("  ! ").append(f).append("\n"));
                    sb.append("\n");
                }

                if (added.isEmpty() && changed.isEmpty() && modified.isEmpty()
                        && removed.isEmpty() && untracked.isEmpty() && conflicting.isEmpty()) {
                    sb.append("工作区干净,没有变更\n");
                }

                return sb.toString();
            }
        }
    }

    /**
     * 获取最近 N 条提交记录
     */
    public static String getRecentCommits(String repoPath, int count) throws IOException, GitAPIException {
        try (Repository repository = openRepository(repoPath)) {
            try (Git git = new Git(repository)) {
                Iterable<RevCommit> commits = git.log().setMaxCount(count).call();
                StringBuilder sb = new StringBuilder();

                sb.append("=== 最近 ").append(count).append(" 条提交记录 ===\n\n");

                int index = 1;
                for (RevCommit commit : commits) {
                    sb.append("[").append(index++).append("] ")
                            .append(commit.getName().substring(0, 8)).append("\n");
                    sb.append("    作者: ").append(commit.getAuthorIdent().getName())
                            .append(" <").append(commit.getAuthorIdent().getEmailAddress()).append(">\n");
                    sb.append("    日期: ").append(DATE_FORMAT.format(commit.getAuthorIdent().getWhen())).append("\n");
                    sb.append("    提交: ").append(commit.getShortMessage()).append("\n");
                    sb.append("\n");
                }

                return sb.toString();
            }
        }
    }

    /**
     * 获取文件变更历史
     */
    public static String getFileHistory(String repoPath, String filePath) throws IOException, GitAPIException {
        try (Repository repository = openRepository(repoPath)) {
            try (Git git = new Git(repository)) {
                Iterable<RevCommit> commits = git.log()
                        .addPath(filePath)
                        .setMaxCount(20)
                        .call();

                StringBuilder sb = new StringBuilder();
                sb.append("=== 文件历史: ").append(filePath).append(" ===\n\n");

                int index = 1;
                for (RevCommit commit : commits) {
                    sb.append("[").append(index++).append("] ")
                            .append(commit.getName().substring(0, 8)).append("\n");
                    sb.append("    作者: ").append(commit.getAuthorIdent().getName()).append("\n");
                    sb.append("    日期: ").append(DATE_FORMAT.format(commit.getAuthorIdent().getWhen())).append("\n");
                    sb.append("    提交: ").append(commit.getShortMessage()).append("\n");
                    sb.append("\n");
                }

                if (index == 1) {
                    sb.append("未找到该文件的提交历史\n");
                }

                return sb.toString();
            }
        }
    }


    /**
     * 获取分支信息
     */
    public static String getBranchInfo(String repoPath) throws IOException, GitAPIException {
        try (Repository repository = openRepository(repoPath)) {
            try (Git git = new Git(repository)) {
                StringBuilder sb = new StringBuilder();
                sb.append("=== Git 分支信息 ===\n\n");

                // 当前分支
                String currentBranch = repository.getBranch();
                sb.append("当前分支: ").append(currentBranch).append("\n\n");

                // 所有本地分支
                List<Ref> localBranches = git.branchList().call();
                sb.append("本地分支 (").append(localBranches.size()).append("):\n");
                for (Ref branch : localBranches) {
                    String name = branch.getName().replace("refs/heads/", "");
                    if (name.equals(currentBranch)) {
                        sb.append("  * ").append(name).append(" (当前)\n");
                    } else {
                        sb.append("    ").append(name).append("\n");
                    }
                }

                // 远程分支
                try {
                    List<Ref> remoteBranches = git.branchList().setListMode(org.eclipse.jgit.api.ListBranchCommand.ListMode.REMOTE).call();
                    sb.append("\n远程分支 (").append(remoteBranches.size()).append("):\n");
                    for (Ref branch : remoteBranches) {
                        sb.append("    ").append(branch.getName()).append("\n");
                    }
                } catch (Exception e) {
                    sb.append("\n远程分支: 获取失败 - ").append(e.getMessage()).append("\n");
                }

                return sb.toString();
            }
        }
    }

    /**
     * 打开 Git 仓库
     */
    public static Repository openRepository(String repoPath) throws IOException {
        File gitDir = Paths.get(repoPath, ".git").toFile();
        if (!gitDir.exists()) {
            throw new IOException("Git 仓库不存在: " + repoPath);
        }

        FileRepositoryBuilder builder = new FileRepositoryBuilder();
        return builder.setGitDir(gitDir)
                .readEnvironment()
                .findGitDir()
                .build();
    }

}

编写对应的单元测试类,验证工具是否可用:

java 复制代码
@SpringBootTest
class GitQueryToolTest {
​
    @Resource
    private GitQueryTool gitQueryTool;
​
    @Test
    void queryGitInfo() {
        // 测试查询Git状态
        String status = gitQueryTool.getGitStatus();
        Assertions.assertNotNull(status);
        // 测试查询最近5条提交
        String commits = gitQueryTool.getRecentCommits(5);
        Assertions.assertNotNull(commits);
    }
}

5)在主类中通过定义 ToolCallbackProvider Bean 来注册工具:

java 复制代码
@SpringBootApplication
public class GitQueryMcpServerApplication {
​
    public static void main(String[] args) {
        SpringApplication.run(GitQueryMcpServerApplication.class, args);
    }
​
    @Bean
    public ToolCallbackProvider gitQueryTools(GitQueryTool gitQueryTool) {
        return MethodToolCallbackProvider.builder()
                .toolObjects(gitQueryTool)
                .build();
    }
}

6)至此开发完成,最后使用 Maven Package 命令打包,会在 target 目录下生成可执行的 JAR 包,供客户端调用。

客户端开发

接下来直接在根项目中开发客户端,调用刚才创建的 Git 查询服务。

1)先引入必要的 MCP 客户端依赖:

XML 复制代码
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-mcp-client-spring-boot-starter</artifactId>
    <version>1.0.0-M6</version>
</dependency>

实际开发中,可按需添加 WebFlux 支持,需与服务端模式匹配。

2)测试 stdio 传输方式。在 mcp-servers.json 配置文件中新增 MCP Server 配置,通过 java 命令执行打包好的 jar 包:

XML 复制代码
{
  "mcpServers": {
    "git-query-mcp-server": {
      "command": "java",
      "args": [
        "-Dspring.ai.mcp.server.stdio=true",
        "-Dspring.main.web-application-type=none",
        "-Dlogging.pattern.console=",
        "-jar",
        "git-query-mcp-server/target/git-query-mcp-server-0.0.1-SNAPSHOT.jar"
      ],
      "env": {}
    }
  }
}

3)测试运行,编写单元测试代码:

java 复制代码
@Test
void doChatWithGitMcp() {
    // 测试Git查询 MCP
    String message = "帮我查询一下项目最近的5次提交记录";
    String answer = loveApp.doChatWithMcp(message, chatId);
    Assertions.assertNotNull(answer);
}

运行后可看到 MCP 服务提供的工具被成功加载,输出 Git 相关信息。

4)测试 SSE 连接方式,首先修改 MCP 服务端的配置文件,激活 SSE 的配置:

bash 复制代码
spring:
  application:
    name: git-query-mcp-server
  profiles:
    active: sse
server:
  port: 8128

以 Debug 模式启动 MCP 服务。

然后修改客户端的配置文件,添加 SSE 配置,注释原有的 stdio 配置避免端口冲突:

bash 复制代码
spring:
  ai:
    mcp:
      client:
        sse:
          connections:
            server1:
              url: http://localhost:8128
        # stdio:
        # servers-configuration: classpath:mcp-servers.json

测试运行,SSE 模式下可更方便地调试 MCP 服务。

MCP 开发最佳实践

在学会如何开发 MCP 服务端和客户端后,我们来学习一些 MCP 开发的最佳实践。

1)慎用 MCP:MCP 不是银弹,其本质就是工具调用,只不过统一了标准、更容易共享而已。如果我们自己开发一些不需要共享的工具,完全没必要使用 MCP,可以节约开发和部署成本。我个人的建议是能不用就不用,先开发工具调用,之后需要提供 MCP 服务时再将工具调用转换成 MCP 服务即可。

2)传输模式选择:Stdio 模式作为客户端子进程运行,无需网络传输,因此安全性和性能都更高,更适合小型项目;SSE 模式适合作为独立服务部署,可以被多客户端共享调用,更适合模块化的中大型项目团队。

3)明确服务:设计 MCP 服务时,要合理划分工具和资源,并且利用 @Tool、@ToolParam 注解尽可能清楚地描述工具的作用,便于 AI 理解和选择调用。

4)注意容错:和工具开发一样,要注意 MCP 服务的容错性和健壮性,捕获并处理所有可能的异常,并且返回友好的错误信息,便于客户端处理。

5)性能优化:MCP 服务端要防止单次执行时间过长,可以采用异步模式来处理耗时操作,或者设置超时时间。客户端也要合理设置超时时间,防止因为 MCP 调用时间过长而导致 AI 应用阻塞。

6)跨平台兼容性:开发 MCP 服务时,应该考虑在 Windows、Linux 和 macOS 等不同操作系统上的兼容性。特别是使用 stdio 传输模式时,注意路径分隔符差异、进程启动方式和环境变量设置。比如客户端在 Windows 系统中使用命令时需要额外添加 .cmd 后缀。

MCP 部署方案

由于 MCP 的传输方式分为 stdio(本地)和 SSE(远程),因此 MCP 的部署也可以对应分为 本地部署 和 远程部署,部署过程和部署一个后端项目的流程基本一致。

本地部署

适用于 stdio 传输方式。其流程与我们开发 MCP 的流程一致,只需将 MCP Server 的代码打包,然后上传到 MCP Client 可访问到的路径下,通过编写对应的 MCP 配置即可启动。

举个例子,我们的后端项目放到了服务器 A 上,如果这个项目需要调用 java 开发的 MCP Server,就要把 MCP Server 的可执行 jar 包也放到服务器 A 上。

这种方式简单直接,适合小项目,但缺点也很明显:每个 MCP 服务都要单独部署(放到服务器上),如果 MCP 服务数量较多,操作起来会非常繁琐。这时你不禁会想:我为什么不直接在后端项目中开发工具调用,非要新搞个项目开发 MCP 呢?

远程部署

适用于 SSE؜؜؜ 传输方式。远程部署 MC⁠⁠⁠P 服务的流程跟部署一个后‏‏‏端 web 项目是一样的,‌‌‌都需要在服务器上部署服务(‏‏‏比如 jar 包)并运行

除了部署到自己的服务器之外,؜؜؜由于 MCP 服务一般都是职责单一的小型项目,很适合部⁠⁠⁠署到 Serverless 平台上。使用 Server‏‏‏less 平台,开发者只需关注业务代码的编写,无需管理‌‌‌服务器等基础设施,系统会根据实际使用量自动扩容并按使用‏‏‏付费,从而显著降低运维成本和开发复杂度。

百炼提供了详细的 使用和部署 MCP 服务指南,可以将自己的 MCP 服务部署到阿里云函数计算平台,实现 Serverless 部署。

友情؜؜؜提示,如果是学习⁠⁠使⁠用,建议及时删‏‏除 ‏MCP 服务‌‌哦,会‌自动关联删‏‏除函数‏计算资源。💡

提交至平台

还可以将 MCP 服务提交到各种第三方 MCP 服务市场,类似于将应用发布到应用商店,让其他人也能使用你的 MCP 服务。

这种做法的好处可以参考开源模式,直白来说,至少能提升技术影响力、收获一波流量,这也是大公司会迅速在 MCP 服务市场布局的原因。


恭喜你学习完成!❀

相关推荐
书到用时方恨少!2 小时前
Python NumPy 使用指南:科学计算的基石
开发语言·python·numpy
2501_933329552 小时前
技术深度拆解:Infoseek舆情系统的全链路架构与核心实现
开发语言·人工智能·分布式·架构
赵丙双2 小时前
spring boot 排除自动配置类的方式和原理
java·spring boot·自动配置
8Qi82 小时前
LeetCode热题100--45.跳跃游戏 II
java·算法·leetcode·贪心算法·编程
REDcker2 小时前
Git分支可视化管理面板设计与选型
git
bilI LESS2 小时前
Spring Boot接收参数的19种方式
java·spring boot·后端
web前端进阶者2 小时前
Rust初学知识点快速记忆
开发语言·后端·rust
lucky九年3 小时前
GO语言模拟C++封装,继承,多态
开发语言·c++·golang
九皇叔叔3 小时前
004-SpringSecurity-Demo 拆分环境
java·springboot3·springsecurity