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 服务市场布局的原因。


恭喜你学习完成!❀

相关推荐
Scott9999HH4 小时前
【IIoT流量实战】蒸汽管道阀门全关却仍有流量?用 Python 实现涡街信号 FFT 频谱分析与温压全补偿积算网关,深度拆解靠谱的涡街流量计厂家硬核技术标准
开发语言·python
腻害兔4 小时前
【若依项目-产品经理视角】深度拆解 RuoYi-Vue-Pro 商城模块:从商品管理到交易引擎,50 张表撑起一整套电商系统
java·大数据·vue.js·产品经理·ai编程
zzqssliu4 小时前
煤炉自动代拍系统的队列设计与超时控制机制
git·github
一支绝命钩4 小时前
FPGA工程Git常用操作手册
git
码智社5 小时前
AES加密原理详解及Java实现加解密实战
java·开发语言
AI云海5 小时前
python 列表、元组、集合和字典
开发语言·python
萧瑟余晖6 小时前
JDK 26 新特性详解
java·开发语言
马优晨6 小时前
Freemarker 完整讲解(后端 Java 模板引擎)
java·开发语言·freemarker·freemarker 完整讲解·freemarker模板引擎
人邮异步社区8 小时前
怎么把C语言学到精通?
c语言·开发语言
不搞学术柒柒8 小时前
Git新功能完整开发提交流程
git