从零实现一个自动提交 PR 的 MCP 工具:用 Skill 串起 Git 与 Azure DevOps

每次开发完成后,创建 PR 往往要重复几件事:确认当前分支、找目标分支、整理变更说明、打开代码平台填写表单。这里分享一个 code-pr-submit 的实现思路:让智能体读取本地 Git 上下文,由 MCP 工具调用 Azure DevOps API,最后给出可点击的 PR 链接。

本文会先做一个可以运行的最小版本,再解释如何补上父分支推断、预览、查重和测试。示例中的组织、项目和仓库都由读者自行填写;不依赖特定公司的 npm 包或内部文档。

先分清两层职责

MCP 服务负责提供可调用的能力 ,例如"查询已有 PR""创建 PR"。Skill 负责告诉智能体什么时候以及按什么顺序使用这些能力 ,例如先读 Git、再生成描述、最后决定预览还是提交。把两层分开后,业务流程可以调整,而访问 Azure DevOps 的代码不必跟着重写。关于 Skill 与 MCP 的这种分工,可参考 OpenAI 的 Skill 说明。

text 复制代码
用户提出请求
    ↓
Skill 读取本地 Git 信息,确定源分支和目标分支
    ↓
Skill 整理标题与描述,检查是否允许创建
    ↓
MCP 工具查询 Azure DevOps,再创建或返回已有 PR

本文使用的分支方向始终是:

text 复制代码
feature/order-search(源分支,带改动) -> release/1.8(目标分支,接收改动)

Azure DevOps 的 API 字段分别叫 sourceRefName 和 targetRefName。不要因为"当前分支""父分支"这些业务说法,把两个字段填反。微软的创建 PR 接口文档给出了字段定义。

第一步:建立最小项目

准备 Node.js 20 或更高版本,创建一个空目录:

bash 复制代码
mkdir pr-mcp-demo
cd pr-mcp-demo
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/sdk@1.30.0 zod@4.4.3

这里固定 SDK 版本,是为了让下面的代码与本文解析的项目实现保持一致。官方 TypeScript SDK 也有 v2 文档,它的包名和 stdio 启动方式与本文示例不同;照着本文操作时请保留上述版本。

一个能提供工具的 MCP 服务,核心只有四部分:McpServer 实例、工具的名称和参数定义、处理函数、传输层。README.md、Skill、测试文件和发布脚本都不是服务启动的前提。官方 SDK 的 v1 服务文档展示了相同的基本结构。

第二步:写一个真正会创建 PR 的工具

在项目根目录创建 index.js。下面是完整的最小实现:它从环境变量读取 PAT,先查相同方向的活跃 PR,再决定是否创建。目标是 main 或 master 时,需要调用方明确传入确认参数。

js 复制代码
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';

const server = new McpServer({ name: 'pr-mcp-demo', version: '1.0.0' });

function ref(branch) {
  return branch.startsWith('refs/heads/') ? branch : `refs/heads/${branch}`;
}

function result(data, isError = false) {
  return {
    content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
    ...(isError ? { isError: true } : {})
  };
}

server.registerTool(
  'create_pr',
  {
    description: '在 Azure DevOps 创建 PR;相同方向的活跃 PR 已存在时返回其链接',
    inputSchema: {
      organization: z.string().min(1),
      project: z.string().min(1),
      repository: z.string().min(1),
      sourceBranch: z.string().min(1),
      targetBranch: z.string().min(1),
      title: z.string().min(1),
      description: z.string().optional(),
      confirmMain: z.boolean().optional()
    }
  },
  async input => {
    const sourceRefName = ref(input.sourceBranch);
    const targetRefName = ref(input.targetBranch);
    const targetName = targetRefName.slice('refs/heads/'.length).toLowerCase();

    if (sourceRefName === targetRefName) {
      return result({ error: '源分支和目标分支不能相同' }, true);
    }
    if (['main', 'master'].includes(targetName) && input.confirmMain !== true) {
      return result({ error: '目标是主分支,需要先取得用户明确确认' }, true);
    }

    const pat = process.env.AZURE_DEVOPS_PAT;
    if (!pat) return result({ error: '缺少 AZURE_DEVOPS_PAT' }, true);

    const { organization, project, repository } = input;
    const base = `https://dev.azure.com/${encodeURIComponent(organization)}`
      + `/${encodeURIComponent(project)}/_apis/git/repositories/`
      + `${encodeURIComponent(repository)}/pullrequests`;
    const webUrl = id => `https://dev.azure.com/${encodeURIComponent(organization)}`
      + `/${encodeURIComponent(project)}/_git/${encodeURIComponent(repository)}`
      + `/pullrequest/${encodeURIComponent(id)}`;
    const headers = {
      Authorization: `Basic ${Buffer.from(`:${pat}`).toString('base64')}`,
      Accept: 'application/json'
    };

    try {
      const query = new URLSearchParams({
        'searchCriteria.status': 'active',
        'searchCriteria.sourceRefName': sourceRefName,
        'searchCriteria.targetRefName': targetRefName,
        '$top': '1',
        'api-version': '7.1'
      });
      const existingResponse = await fetch(`${base}?${query}`, { headers });
      if (!existingResponse.ok) {
        return result({ error: `查询已有 PR 失败:HTTP ${existingResponse.status}` }, true);
      }
      const existing = (await existingResponse.json()).value?.[0];
      if (existing) {
        if (!existing.pullRequestId) return result({ error: '已有 PR 缺少 ID' }, true);
        return result({
          created: false,
          alreadyExists: true,
          url: existing._links?.web?.href ?? webUrl(existing.pullRequestId)
        });
      }

      const createResponse = await fetch(`${base}?api-version=7.1`, {
        method: 'POST',
        headers: { ...headers, 'Content-Type': 'application/json' },
        body: JSON.stringify({
          sourceRefName,
          targetRefName,
          title: input.title,
          description: input.description ?? ''
        })
      });
      if (!createResponse.ok) {
        return result({ error: `创建 PR 失败:HTTP ${createResponse.status}` }, true);
      }
      const created = await createResponse.json();
      if (!created.pullRequestId) return result({ error: '创建响应缺少 PR ID' }, true);
      return result({
        created: true,
        alreadyExists: false,
        id: created.pullRequestId,
        url: created._links?.web?.href ?? webUrl(created.pullRequestId)
      });
    } catch (error) {
      return result({ error: error instanceof Error ? error.message : String(error) }, true);
    }
  }
);

await server.connect(new StdioServerTransport());

这里有几个容易忽略的点:

  1. inputSchema 让 SDK 在处理函数执行前校验参数;处理函数返回的 content 才是客户端能读取的工具结果。
  2. PAT 从运行环境读取,不放进工具参数或文章示例。调用 Azure DevOps 时,HTTP Basic 的用户名留空,PAT 放在密码位置。
  3. 查询时同时筛选 sourceRefName、targetRefName 和 active,避免把反方向的 PR 当成同一个。对应的查询参数见微软的查询 PR 接口文档。
  4. stdio 的标准输出是协议通道,不要在服务中用 console.log 打印调试信息;需要日志时写到标准错误。这个要求也见于官方 SDK 文档。

示例刻意保持短小。生产代码还应给网络请求设置超时,在创建返回冲突时再次查询,并核对 PR 创建响应中的 ID 和链接。本文所参考的完整实现也把这些情况单独处理了。

第三步:让客户端启动它

把可访问 Azure DevOps 的 PAT 放进启动客户端的环境变量中,并确保它具有创建 PR 所需权限。不要把 PAT 写进 index.js、Skill 文件或 Git 仓库。微软接口文档列出了创建 PR 所需的代码写入权限。

若使用 Codex,可在 ~/.codex/config.toml 中加入:

toml 复制代码
[mcp_servers.pr-mcp-demo]
command = "node"
args = ["/你的绝对路径/pr-mcp-demo/index.js"]
env_vars = ["AZURE_DEVOPS_PAT"]

重启客户端后,它会启动这个进程并发现 create_pr。单独运行 node index.js 后看起来一直在等待,是因为 stdio 服务在等客户端发送协议消息。想先独立检查工具列表,可以用官方 MCP Inspector:

bash 复制代码
npx @modelcontextprotocol/inspector node index.js

在 Inspector 中选择 create_pr,填入自己的组织、项目、仓库和分支即可调用。这个工具真的会创建 PR;初次验证可先使用专门的测试仓库与非主分支。

第四步:把"自动获取上下文"写进 Skill

只有 MCP 时,create_pr 仍要求调用方填写组织、仓库和分支。Skill 才是"提交当前分支的 PR"这一句话能够生效的关键:它指导智能体先读本地 Git,决定目标分支,区分预览和创建,再把准确的参数交给 MCP。官方 Skill 编写说明也强调要写清触发条件、步骤、输出以及何时停止。

下面做一个与前文 create_pr 参数完全对应 的 Skill。以本例使用的个人 Skill 目录为例,先创建 ~/.agents/skills/code-pr-submit/,将以下内容保存为其中的 SKILL.md:

markdown 复制代码
---
name: code-pr-submit
description: 用户要求预览或创建当前 Git 分支的 Azure DevOps PR 时,读取本地仓库和分支信息,整理标题与描述,并按请求决定是否调用 pr-mcp-demo 的 create_pr 工具。
---

# 提交当前分支的 PR

只处理当前 Git 项目。用户明确给出的目标分支、标题和描述优先。
通过 pr-mcp-demo 服务中的 create_pr 工具访问 Azure DevOps;不要读取、打印或传递 PAT。

## 识别仓库

1. 运行 `git rev-parse --show-toplevel`、`git branch --show-current`、
   `git remote get-url origin`。任一命令失败,或当前处于 detached HEAD 时停止,
   告诉用户缺少什么信息。
2. 将当前分支记为 sourceBranch。从 origin URL 解析:
   - `https://dev.azure.com/{organization}/{project}/_git/{repository}`
   - `git@ssh.dev.azure.com:v3/{organization}/{project}/{repository}`
   URL 中的用户名不是 organization;仓库名末尾的 `.git` 要去掉。
   无法唯一解析时,只询问缺失字段,不猜测。

## 确定目标分支

1. 用户指定了目标分支就使用它。否则查看当前分支最早的 reflog:
   `git reflog show --format='%H%x09%gs' <当前分支>`。
   如果创建记录是 `branch: Created from <分支名>`,且该分支仍可解析,
   用它作为 targetBranch。
2. reflog 没给出明确分支时,列出本地与 origin 的候选分支,使用
   `git merge-base HEAD <候选分支>` 比较共同祖先。多个候选同样可信,
   或没有可靠记录时,向用户展示候选并请其选择;不能直接假定为 main。
3. 去掉 targetBranch 的 `origin/` 或 `refs/heads/` 前缀。
   如果 sourceBranch 与 targetBranch 相同,停止。

## 准备标题和描述

1. 用户没有给标题时,运行 `git log --no-merges -1 --format=%s`,
   用最新的非合并提交标题作为初稿。
2. 运行 `git fetch origin <targetBranch>` 更新目标分支比较基线,随后运行
   `git diff --stat FETCH_HEAD...HEAD` 和 `git log --oneline FETCH_HEAD..HEAD`。
   抓取或差异读取失败时停止并说明原因。
3. 根据真实变更写简短 Markdown 描述;不编造未看到的功能。
4. 用户只说"预览"或"准备"时,输出
   `sourceBranch -> targetBranch`、标题和描述,到此停止,绝不调用 create_pr。

## 创建前检查

1. 只有用户明确要求"创建"或"提交"才进入此步骤。
2. 运行 `git status --short`;有未提交或未跟踪文件时停止,说明它们
   不会进入 PR。
3. 运行 `git ls-remote --exit-code origin refs/heads/<sourceBranch>`。
   远端没有源分支时停止,提示用户先推送;不要自行 git push。
   比较远端返回的 SHA 与 `git rev-parse HEAD`;不一致时停止,提示先同步。
4. 目标为 main/master 时,明确展示 `sourceBranch -> targetBranch`,
   取得用户单独确认后,才能把 confirmMain 设为 true。

## 创建并报告

调用 pr-mcp-demo 服务的 create_pr,参数一一对应:
organization、project、repository、sourceBranch、targetBranch、title、description;
只有主分支确认完成后才传 `confirmMain: true`。
返回 `created: true` 时给出新 PR 链接;返回 `alreadyExists: true` 时说明
没有重复创建并给出现有链接。工具返回错误或结果不明确时,报告原错误,
不要盲目重试创建。

安装后重启客户端,在一个指向 Azure DevOps 的测试仓库中先试只读请求:

text 复制代码
使用 $code-pr-submit 预览当前分支的 PR

预览应给出源分支、目标分支、标题和描述,但不产生新 PR。接着可在测试分支上试创建:

text 复制代码
使用 $code-pr-submit 把当前分支提交到 develop

这份 Skill 已覆盖"发现、判断、预览、提交、报告"的基本闭环,但父分支推断仍是启发式。reflog 可能被清理,多个分支也可能共享同一个 merge-base;证据不足时应让用户选择。要支持更多仓库,还可补上含用户名的 HTTPS、visualstudio.com 和其他 SSH remote 解析规则。

完整版本通常还会把 get_branch_diff 和 read_file_at_ref 做成只读工具,让描述基于远端真实内容生成。这样,MCP 提供"读取与创建"的原子能力,Skill 负责"先读、再判断、最后写"的流程。

第五步:补上测试和交付保护

最小示例能跑通,但创建 PR 属于写操作,至少要覆盖以下场景:

场景 期望结果
源分支与目标分支相同 本地拒绝,不发 HTTP 请求
目标为 main 或 master 且未确认 拒绝创建
同方向活跃 PR 已存在 返回原链接,不重复创建
查询 Azure DevOps 失败 停止创建,显示错误
创建成功 返回 PR ID 和网页链接
工具启动 客户端能通过 stdio 列出并调用工具

可以用 Node.js 的 node:test 模拟 fetch,再使用 SDK 的 Client 和 StdioClientTransport 做一次真实的协议连接测试。单测验证业务分支,连接测试验证 index.js 是否真的把工具暴露给客户端。发布成 npm 命令时,再给 package.json 增加 bin 映射;仅在本机使用时,绝对路径启动已经足够。

回看目录里的每个文件

把最小示例演进为可维护项目后,通常会得到下面的结构:

text 复制代码
pr-mcp-demo/
├── package.json
├── index.js
├── azure-devops.js
├── index.test.js
├── config.toml.example
└── README.md

index.js 注册工具并连接 stdio;azure-devops.js 封装 API、认证和 PR 业务;index.test.js 测试业务与 MCP 连接;package.json 管理运行依赖和命令;config.toml.example 帮使用者配置客户端;README.md 记录环境变量、启动和验证方法。真正让 MCP 服务运行的核心是前两项职责,文件是否拆开由项目规模决定。

这套设计的关键不在"让模型替人点创建按钮",而在把分支方向、证据来源、创建条件和返回状态说清楚。先把一个工具做成可调用、可验证的服务,再用 Skill 编排它,自动提交 PR 才会成为可维护的工作流。

相关推荐
喜欢吃豆1 小时前
Agent 前端协议正在分层:彻底讲清 MCP Apps、AG-UI 与 A2UI
前端·大模型
赵锦川1 小时前
css代替表格
前端·css
其实防守也摸鱼1 小时前
内网安全防护实践:从攻击视角理解防御要点
java·前端·安全·架构·自动化·nps
JavaGuide2 小时前
DeepSeek Harness 官方桌面端终于有了!
前端·后端
wshzd2 小时前
LLM之Agent(104)|DeepSeek-Harness(十三)ReactLoopAgent 总览:kick → turn → step
开发语言·前端·javascript
茉莉玫瑰花茶2 小时前
OpenGL [ 基础概念 ]
java·前端·数据库
随性而行3602 小时前
企业微信二次开发如何接入大模型工具?API接口实现智能任务调用的技术思路
java·前端·人工智能·python·微信·机器人·企业微信
IMPYLH2 小时前
HTML 的 <td> 元素
前端·html
gnip3 小时前
Flutter GetX 三件套开发规范(Skill)
前端·flutter