每次开发完成后,创建 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());
这里有几个容易忽略的点:
inputSchema让 SDK 在处理函数执行前校验参数;处理函数返回的content才是客户端能读取的工具结果。- PAT 从运行环境读取,不放进工具参数或文章示例。调用 Azure DevOps 时,HTTP Basic 的用户名留空,PAT 放在密码位置。
- 查询时同时筛选
sourceRefName、targetRefName和active,避免把反方向的 PR 当成同一个。对应的查询参数见微软的查询 PR 接口文档。 - 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 才会成为可维护的工作流。