1. 为什么我要自己造一个自动提 PR 的工具
先说结论:我受够了每次改完代码还要手动切分支、写 commit message、推远端、开浏览器、填 PR 标题和描述这一整套流程。尤其是当你在一个迭代周期里要反复提交十几个小改动时,这种重复劳动简直是在消耗生命。
我日常的主力代码托管平台是 Azure DevOps,团队用的是标准的 Pull Request 工作流。Git 本身的操作并不复杂,但问题在于“流程编排”——你得记住当前分支名、目标分支名、关联的工作项 ID、PR 描述模板,还要确保没有漏掉 reviewer。这些信息散落在各个地方,每次手动操作都像是在做一道填空题。
于是我开始琢磨:能不能做一个工具,让我在终端里敲一行命令,剩下的全自动完成?更进一步,能不能让 AI 助手直接帮我完成这件事?这就是我动手实现这个 MCP 工具的起点。
MCP(Model Context Protocol)是 Anthropic 推出的一个开放协议,简单说就是让 AI 助手能够调用外部工具的标准化接口。你可以把它理解成“AI 世界的 USB 接口”——只要你的工具实现了 MCP 协议,任何支持 MCP 的 AI 客户端都能直接调用它。而Skill在这里扮演的是“能力封装”的角色,把 Git 操作和 Azure DevOps API 调用串成一条完整的流水线。
这个工具适合谁?如果你满足以下任意一条,这篇文章就是写给你的:
- 每天要在 Azure DevOps 上提交多个 PR,手动操作已经让你厌烦
- 想了解 MCP 协议的实际落地方式,而不只是停留在概念层面
- 对 AI 助手 + 工具链的自动化编排感兴趣,想自己动手做一个
- 团队有标准化的 PR 流程,但缺乏自动化手段
我最终实现的效果是:在支持 MCP 的 AI 客户端里说一句“帮我把当前改动提交 PR 到 develop 分支”,工具会自动完成分支检查、文件暂存、commit、push、创建 PR 的全流程,并且返回 PR 链接。整个过程不需要离开对话界面。
下面我把整个实现过程拆开来讲,包括架构设计、核心代码、踩过的坑,以及一些只有实际跑过才会知道的细节。
2. 整体架构设计与技术选型思路
2.1 为什么选 MCP 而不是写个 Shell 脚本
最开始我确实想过写个 Bash 脚本就完事了。git add、git commit、git push、然后调 Azure DevOps 的 REST API 创建 PR,一个脚本几十行就能搞定。但实际用下来发现几个问题:
第一,参数传递不灵活。Shell 脚本处理 JSON 响应很痛苦,尤其是 Azure DevOps 返回的 PR 对象嵌套层级很深,用 jq 解析虽然能行,但可读性和可维护性都很差。
第二,无法与 AI 助手集成。Shell 脚本只能我自己在终端跑,没法让 AI 助手理解“当前仓库状态”并做出决策。比如 AI 需要先判断当前分支是否有未提交的改动、是否已经存在同名 PR,这些逻辑用 Shell 写会非常臃肿。
第三,错误处理粗糙。Git 操作和 API 调用都可能失败,Shell 脚本的错误处理基本靠set -e和手动判断退出码,很难做到精细化的错误分类和重试。
MCP 的优势在于它天然就是为“工具调用”设计的。每个工具都有明确的输入 schema 和输出格式,AI 助手能理解每个工具做什么、需要什么参数、返回什么结果。这比 Shell 脚本的“黑盒”模式强太多。
2.2 Skill 的定位:编排层而非执行层
这里要澄清一个概念:Skill 在我的设计里不是“执行具体操作”的模块,而是“编排流程”的模块。具体来说:
- 执行层:Git 命令封装、Azure DevOps API 调用,这些是原子操作
- 编排层(Skill):决定什么时候调用哪个原子操作、如何处理中间结果、如何应对异常
打个比方,执行层像是厨房里的各种厨具——刀、锅、铲子;Skill 像是菜谱——告诉你先切菜还是先热油、什么时候放盐、火候怎么控制。没有菜谱,你有一堆厨具也做不出一道菜。
我选择把 Skill 独立出来的另一个原因是可测试性。编排逻辑可以单独写单元测试,mock 掉底层的 Git 和 API 调用,验证流程是否正确。如果全部揉在一起,测试会变得非常困难。
2.3 技术栈选择与理由
最终的技术栈如下:
| 组件 | 选型 | 理由 |
|---|---|---|
| 运行时 | Node.js 18+ | MCP SDK 对 Node.js 支持最好,生态成熟 |
| 语言 | TypeScript | 类型安全,MCP 的 schema 定义需要强类型支撑 |
| Git 操作 | simple-git | 比 child_process 调 git 命令更安全,API 友好 |
| HTTP 请求 | axios | 拦截器机制方便统一处理 Azure DevOps 的认证和错误 |
| MCP SDK | @modelcontextprotocol/sdk | 官方 SDK,文档完善 |
| 构建工具 | tsup | 零配置打包,输出 ESM + CJS 双格式 |
为什么不用 Python?MCP 的 Python SDK 确实存在,但 Node.js 版本更新更频繁,社区示例也更多。而且 simple-git 这个库的质量非常高,省去了很多自己封装 Git 命令的工作。
为什么不用 Go?Go 的 MCP 生态还不够成熟,而且我在这个项目里更看重开发速度而非运行时性能。Git 操作和 API 调用都是 IO 密集型,Node.js 的异步模型完全够用。
2.4 整体数据流设计
整个工具的数据流是这样的:
- AI 客户端发起工具调用,传入参数(目标分支、PR 标题等)
- MCP Server 接收请求,交给 Skill 编排层
- Skill 首先调用 Git 模块检查仓库状态(当前分支、是否有未提交改动)
- 如果有未提交改动,调用 Git 模块执行 add + commit
- 调用 Git 模块 push 到远端
- 调用 Azure DevOps 模块创建 PR
- 返回 PR 链接给 AI 客户端
这个流程看起来简单,但每一步都有需要处理的边界情况。比如:当前分支就是目标分支怎么办?远端已经存在同名分支怎么办?PR 已经存在怎么办?这些都会在后面详细展开。
3. 核心模块拆解与关键实现细节
3.1 Git 操作模块:不只是调命令那么简单
很多人觉得封装 Git 操作就是exec('git add .')这么简单,但实际做下来你会发现有一堆细节要处理。
首先是仓库状态检测。在提交之前,你必须知道当前仓库处于什么状态:
import simpleGit, { SimpleGit } from 'simple-git'; interface RepoStatus { currentBranch: string; hasUncommittedChanges: boolean; stagedFiles: string[]; unstagedFiles: string[]; untrackedFiles: string[]; aheadCount: number; behindCount: number; } async function getRepoStatus(repoPath: string): Promise<RepoStatus> { const git: SimpleGit = simpleGit(repoPath); const status = await git.status(); return { currentBranch: status.current || '', hasUncommittedChanges: !status.isClean(), stagedFiles: status.staged, unstagedFiles: status.modified, untrackedFiles: status.not_added, aheadCount: status.ahead, behindCount: status.behind, }; }这段代码看起来直白,但有几个坑我踩过:
坑一:detached HEAD 状态。如果用户处于 detached HEAD 状态,status.current会返回空字符串或者 commit hash。这时候直接提交会导致 commit 丢失。我的处理方式是检测到 detached HEAD 时直接报错,提示用户先切换到正常分支。
坑二:未跟踪文件太多。有些项目里node_modules或者构建产物没有被.gitignore正确忽略,导致untrackedFiles列表巨大。如果直接git add .会把这些文件全部加进去。我的做法是默认只 add 已跟踪文件的改动,未跟踪文件需要显式指定。
坑三:换行符问题。Windows 和 Unix 的换行符不同,如果团队里有人用 Windows 有人用 Mac,Git 可能会把所有文件标记为 modified。这个需要在.gitattributes里配置,工具层面能做的不多,但至少要在文档里提醒。
接下来是分支操作。创建 PR 之前需要确保当前分支已经推送到远端:
async function ensureBranchPushed( git: SimpleGit, branchName: string, remote: string = 'origin' ): Promise<void> { const remotes = await git.getRemotes(true); const targetRemote = remotes.find(r => r.name === remote); if (!targetRemote) { throw new Error(`Remote "${remote}" not found`); } const localBranches = await git.branchLocal(); if (!localBranches.all.includes(branchName)) { throw new Error(`Local branch "${branchName}" does not exist`); } const remoteBranches = await git.branch(['-r']); const remoteBranchName = `${remote}/${branchName}`; if (remoteBranches.all.includes(remoteBranchName)) { // 远端已存在,检查是否需要 force push const localHash = await git.revparse([branchName]); const remoteHash = await git.revparse([remoteBranchName]); if (localHash !== remoteHash) { await git.push(remote, branchName); } } else { // 远端不存在,设置 upstream 并推送 await git.push(['-u', remote, branchName]); } }这里的关键点是不要无脑 force push。force push 会覆盖远端历史,如果其他人已经基于这个分支做了改动,会造成灾难性的后果。我的策略是:如果远端分支存在且与本地不一致,先尝试普通 push;如果失败(说明有冲突),报错让用户手动处理。
3.2 Azure DevOps API 封装:认证与错误处理
Azure DevOps 的 REST API 认证方式有几种:PAT(Personal Access Token)、OAuth、Managed Identity。对于个人工具来说,PAT 是最简单的方式。
PAT 的权限配置很关键。创建 PR 需要以下权限:
- Code (Read & Write):读取仓库信息、创建 PR
- Pull Request Threads (Read & Write):如果需要添加评论或 reviewer
- Work Items (Read):如果需要关联工作项
我见过有人给了 Full Access 的 PAT,这是非常危险的。最小权限原则在这里同样适用。
import axios, { AxiosInstance } from 'axios'; interface AzureDevOpsConfig { organization: string; project: string; repository: string; pat: string; } function createApiClient(config: AzureDevOpsConfig): AxiosInstance { const baseURL = `https://dev.azure.com/${config.organization}/${config.project}/_apis`; const client = axios.create({ baseURL, headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${Buffer.from(`:${config.pat}`).toString('base64')}`, }, timeout: 30000, }); // 统一错误处理 client.interceptors.response.use( response => response, error => { if (error.response) { const { status, data } = error.response; const message = data?.message || error.message; switch (status) { case 401: throw new Error(`Azure DevOps 认证失败,请检查 PAT 是否有效: ${message}`); case 403: throw new Error(`权限不足,请检查 PAT 的权限配置: ${message}`); case 404: throw new Error(`资源不存在,请检查组织/项目/仓库名称: ${message}`); case 409: throw new Error(`冲突,可能已存在相同的 PR: ${message}`); default: throw new Error(`Azure DevOps API 错误 (${status}): ${message}`); } } throw error; } ); return client; }认证的坑:Azure DevOps 的 PAT 认证用的是 HTTP Basic Auth,但用户名部分可以是空的,密码部分填 PAT。Base64 编码的格式是:PAT(注意冒号前面是空的)。我第一次写的时候把用户名填成了邮箱,结果一直 401。
API 版本号:Azure DevOps 的 REST API 需要指定api-version参数。不同版本的 API 行为可能有差异。我固定用7.1版本,这是目前最稳定的。
3.3 创建 PR 的核心逻辑
创建 PR 的 API 调用本身不复杂,但参数构造需要仔细:
interface CreatePRParams { sourceBranch: string; targetBranch: string; title: string; description?: string; isDraft?: boolean; reviewers?: string[]; workItemIds?: number[]; } async function createPullRequest( client: AxiosInstance, config: AzureDevOpsConfig, params: CreatePRParams ): Promise<{ prId: number; url: string }> { // 分支名必须以 refs/heads/ 开头 const sourceRef = params.sourceBranch.startsWith('refs/heads/') ? params.sourceBranch : `refs/heads/${params.sourceBranch}`; const targetRef = params.targetBranch.startsWith('refs/heads/') ? params.targetBranch : `refs/heads/${params.targetBranch}`; const body: Record<string, unknown> = { sourceRefName: sourceRef, targetRefName: targetRef, title: params.title, description: params.description || '', isDraft: params.isDraft || false, }; if (params.reviewers && params.reviewers.length > 0) { body.reviewers = params.reviewers.map(id => ({ id })); } if (params.workItemIds && params.workItemIds.length > 0) { body.workItemRefs = params.workItemIds.map(id => ({ id: String(id) })); } const response = await client.post( `/git/repositories/${config.repository}/pullrequests?api-version=7.1`, body ); return { prId: response.data.pullRequestId, url: `https://dev.azure.com/${config.organization}/${config.project}/_git/${config.repository}/pullrequest/${response.data.pullRequestId}`, }; }关键细节:sourceRefName和targetRefName必须以refs/heads/开头。如果你只传分支名(比如feature/xxx),API 会返回 400 错误。这个在官方文档里没有明确说明,我是通过抓包才发现的。
重复 PR 检测:Azure DevOps 允许同一个源分支和目标分支之间存在多个 PR(只要状态不同)。但实际使用中,如果已经有一个活跃的 PR,再创建一个会让人困惑。我的做法是在创建之前先查询是否已存在活跃 PR:
async function findExistingPR( client: AxiosInstance, config: AzureDevOpsConfig, sourceBranch: string, targetBranch: string ): Promise<number | null> { const sourceRef = `refs/heads/${sourceBranch}`; const targetRef = `refs/heads/${targetBranch}`; const response = await client.get( `/git/repositories/${config.repository}/pullrequests?api-version=7.1`, { params: { 'searchCriteria.sourceRefName': sourceRef, 'searchCriteria.targetRefName': targetRef, 'searchCriteria.status': 'active', }, } ); const prs = response.data.value; return prs.length > 0 ? prs[0].pullRequestId : null; }如果找到已存在的 PR,我会返回它的链接而不是创建新的。这样避免了重复 PR 的问题。
3.4 Skill 编排层:把碎片串成流水线
Skill 层的核心是一个状态机。每个步骤都有明确的输入、输出和失败处理:
interface SkillContext { repoPath: string; targetBranch: string; prTitle: string; prDescription?: string; commitMessage?: string; reviewers?: string[]; workItemIds?: number[]; isDraft?: boolean; } interface SkillResult { success: boolean; prUrl?: string; prId?: number; message: string; steps: StepResult[]; } interface StepResult { name: string; status: 'success' | 'skipped' | 'failed'; detail: string; } async function executeAutoPR( context: SkillContext, git: SimpleGit, apiClient: AxiosInstance, config: AzureDevOpsConfig ): Promise<SkillResult> { const steps: StepResult[] = []; // Step 1: 检查仓库状态 const status = await getRepoStatus(context.repoPath); steps.push({ name: 'check-repo-status', status: 'success', detail: `当前分支: ${status.currentBranch}, 未提交改动: ${status.hasUncommittedChanges}`, }); // Step 2: 检查是否在目标分支上 if (status.currentBranch === context.targetBranch) { return { success: false, message: `当前分支就是目标分支 "${context.targetBranch}",无法创建 PR`, steps, }; } // Step 3: 提交未暂存的改动 if (status.hasUncommittedChanges) { const commitMsg = context.commitMessage || `auto: update from ${status.currentBranch}`; await git.add(['-A']); await git.commit(commitMsg); steps.push({ name: 'commit-changes', status: 'success', detail: `已提交改动: ${commitMsg}`, }); } else { steps.push({ name: 'commit-changes', status: 'skipped', detail: '没有未提交的改动', }); } // Step 4: 推送分支 await ensureBranchPushed(git, status.currentBranch); steps.push({ name: 'push-branch', status: 'success', detail: `已推送分支: ${status.currentBranch}`, }); // Step 5: 检查是否已存在 PR const existingPR = await findExistingPR( apiClient, config, status.currentBranch, context.targetBranch ); if (existingPR) { const url = buildPRUrl(config, existingPR); steps.push({ name: 'create-pr', status: 'skipped', detail: `已存在活跃 PR #${existingPR}`, }); return { success: true, prId: existingPR, prUrl: url, message: `已存在活跃 PR,直接返回链接`, steps, }; } // Step 6: 创建 PR const pr = await createPullRequest(apiClient, config, { sourceBranch: status.currentBranch, targetBranch: context.targetBranch, title: context.prTitle, description: context.prDescription, isDraft: context.isDraft, reviewers: context.reviewers, workItemIds: context.workItemIds, }); steps.push({ name: 'create-pr', status: 'success', detail: `已创建 PR #${pr.prId}`, }); return { success: true, prId: pr.prId, prUrl: pr.url, message: `PR 创建成功`, steps, }; }这个编排逻辑有几个设计决策值得说明:
为什么先 commit 再 push?因为如果先 push 再 commit,push 的内容不包含最新的改动,PR 里看到的代码就不是最新的。这个顺序不能反。
为什么用git add -A而不是git add .?git add .只添加当前目录下的改动,如果仓库有多个子目录,可能会漏掉。git add -A会添加所有改动,包括删除的文件。当然,前提是.gitignore配置正确。
为什么检查已存在的 PR?因为 AI 助手可能会重复调用同一个工具。如果用户说“帮我提个 PR”,AI 执行了;过一会儿用户又说“再提一次”,如果没有去重逻辑,就会创建两个 PR。这个在实际使用中非常常见。
4. MCP Server 的注册与工具定义
4.1 MCP 工具 Schema 设计
MCP 工具的定义需要遵循 JSON Schema 规范。每个工具需要声明名称、描述和输入参数:
import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; const server = new Server( { name: 'auto-pr', version: '1.0.0' }, { capabilities: { tools: {} } } ); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'auto_create_pr', description: '自动提交当前改动并创建 Azure DevOps Pull Request。会自动检测未提交的改动、提交、推送分支、创建 PR。', inputSchema: { type: 'object', properties: { repoPath: { type: 'string', description: 'Git 仓库的本地路径,默认为当前工作目录', }, targetBranch: { type: 'string', description: 'PR 的目标分支,例如 main、develop', default: 'main', }, prTitle: { type: 'string', description: 'PR 标题', }, prDescription: { type: 'string', description: 'PR 描述内容', }, commitMessage: { type: 'string', description: '提交信息,如果不提供则自动生成', }, reviewers: { type: 'array', items: { type: 'string' }, description: 'Reviewer 的用户 ID 列表', }, workItemIds: { type: 'array', items: { type: 'number' }, description: '关联的工作项 ID 列表', }, isDraft: { type: 'boolean', description: '是否创建为草稿 PR', default: false, }, }, required: ['prTitle'], }, }, { name: 'check_repo_status', description: '检查 Git 仓库的当前状态,包括当前分支、未提交改动、与远端的差异等。', inputSchema: { type: 'object', properties: { repoPath: { type: 'string', description: 'Git 仓库的本地路径', }, }, }, }, ], }));Schema 设计的经验:
第一,description要写得足够详细。AI 助手是根据 description 来决定是否调用这个工具的。如果描述太简略,AI 可能不知道什么时候该用它。比如我写的是“自动提交当前改动并创建 Azure DevOps Pull Request”,而不是简单的“创建 PR”。
第二,required字段要精确。prTitle是必须的,因为 Azure DevOps 要求 PR 必须有标题。其他参数都有合理的默认值,不需要强制用户提供。
第三,参数命名要一致。我用的是 camelCase,和 TypeScript 的惯例保持一致。有些 MCP 工具用 snake_case,虽然也能工作,但看起来不统一。
4.2 工具调用的处理逻辑
server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { switch (name) { case 'auto_create_pr': { const context: SkillContext = { repoPath: (args?.repoPath as string) || process.cwd(), targetBranch: (args?.targetBranch as string) || 'main', prTitle: args?.prTitle as string, prDescription: args?.prDescription as string | undefined, commitMessage: args?.commitMessage as string | undefined, reviewers: args?.reviewers as string[] | undefined, workItemIds: args?.workItemIds as number[] | undefined, isDraft: args?.isDraft as boolean | undefined, }; const config = loadAzureDevOpsConfig(); const git = simpleGit(context.repoPath); const apiClient = createApiClient(config); const result = await executeAutoPR(context, git, apiClient, config); return { content: [ { type: 'text', text: formatResult(result), }, ], }; } case 'check_repo_status': { const repoPath = (args?.repoPath as string) || process.cwd(); const status = await getRepoStatus(repoPath); return { content: [ { type: 'text', text: JSON.stringify(status, null, 2), }, ], }; } default: throw new Error(`Unknown tool: ${name}`); } } catch (error) { const message = error instanceof Error ? error.message : String(error); return { content: [ { type: 'text', text: `执行失败: ${message}`, }, ], isError: true, }; } });错误处理的关键点:MCP 工具调用失败时,不要直接抛异常,而是返回isError: true的内容。这样 AI 助手能理解发生了什么,并可能尝试其他方案。如果直接抛异常,AI 客户端可能只会显示一个通用的错误信息。
4.3 配置管理:PAT 不能硬编码
Azure DevOps 的 PAT 绝对不能写在代码里。我的做法是通过环境变量读取:
interface AzureDevOpsConfig { organization: string; project: string; repository: string; pat: string; } function loadAzureDevOpsConfig(): AzureDevOpsConfig { const organization = process.env.AZDO_ORG; const project = process.env.AZDO_PROJECT; const repository = process.env.AZDO_REPO; const pat = process.env.AZDO_PAT; const missing: string[] = []; if (!organization) missing.push('AZDO_ORG'); if (!project) missing.push('AZDO_PROJECT'); if (!repository) missing.push('AZDO_REPO'); if (!pat) missing.push('AZDO_PAT'); if (missing.length > 0) { throw new Error( `缺少必要的环境变量: ${missing.join(', ')}。请参考文档配置 Azure DevOps 连接信息。` ); } return { organization, project, repository, pat: pat! }; }在 MCP 客户端的配置文件里,通过env字段传入这些环境变量。比如在 Claude Desktop 的配置里:
{ "mcpServers": { "auto-pr": { "command": "node", "args": ["/path/to/auto-pr-mcp/dist/index.js"], "env": { "AZDO_ORG": "your-org", "AZDO_PROJECT": "your-project", "AZDO_REPO": "your-repo", "AZDO_PAT": "your-pat" } } } }安全提醒:PAT 应该定期轮换,并且只授予必要的权限。如果 PAT 泄露,攻击者可以以你的身份操作仓库。我建议设置 90 天过期,并且只勾选 Code (Read & Write) 权限。
5. 实操过程中踩过的坑与排查技巧
5.1 Git 相关的典型问题
问题一:SSH 认证失败
这是最常见的问题。git push报错Permission denied (publickey),说明 SSH key 没有配置好。排查步骤:
- 检查 SSH key 是否存在:
ls ~/.ssh/id_* - 检查 SSH agent 是否加载了 key:
ssh-add -l - 测试 SSH 连接:
ssh -T git@ssh.dev.azure.com
如果 SSH key 不存在,需要生成:ssh-keygen -t ed25519 -C "your-email"。然后把公钥添加到 Azure DevOps 的 SSH Keys 设置里。
问题二:分支名包含特殊字符
Azure DevOps 对分支名有一些限制。比如分支名不能以/开头或结尾,不能包含..、~、^、:等字符。如果分支名不合法,创建 PR 时会报 400 错误。
我的处理方式是在创建 PR 之前先校验分支名:
function validateBranchName(branchName: string): void { const invalidPatterns = [ /^\./, /\.\./, /[~^:?*\[\\]/, /\/$/, /\.lock$/, /@\{/, ]; for (const pattern of invalidPatterns) { if (pattern.test(branchName)) { throw new Error(`分支名 "${branchName}" 包含非法字符,请重命名后再试`); } } }问题三:.gitignore不生效
有时候明明在.gitignore里写了规则,但git status还是显示那些文件。常见原因:
- 文件已经被跟踪了。
.gitignore只对未跟踪的文件生效。如果文件已经被git add过,需要先git rm --cached <file>。 - 规则写法不对。比如
*.log会匹配所有.log文件,但/logs/只匹配根目录下的logs文件夹。 - 全局
.gitignore和项目.gitignore冲突。可以用git check-ignore -v <file>查看是哪条规则在生效。
5.2 Azure DevOps API 的坑
问题一:PAT 权限不足
创建 PR 需要 Code (Read & Write) 权限。如果 PAT 只有 Code (Read),创建 PR 时会返回 403。排查方式是看错误信息里的message字段,通常会提示具体缺少什么权限。
问题二:组织名和项目名混淆
Azure DevOps 的 URL 格式是https://dev.azure.com/{organization}/{project}/_git/{repository}。很多人会把组织名和项目名搞混。组织名是你在 Azure DevOps 上的顶层命名空间,项目名是组织下的具体项目。
问题三:API 版本不兼容
不同版本的 Azure DevOps Server(on-premise)支持的 API 版本不同。如果你用的是本地部署的 Azure DevOps Server,可能需要降低 API 版本号。我建议先用7.1,如果报错再尝试6.0或5.1。
5.3 MCP 集成的问题
问题一:MCP Server 启动失败
如果 MCP 客户端报“无法连接到 MCP Server”,首先检查:
- Node.js 版本是否 >= 18
- 入口文件路径是否正确
- 是否有未捕获的异常导致进程退出
可以在终端手动运行 MCP Server 来排查:node dist/index.js。如果启动正常,会看到进程挂起等待输入(这是 stdio 传输的正常行为)。
问题二:工具调用超时
Azure DevOps API 的响应时间可能较长,尤其是创建 PR 时。如果 MCP 客户端设置了较短的超时时间,可能会中断调用。我的做法是在 axios 里设置 30 秒超时,同时在 MCP 工具描述里提醒用户“创建 PR 可能需要几秒钟”。
问题三:环境变量不生效
MCP 客户端启动 MCP Server 时,环境变量是通过配置文件传入的。如果你在 shell 里export了变量,但 MCP 客户端不是从 shell 启动的,那些变量不会生效。确保在 MCP 客户端的配置里正确设置了env字段。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| git push 报 Permission denied | SSH key 未配置 | ssh -T git@ssh.dev.azure.com | 生成 SSH key 并添加到 Azure DevOps |
| 创建 PR 返回 401 | PAT 无效或过期 | 检查 PAT 是否过期 | 重新生成 PAT |
| 创建 PR 返回 403 | PAT 权限不足 | 查看错误信息中的权限提示 | 给 PAT 添加 Code (Read & Write) 权限 |
| 创建 PR 返回 400 | 分支名不合法 | 检查分支名是否包含特殊字符 | 重命名分支 |
| MCP Server 无法启动 | Node.js 版本过低 | node --version | 升级到 Node.js 18+ |
| 工具调用无响应 | 环境变量未配置 | 检查 MCP 客户端配置 | 在 env 字段中配置 AZDO_* 变量 |
| commit 后文件丢失 | detached HEAD 状态 | git status查看 HEAD 状态 | 切换到正常分支后重新操作 |
| .gitignore 不生效 | 文件已被跟踪 | git check-ignore -v <file> | git rm --cached <file> |
6. 一些只有实际跑过才知道的经验
6.1 关于 commit message 的自动生成
我最初想让 AI 根据 diff 内容自动生成 commit message,但实际用下来发现效果不稳定。AI 有时候会生成过于笼统的描述(比如“更新代码”),有时候又会过度解读改动意图。
后来我改成了“模板 + 可选覆盖”的策略:默认使用auto: update from {branch}这样的模板,用户可以通过commitMessage参数覆盖。这样既保证了自动化,又保留了灵活性。
如果你确实想让 AI 生成 commit message,建议在 Skill 里加一个单独的步骤,让 AI 先分析 diff 再生成 message,而不是在创建 PR 的流程里顺便做。这样职责更清晰,也更容易调试。
6.2 关于 reviewer 的自动分配
Azure DevOps 支持通过 API 指定 reviewer,但需要用户的 ID(通常是邮箱或 GUID)。我试过根据文件路径自动分配 reviewer(比如前端文件分配给前端负责人),但维护这个映射关系很麻烦。
目前我的做法是:如果用户没有指定 reviewer,就不自动添加。团队可以在 Azure DevOps 的项目设置里配置“自动包含的 reviewer”,这样创建 PR 时会自动添加。这比在工具层面做映射要可靠得多。
6.3 关于草稿 PR 的使用场景
isDraft: true创建的 PR 不会被自动合并,也不会触发一些 CI 检查。这个功能在以下场景很有用:
- 代码还没写完,但想先让团队看到方向
- 需要先跑一遍 CI 看看有没有问题,但不想让 reviewer 现在就 review
- 作为“占位符”,防止其他人对同一分支创建 PR
我通常会在迭代初期创建草稿 PR,等代码稳定后再标记为 ready for review。
6.4 关于错误恢复
自动化工具有一个通病:一旦中间步骤失败,整个流程就卡住了。比如 commit 成功了但 push 失败了,下次再运行工具时,它会发现没有未提交的改动,然后直接尝试 push。这其实是正确的行为,但用户可能会困惑“为什么没有 commit 就直接 push 了”。
我的做法是在返回结果里详细列出每一步的状态,让用户清楚地知道哪些步骤执行了、哪些跳过了、哪些失败了。这样即使流程中断,用户也能知道当前处于什么状态,以及下一步该怎么做。
6.5 关于性能优化
这个工具的性能瓶颈主要在 Git 操作和 API 调用上。我做过一些优化:
- 并行化:检查仓库状态和加载配置可以并行执行,节省几百毫秒
- 缓存:Azure DevOps 的仓库信息(比如默认分支)可以缓存,避免每次都查询
- 懒加载:simple-git 的实例不需要在启动时就创建,等到实际使用时再创建
但这些优化的收益有限,因为整个流程的主要耗时在 push 和 API 调用上,这些是网络 IO,没法优化。所以我没有过度投入性能优化,够用就行。
6.6 关于测试策略
MCP 工具的测试比较特殊,因为它涉及外部依赖(Git 仓库、Azure DevOps API)。我的测试策略是:
- 单元测试:mock 掉 simple-git 和 axios,测试 Skill 编排逻辑
- 集成测试:用一个临时的 Git 仓库和 mock 的 Azure DevOps API Server,测试完整的流程
- 手动测试:在真实的 Azure DevOps 仓库上测试,验证端到端流程
单元测试用 Vitest,集成测试用 msw(Mock Service Worker)来拦截 HTTP 请求。手动测试就是我自己日常使用,发现问题就修。
7. 这个工具还能怎么扩展
目前这个工具只覆盖了“创建 PR”这一个场景,但实际上还有很多可以扩展的方向。
自动合并 PR:当 PR 的 CI 检查通过且所有 reviewer 都 approve 后,自动合并。这个需要监听 PR 的状态变化,可以通过 Azure DevOps 的 Webhook 或者轮询 API 来实现。
批量创建 PR:如果你有多个仓库需要同时提交改动,可以扩展成批量操作。每个仓库独立执行,最后汇总结果。
PR 模板支持:Azure DevOps 支持 PR 模板,可以在创建 PR 时自动填充描述。我的工具目前是手动传prDescription,可以改成从模板文件读取。
与工作项联动:创建 PR 时自动关联工作项,并在 PR 合并后自动更新工作项状态。这个需要 Work Items (Read & Write) 权限。
多平台支持:目前只支持 Azure DevOps,可以扩展到 GitHub、GitLab 等平台。MCP 的抽象层让这个扩展变得相对容易——只需要实现对应平台的 API 封装,Skill 层的逻辑可以复用。
我个人的优先级是:先把手头的 Azure DevOps 场景做稳定,再考虑扩展。毕竟工具的价值在于解决实际问题,而不是支持尽可能多的平台。
7.1 关于 Skill 的复用性思考
这个项目让我对 Skill 的设计有了新的理解。一个好的 Skill 应该是:
- 原子性:每个 Skill 只做一件事,但做得很完整
- 可组合:多个 Skill 可以串联成更复杂的流程
- 可观测:每个步骤的状态和结果都清晰可见
- 可恢复:失败后可以从断点继续,而不是从头再来
我目前的设计基本满足前三点,第四点(可恢复)还有改进空间。比如如果 push 失败了,下次运行时会重新检查状态并继续,但不会保留上次的上下文。如果要做到真正的可恢复,需要把中间状态持久化到磁盘。
这个可能是我下一步要优化的方向。不过说实话,在实际使用中,失败后重新运行一次的成本并不高,所以优先级不高。
7.2 关于 AI 助手集成的体验
最后说一个有意思的观察:当我把这个工具接入 AI 助手后,使用方式发生了微妙的变化。以前我是“想好了要提 PR,然后执行工具”;现在我是“跟 AI 说我要提 PR,AI 帮我判断当前状态并执行”。
比如我会说:“帮我把当前的改动提个 PR 到 develop,标题写‘修复登录页面的样式问题’。”AI 会先调用check_repo_status看看当前分支和改动情况,然后调用auto_create_pr完成提交。如果当前分支就是 develop,AI 会告诉我“你当前在 develop 分支上,无法创建 PR,需要先切换到 feature 分支”。
这种交互方式比手动执行命令要自然得多。你不需要记住工具的具体参数,只需要描述你的意图。这也是 MCP 协议的价值所在——它让 AI 助手真正成为了“能干活”的助手,而不只是“能聊天”的助手。
当然,这也带来了新的挑战:AI 可能会误解你的意图,或者在不该调用工具的时候调用。所以工具的设计需要足够健壮,能够处理各种边界情况,并且在出错时给出清晰的提示。这也是我在实现过程中花最多时间的地方——不是写代码,而是想清楚各种可能的失败场景并妥善处理。