
1. 为什么我要自己造一个自动提 PR 的轮子团队里用 Azure DevOps 做代码托管已经两年多了日常开发流程里最烦的其实不是写代码而是每次改完一堆文件之后那套机械动作切分支、写 commit message、push、打开浏览器、找到目标仓库、点 New Pull Request、填标题、填描述、选 reviewer、关联工作项。一套下来少说五分钟多的时候十分钟就没了而且这十分钟里你脑子是空转的纯纯的体力活。一开始我也试过用现成的脚本糊一个比如写个 bash 把 git 命令串起来再调 Azure DevOps 的 REST API 创建 PR。能用但问题很明显脚本是死的参数全靠命令行传稍微复杂一点的需求比如根据改动文件自动推断 reviewer、根据分支名自动关联工作项就得改脚本。后来 MCPModel Context Protocol这个概念火起来我意识到这东西天生适合干这种把多个工具串起来、由模型决定调用顺序的活。MCP 本质上是一个标准化的协议让模型能够以统一的方式调用外部工具而 Skill 则是把这些工具能力封装成模型可以理解和编排的单元。所以这个项目的目标就很明确了做一个 MCP 工具把 Git 的本地操作和 Azure DevOps 的远端 API 串起来让模型通过 Skill 编排实现我说一句把这个改动提个 PR它自己把剩下的事全干完。关键词里的 MCP、Git、Azure DevOps、PR、Skill 这五个词基本就是这个项目的全部骨架。这篇文章我会把整个实现过程拆开讲包括我踩过的坑、为什么这么设计、以及一些文档里不会写的细节。先说清楚适合谁看如果你已经在用 Azure DevOps 或者类似的代码平台日常有大量重复的提交 PR 操作并且对 MCP 有基本了解哪怕只是听说过那这篇能直接抄作业。如果你完全没接触过 MCP也没关系我会在讲实现的时候把必要的背景补上但你需要对 Git 的基本操作和 REST API 的调用方式有概念。2. 拆解需求这个 MCP 工具到底要解决哪几件事2.1 把提 PR这个动作拆成原子能力很多人一上来就想做一个一键提 PR的大工具我觉得这是错的。MCP 的设计哲学是提供细粒度的工具让模型去编排而不是做一个黑盒。所以我先把整个流程拆成了几个原子操作检查工作区状态当前在哪个分支、有没有未提交的改动、有没有未跟踪的文件。创建并切换分支根据命名规范生成分支名比如feature/xxx或fix/xxx。暂存与提交把改动 add 进来生成符合规范的 commit message。推送到远端push 到 origin并设置 upstream。创建 PR调用 Azure DevOps REST API填标题、描述、源分支、目标分支、reviewer。关联工作项如果分支名或 commit message 里有工作项 ID自动关联。这六个操作每一个都做成独立的 MCP tool模型可以根据实际情况决定调用哪些、跳过哪些。比如工作区是干净的那就不需要 commit直接 push 就行比如分支已经存在那就不需要创建。2.2 为什么不让模型直接调 git 命令有人会问既然模型能执行 shell 命令为什么不直接让它跑git commit -m xxx我试过问题在于不可控。模型生成的命令你没法保证它每次都正确尤其是涉及路径、分支名、特殊字符的时候一个引号没处理好就出问题。而且直接执行 shell 命令没有结构化输出模型拿到一堆文本还得自己解析容易出错。做成 MCP tool 的好处是输入输出都是结构化的 JSON模型知道每个字段是什么含义调用之前可以先校验参数调用之后能拿到明确的结果。比如create_branch这个 tool输入是{branch_name, base_branch}输出是{success, branch_name, message}模型一看就知道成没成。2.3 Skill 在这里扮演什么角色Skill 我的理解是给模型看的使用说明书 编排模板。MCP tool 是能力Skill 是告诉模型在什么场景下、按什么顺序、用哪些能力。比如我写了一个叫auto-pr的 Skill里面描述了标准流程先检查状态再决定是否创建分支再提交再推送最后创建 PR。模型读到这个 Skill 之后就知道遇到提 PR的请求该按什么套路走。这样做的好处是流程逻辑不写在代码里而是写在 Skill 的描述里。以后流程要改改 Skill 就行不用重新编译 MCP server。这也是我觉得 MCP Skill 这套组合最有价值的地方能力与编排分离。3. 环境搭建Git 与 Azure DevOps 的对接准备3.1 Git 侧的配置细节Git 的安装和配置网上教程一大堆我就不重复了只说几个容易忽略但很关键的点。第一个是用户身份。MCP server 执行 git commit 的时候用的是当前系统用户的 git config。如果你在 CI 环境或者容器里跑很可能没配 user.name 和 user.emailcommit 会直接失败。我的做法是在 MCP server 启动时先检查这两个配置没有的话就用环境变量兜底git config --global user.name ${GIT_USER_NAME:-mcp-bot} git config --global user.email ${GIT_USER_EMAIL:-mcp-botexample.com}第二个是凭证管理。push 到远端需要认证Azure DevOps 支持 PATPersonal Access Token或者 SSH key。我推荐用 PAT因为它在 HTTP 层面工作配置简单而且可以精细控制权限范围。PAT 的权限只需要勾选Code (read write)就够了不要给多余的权限。第三个是换行符。跨平台协作的时候 CRLF 和 LF 混用会导致整个文件显示为改动非常烦。建议在仓库根目录放一个.gitattributes强制统一* textauto eollf *.bat text eolcrlf3.2 Azure DevOps 的 PAT 与 API 端点Azure DevOps 的 REST API 基地址格式是https://dev.azure.com/{organization}/{project}/_apis/git/repositories/{repositoryId}/pullrequests?api-version7.0创建 PR 用 POST请求体大概长这样{ sourceRefName: refs/heads/feature/xxx, targetRefName: refs/heads/main, title: PR 标题, description: PR 描述, reviewers: [ { id: reviewer-uuid } ], workItemRefs: [ { id: 12345 } ] }注意几个坑sourceRefName和targetRefName必须带refs/heads/前缀少了会报错。reviewers里的id是用户的 UUID不是邮箱也不是显示名这个 UUID 需要通过 Graph API 查。workItemRefs关联工作项的时候工作项 ID 是字符串不是数字。PAT 的认证方式是 HTTP Basic Auth用户名随便填通常填个pat或者空字符串密码填 PATcurl -u :$PAT https://dev.azure.com/...3.3 MCP Server 的技术选型MCP server 我选了 Node.js TypeScript原因是官方 SDK 对 TypeScript 支持最好而且 JSON 处理方便。核心依赖就两个modelcontextprotocol/sdk和simple-git封装 git 命令。HTTP 请求用 Node 18 自带的 fetch不需要额外装 axios。目录结构大概是这样mcp-auto-pr/ ├── src/ │ ├── index.ts # MCP server 入口 │ ├── tools/ │ │ ├── git-status.ts │ │ ├── create-branch.ts │ │ ├── commit.ts │ │ ├── push.ts │ │ └── create-pr.ts │ ├── azure/ │ │ └── client.ts # Azure DevOps API 封装 │ └── config.ts # 配置读取 ├── skills/ │ └── auto-pr.md # Skill 描述文件 └── package.json4. 逐个实现 MCP Tool从 git status 到 create PR4.1 git_status一切判断的起点这个 tool 的作用是让模型知道当前仓库的状态。返回的信息包括当前分支、是否有未提交改动、未跟踪文件列表、与远端的差异。import { simpleGit } from simple-git; export async function gitStatus(repoPath: string) { const git simpleGit(repoPath); const status await git.status(); const branch await git.revparse([--abbrev-ref, HEAD]); return { currentBranch: branch.trim(), isClean: status.isClean(), modified: status.modified, notAdded: status.not_added, created: status.created, deleted: status.deleted, ahead: status.ahead, behind: status.behind, }; }这里有个细节status.isClean()只判断已跟踪文件的改动未跟踪文件不算。所以我在返回里单独列了not_added让模型自己决定要不要处理。实际用下来模型看到有未跟踪文件时会主动问用户这些新文件要不要一起提交这个交互体验挺好的。4.2 create_branch命名规范要在这里落地分支命名我定了一套规范{type}/{workitem-id}-{short-desc}比如feature/12345-add-login。type 可以是 feature、fix、hotfix、chore。export async function createBranch( repoPath: string, branchName: string, baseBranch: string main ) { const git simpleGit(repoPath); // 先切到 base 分支并拉最新 await git.checkout(baseBranch); await git.pull(origin, baseBranch); // 创建并切换到新分支 await git.checkoutLocalBranch(branchName); return { success: true, branchName, baseBranch }; }为什么要先切到 base 再 pull因为如果直接在当前分支创建新分支新分支会继承当前分支的状态可能包含未推送的提交。先切到 base 并拉最新能保证新分支是从最新的主干切出来的减少后续合并冲突。注意如果工作区有未提交的改动checkout会失败或者把改动带过去。所以这个 tool 应该在git_status确认工作区干净之后再调用或者在内部先做 stash。4.3 commitmessage 生成与暂存策略commit 这个 tool 我设计成接收一个 message 参数而不是让模型自己拼 git 命令。暂存策略默认是git add -A但支持传入文件列表做精细控制。export async function commit( repoPath: string, message: string, files?: string[] ) { const git simpleGit(repoPath); if (files files.length 0) { await git.add(files); } else { await git.add(.); } const result await git.commit(message); return { success: true, commitHash: result.commit, summary: result.summary, }; }commit message 的规范我建议用 Conventional Commits格式是type(scope): subject。这个规范的好处是机器可读后续可以自动生成 changelog。我在 Skill 里明确告诉模型要按这个格式生成 message实测下来模型生成的质量还不错比如feat(auth): add JWT token refresh logic。4.4 push处理 upstream 和冲突push 看起来简单其实坑不少。最常见的是新分支没有 upstream直接git push会报错。所以要用git push -u origin branchName。export async function push( repoPath: string, branchName: string, setUpstream: boolean true ) { const git simpleGit(repoPath); const options setUpstream ? [-u, origin, branchName] : [origin, branchName]; const result await git.push(options); return { success: true, pushed: result.pushed, branch: branchName, }; }另一个坑是远端已经有同名分支且历史不一致。这种情况 push 会被拒绝需要 force push。但 force push 很危险我坚决不在 tool 里提供这个选项。如果遇到这种情况让模型告诉用户手动处理这是安全底线。4.5 create_prAzure DevOps API 的封装这是整个项目最核心的 tool。前面几个都是本地操作这个才是真正跟 Azure DevOps 打交道的地方。export async function createPullRequest(params: { organization: string; project: string; repositoryId: string; sourceBranch: string; targetBranch: string; title: string; description: string; reviewerIds?: string[]; workItemIds?: string[]; pat: string; }) { const url https://dev.azure.com/${params.organization}/${params.project}/_apis/git/repositories/${params.repositoryId}/pullrequests?api-version7.0; const body { sourceRefName: refs/heads/${params.sourceBranch}, targetRefName: refs/heads/${params.targetBranch}, title: params.title, description: params.description, reviewers: (params.reviewerIds || []).map(id ({ id })), workItemRefs: (params.workItemIds || []).map(id ({ id })), }; const response await fetch(url, { method: POST, headers: { Content-Type: application/json, Authorization: Basic ${Buffer.from(:${params.pat}).toString(base64)}, }, body: JSON.stringify(body), }); if (!response.ok) { const errorText await response.text(); throw new Error(创建 PR 失败: ${response.status} ${errorText}); } const data await response.json(); return { success: true, pullRequestId: data.pullRequestId, url: https://dev.azure.com/${params.organization}/${params.project}/_git/${params.repositoryId}/pullrequest/${data.pullRequestId}, }; }这里有个细节值得说reviewer 的 UUID 怎么来。Azure DevOps 的 Graph API 可以按邮箱或显示名查用户GET https://vssps.dev.azure.com/{organization}/_apis/graph/users?api-version7.0返回的是所有用户列表需要自己过滤。我建议在配置里维护一个邮箱 → UUID的映射表避免每次调用都查一遍。这个映射表可以定期更新或者第一次查完之后缓存起来。5. Skill 编排让模型知道按什么顺序干活5.1 Skill 描述文件的结构Skill 文件我用 Markdown 写因为模型读 Markdown 最自然。内容分三部分适用场景、可用工具、标准流程。# Skill: auto-pr ## 适用场景 当用户要求提交 PR、创建合并请求、把这个改动提上去时使用。 ## 可用工具 - git_status: 检查工作区状态 - create_branch: 创建并切换分支 - commit: 暂存并提交 - push: 推送到远端 - create_pr: 在 Azure DevOps 创建 PR ## 标准流程 1. 调用 git_status确认当前分支和工作区状态。 2. 如果工作区不干净根据改动内容生成 commit message调用 commit。 3. 如果当前在 main 或 develop 等保护分支调用 create_branch 创建新分支。 4. 调用 push 推送当前分支。 5. 调用 create_pr标题和描述根据 commit 内容生成。 6. 如果分支名或 commit message 里有工作项 ID在 create_pr 时关联。5.2 为什么流程要写在 Skill 里而不是代码里这是我在实际使用中体会最深的一点。一开始我把流程硬编码在 MCP server 里做一个auto_pr的大 tool内部按顺序调所有子操作。结果发现两个问题第一灵活性差。有时候用户只想提交不想提 PR有时候分支已经存在不需要创建硬编码的流程没法处理这些变体。第二调试困难。流程出问题的时候你不知道是哪一步错了因为所有步骤都包在一个 tool 里。改成 Skill 编排之后模型会根据实际情况决定调用哪些 tool。比如它看到工作区干净、分支已存在、只是没推送就会跳过 commit 和 create_branch直接 push 然后 create_pr。这种灵活性是硬编码给不了的。5.3 处理模型不听话的情况Skill 毕竟是自然语言描述模型不一定每次都严格照做。我遇到过几种情况模型跳过了 git_status 直接 commit结果在有冲突的文件上操作失败。模型生成的 commit message 不符合 Conventional Commits 格式。模型在保护分支上直接 commit 而没有创建新分支。针对这些我的做法是在 tool 层面加校验。比如committool 内部先检查当前分支是不是保护分支如果是就返回错误提示模型先创建分支。create_prtool 校验 sourceBranch 和 targetBranch 不能相同。这样即使模型编排出错tool 层面也能兜住。提示Skill 是软约束tool 的参数校验是硬约束。两者结合才能保证流程可靠。6. 实测中踩过的坑与排查过程6.1 push 报 ssh认证失败 的排查第一次跑通流程的时候push 一直失败报的是 SSH 认证错误。我一开始以为是 key 没配好折腾了半天。后来发现根本原因是仓库的 remote URL 是 SSH 格式但我的 PAT 是给 HTTP 用的。排查过程是这样的先git remote -v看 remote URL发现是gitssh.dev.azure.com:...这种格式。SSH 格式走的是 SSH key 认证跟 PAT 没关系。解决办法有两个要么配 SSH key要么把 remote URL 改成 HTTPS 格式。我选了后者因为 PAT 管理起来更方便git remote set-url origin https://dev.azure.com/{org}/{project}/_git/{repo}改完之后 push 就正常了。这个坑的教训是PAT 和 SSH key 是两套认证体系不能混用。用 PAT 就必须走 HTTPS。6.2 PR 创建成功但 reviewer 没加上有几次 PR 创建成功了但 reviewer 列表是空的。查了 API 返回发现 reviewers 字段被忽略了。原因是传的 UUID 格式不对。Azure DevOps 的 reviewer id 必须是完整的 UUID 格式比如a1b2c3d4-e5f6-7890-abcd-ef1234567890少一段或者格式不对都会被静默忽略。排查方法是在创建 PR 之后再调一次 GET 接口查 PR 详情看 reviewers 字段是不是空的。如果是空的就说明 id 有问题。我后来在代码里加了一个校验UUID 必须匹配正则^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$不匹配的直接报错不静默忽略。6.3 工作项关联失败的几种情况关联工作项也踩过坑。有一次传了工作项 ID但 PR 创建后工作项没关联上。查下来发现是工作项 ID 传成了数字类型而 API 要求字符串。改成字符串之后就好了。还有一种情况是工作项不存在或者没权限访问。这种 API 会返回 400但错误信息很模糊只说 Invalid work item reference。我的处理方式是在关联之前先调工作项的查询接口确认存在不存在就跳过关联并在返回里提示。6.4 大文件导致的 push 超时有一次提交了一个几十 MB 的二进制文件push 卡了很久最后超时。这个问题的根源是仓库里不应该有大文件。我的处理方式是在committool 里加一个文件大小检查超过 10MB 的文件给出警告超过 50MB 直接拒绝提交。const MAX_FILE_SIZE 50 * 1024 * 1024; // 50MB // 在 add 之前遍历文件检查大小这个阈值可以根据团队实际情况调整。如果确实需要管理大文件应该用 Git LFS而不是直接提交到仓库。7. 一些让工具更好用的经验补充7.1 配置文件的设计我把所有环境相关的配置抽到一个config.json里包括 organization、project、repositoryId、PAT、reviewer 映射表、保护分支列表。这样换一个项目只需要改配置不用动代码。{ organization: my-org, project: my-project, repositoryId: my-repo, pat: ${AZURE_DEVOPS_PAT}, protectedBranches: [main, develop, release/*], reviewerMap: { aliceexample.com: uuid-1, bobexample.com: uuid-2 } }PAT 用环境变量占位符避免明文写在配置文件里。启动时做一次替换。7.2 日志与可观测性MCP server 跑在后台出问题的时候如果没日志会很难查。我在每个 tool 的入口和出口都加了日志记录输入参数、执行结果、耗时。日志写到文件里按天切割。logger.info(create_pr called, { sourceBranch, targetBranch, title }); const start Date.now(); // ... 执行 logger.info(create_pr completed, { pullRequestId, duration: Date.now() - start });有了这些日志排查问题的时候直接看日志就行不用复现。实测下来90% 的问题看日志就能定位。7.3 幂等性处理网络抖动或者超时的时候模型可能会重试。如果 create_pr 被调用了两次会创建两个重复的 PR。所以我在 create_pr 里加了幂等检查先查一下源分支有没有已经打开的 PR有的话直接返回那个 PR 的信息不重复创建。GET .../pullrequests?searchCriteria.sourceRefNamerefs/heads/{branch}searchCriteria.statusactive这个查询返回当前分支所有活跃的 PR如果有就直接复用。这样即使重试也不会产生重复 PR。7.4 关于 Skill 的版本管理Skill 文件我放在 git 仓库里跟代码一起管理每次改 Skill 都走正常的 PR 流程。这样做的好处是 Skill 的变更可追溯出问题可以回滚。另外我在 Skill 文件头部加了一个版本号方便排查模型用的是哪个版本的 Skill。!-- skill-version: 1.2.0 --模型读到这个版本号在日志里也会带上排查问题的时候能对应上。8. 关于这套方案的一些个人体会这套东西跑了大半年团队里现在提 PR 基本都是一句话的事。我最大的体会是MCP Skill 这套组合的价值不在于自动化而在于可编排。传统的脚本自动化是线性的、固定的而 MCP 让模型根据上下文动态决定调用哪些能力这才是它真正区别于脚本的地方。另一个体会是安全边界一定要在 tool 层面守住。Skill 是给模型看的模型可能理解偏差但 tool 的参数校验是代码是确定性的。force push、删除分支、修改保护分支这些危险操作我坚决不在 tool 里提供从源头上杜绝误操作。最后说一个我觉得后续可以扩展的方向现在 reviewer 是根据配置文件静态映射的其实可以根据改动的文件路径动态推断。比如改了src/auth/下的文件就自动找 auth 模块的负责人。这个逻辑可以做成一个独立的 tool让 Skill 在创建 PR 之前调用。不过这个需要维护一份路径 → 负责人的映射团队规模小的时候可能不划算规模大了就很有价值。