Copilot 支持在仓库里用
.github/agents/NAME.agent.md定义自定义 agent:指定可用的工具,并在 frontmatter 里声明 MCP 服务器。 这篇定义一个只读的接口审查 agent,接入 54 篇的notes_mcp.py作为规范库,并写两个检查:
agents_lint.py按文档检查文件名、字段、target、工具引用、MCP 的type、tools、command/url,以及密钥是否以COPILOT_MCP_开头;check_mcp_readonly.py实际启动 MCP 服务器、读取tools/list,确认每个工具都声明了readOnlyHint: true。文档说明,Copilot code review 会排除缺少这个标注的工具。
先看清成品(box 上真实运行)
api-reviewer.agent.md检查通过。反例查出 6 个 ERROR 和 3 个 WARN,包括文件名有空格、target: copilot、SENTRY_TOKEN没有COPILOT_MCP_前缀、type: websocket、MCP 缺少必填的tools。- 54 篇原版的
notes_mcp.py两个工具的 annotations 都是None。加上ToolAnnotations(read_only_hint=True, ...)之后,检查全部 ✓。- pytest 5 项通过,其中一项会实际启动“去掉标注的服务器”,验证检查脚本确实能发现问题。
合规提醒:GitHub Copilot 需要有效的订阅或组织授权,不要共享账号,也不要用代理绕过地区或组织的限制。Copilot 会把代码上下文发送给 GitHub 和它的模型服务商处理,公司仓库先确认组织策略(比如是否允许使用 cloud agent,是否开启公开代码匹配过滤)。仓库里的说明文件、workflow、agent 配置都不要写密钥,MCP 用到的密钥放在 Agents secrets 里。本文不列价格,以官网为准。
运行说明:box 上没有登录 GitHub,也没有 Copilot 订阅,所以 Copilot 实际是否读取这些文件、cloud agent 是否真的执行了 setup 步骤,都没有验证。本文的规则全部来自 docs.github.com 的原文(文中会标出处),检查脚本、actionlint 和测试都在 box 上真实运行过。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
.github/agents/api-reviewer.agent.md |
只读审查 agent,配有 project-notes MCP |
.github/agents/Release Helper.agent.md |
反例 |
agents_lint.py |
agent 配置检查 |
notes_mcp.py、notes/ |
54 篇 C 的服务器,给两个工具加上了只读标注 |
check_mcp_readonly.py |
读取 tools/list,检查 readOnlyHint |
test_c.py |
5 项测试 |
需要准备哪些环境?
Python 3.10 以上,pip install "mcp>=2" pyyaml pytest(box 上 mcp 是 2.3.0)。
官方规则(来自 custom-agents-configuration 和 extend-coding-agent-with-mcp 文档)
| 规则 | 内容 |
|---|---|
| 位置和文件名 | .github/agents/NAME.agent.md,文件名只能包含 . - _ a-z A-Z 0-9 |
| 字段 | description 必填;可选字段有 name、target(vscode 或 github-copilot)、tools、model、disable-model-invocation、user-invocable、mcp-servers、metadata;infer 已经停用 |
| 正文 | 最多 30,000 个字符 |
| tools | 可以写别名 execute、read、edit、search、agent、web、todo,也可以写 服务器名/工具名;不写表示全部工具,写 [] 表示禁用所有工具 |
| MCP 必填字段 | tools(建议只放行具体的只读工具)、type(local、stdio、http、sse) |
| local 服务器 | 必须有 command 和 args;远程服务器必须有 url |
| 密钥 | 引用的名字必须以 COPILOT_MCP_ 开头,否则 Copilot 拿不到;agent 的 YAML 里还可以用 ${{ secrets.X }} 或 ${{ vars.X }} |
| code review | 只使用 annotations.readOnlyHint 为 true 的 MCP 工具 |
文件结构
58-github-copilot-C-src/
├── .github/agents/{api-reviewer.agent.md, "Release Helper.agent.md"}
├── notes_mcp.py
├── notes/{deploy.md, api-style.md}
├── agents_lint.py
├── check_mcp_readonly.py
└── test_c.py
第 1 步:agent 配置
---
name: api-reviewer
description: 审查接口改动是否符合 notes/api-style.md 的规范,只读,不修改代码
tools: ["read", "search", "project-notes/search_notes", "project-notes/read_note"]
disable-model-invocation: false
mcp-servers:
project-notes:
type: "local"
command: "python3"
args: ["notes_mcp.py"]
tools: ["search_notes", "read_note"]
env:
NOTES_DIR: "notes"
---
你是接口审查员。先用 project-notes 的 search_notes 找到相关规范,再逐条对照改动。
输出:文件、问题、建议。不修改任何文件。
<!-- 反例:Release Helper.agent.md -->
---
description: 发布
target: copilot
infer: true
tools: "read, edit, execute, deploy"
mcp-servers:
sentry:
type: "local"
command: "npx"
args: ["-y", "@sentry/mcp-server@latest"]
env:
SENTRY_TOKEN: "${{ secrets.SENTRY_TOKEN }}"
docs:
type: "websocket"
url: "wss://example.com/mcp"
---
帮我发布。
第 2 步:配置检查
"""按 GitHub 文档检查 .github/agents/*.agent.md(Copilot 自定义 agent 配置)。退出码:有 ERROR 为 1。
依据:docs.github.com 的 custom-agents-configuration、create-custom-agents、extend-coding-agent-with-mcp
"""
import pathlib, re, sys
import yaml
FIELDS = {"name", "description", "target", "tools", "model", "disable-model-invocation", "user-invocable",
"infer", "mcp-servers", "metadata"}
ALIASES = {"execute", "read", "edit", "search", "agent", "web", "todo", "*"}
BUILTIN_SERVERS = {"github", "playwright"}
TYPES = {"local", "stdio", "http", "sse"}
SECRET_REF = re.compile(r"\$\{\{\s*(secrets|vars)\.([A-Za-z0-9_]+)\s*\}\}|\$\{?([A-Za-z0-9_]+)")
MAX_PROMPT = 30000
def as_list(v):
return [x.strip() for x in v.split(",") if x.strip()] if isinstance(v, str) else list(v or [])
def lint(path):
out = []
if not re.fullmatch(r"[.\-_a-zA-Z0-9]+\.agent\.md", path.name):
out.append(("ERROR", f"文件名 {path.name!r} 只能包含 . - _ a-z A-Z 0-9,并以 .agent.md 结尾"))
m = re.match(r"^---\n(.*?)\n---\n?(.*)", path.read_text(encoding="utf-8"), re.S)
if not m:
return out + [("ERROR", "缺少 YAML frontmatter")]
fm, body = yaml.safe_load(m.group(1)) or {}, m.group(2)
if not fm.get("description"):
out.append(("ERROR", "缺少必填字段 description"))
for k in set(fm) - FIELDS:
out.append(("WARN", f"未知字段 {k}"))
if "infer" in fm:
out.append(("WARN", "infer 已停用(Retired),改用 disable-model-invocation / user-invocable"))
if "target" in fm and fm["target"] not in ("vscode", "github-copilot"):
out.append(("ERROR", f"target={fm['target']!r} 无效,只能是 vscode 或 github-copilot"))
if len(body) > MAX_PROMPT:
out.append(("ERROR", f"正文 {len(body)} 字符,超过 {MAX_PROMPT}"))
servers = fm.get("mcp-servers") or {}
for t in as_list(fm.get("tools")):
if "/" in t:
srv = t.split("/", 1)[0]
if srv not in servers and srv not in BUILTIN_SERVERS:
out.append(("ERROR", f"工具 {t} 引用的服务器 {srv} 未在 mcp-servers 中定义"))
elif t not in ALIASES:
out.append(("WARN", f"工具 {t} 不是文档列出的别名 {sorted(ALIASES - {'*'})},请确认它确实存在"))
for name, cfg in servers.items():
typ = cfg.get("type")
if typ not in TYPES:
out.append(("ERROR", f"{name}: type={typ!r} 无效,只能是 {sorted(TYPES)}"))
if not cfg.get("tools"):
out.append(("ERROR", f"{name}: 缺少必填的 tools(文档建议只放行具体的只读工具)"))
elif cfg["tools"] == ["*"]:
out.append(("WARN", f"{name}: tools 为 *,建议只放行具体的只读工具"))
if typ in ("local", "stdio") and not (cfg.get("command") and "args" in cfg):
out.append(("ERROR", f"{name}: local 服务器必须有 command 和 args"))
if typ in ("http", "sse") and not cfg.get("url"):
out.append(("ERROR", f"{name}: 远程服务器必须有 url"))
if any(isinstance(a, str) and a.endswith("@latest") for a in cfg.get("args", [])):
out.append(("WARN", f"{name}: 用了 @latest,版本不固定"))
for k, v in {**(cfg.get("env") or {}), **(cfg.get("headers") or {})}.items():
for g in SECRET_REF.finditer(str(v)):
ref = g.group(2) or g.group(3)
if not ref.startswith("COPILOT_MCP_"):
out.append(("ERROR", f"{name}: {k} 引用的 {ref} 不以 COPILOT_MCP_ 开头,Copilot 拿不到它"))
return out
def main(root="."):
bad = False
for p in sorted(pathlib.Path(root, ".github/agents").glob("*.md")):
res = lint(p)
print(f"{p.name}: {'OK' if not res else ''}")
for lvl, msg in res:
print(f" [{lvl}] {msg}"); bad |= lvl == "ERROR"
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main(*sys.argv[1:2]))
第 3 步:MCP 服务器与只读标注
"""项目知识库 MCP 服务器(stdio):让 Cursor Agent 检索、读取 docs 目录里的团队笔记。
只读;路径限制在 NOTES_DIR 之内,防止读到仓库外的文件。基于官方 Python SDK(mcp 2.x 的 MCPServer)。"""
import os, pathlib, re
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
ROOT = pathlib.Path(os.environ.get("NOTES_DIR", pathlib.Path(__file__).with_name("notes"))).resolve()
from mcp.types import ToolAnnotations
server = MCPServer("project-notes")
# Copilot code review 只使用 readOnlyHint=true 的工具(GitHub 文档),只读工具要显式声明
READ_ONLY = ToolAnnotations(read_only_hint=True, destructive_hint=False, idempotent_hint=True, open_world_hint=False)
def _safe(name: str) -> pathlib.Path:
p = (ROOT / name).resolve()
if not p.is_relative_to(ROOT) or p.suffix != ".md":
# 2.x 里普通异常会被替换成“Error executing tool”,只有 ToolError 的提示会原样传给 Agent
raise ToolError(f"只能读取 {ROOT.name}/ 下的 .md 文件:{name}")
return p
@server.tool(annotations=READ_ONLY)
def search_notes(query: str, limit: int = 5) -> list[dict]:
"""按关键词(空格分隔,全部命中)搜索团队笔记,返回文件名、命中行号和该行内容。"""
terms = [t.lower() for t in query.split() if t]
hits = []
for f in sorted(ROOT.rglob("*.md")):
for i, line in enumerate(f.read_text(encoding="utf-8").splitlines(), 1):
if terms and all(t in line.lower() for t in terms):
hits.append({"file": str(f.relative_to(ROOT)), "line": i, "text": line.strip()[:200]})
return hits[:limit]
@server.tool(annotations=READ_ONLY)
def read_note(name: str) -> str:
"""读取一篇笔记全文,name 为 search_notes 返回的 file。"""
return _safe(name).read_text(encoding="utf-8")
@server.resource("notes://index")
def index() -> str:
"""全部笔记的标题列表。"""
out = []
for f in sorted(ROOT.rglob("*.md")):
first = next((l for l in f.read_text(encoding="utf-8").splitlines() if l.startswith("#")), f.stem)
out.append(f"- {f.relative_to(ROOT)}:{first.lstrip('# ')}")
return "\n".join(out)
if __name__ == "__main__":
server.run() # 默认 stdio
"""启动本地 MCP 服务器,用官方 SDK 客户端读 tools/list,检查每个工具是否声明了 readOnlyHint=true。
Copilot code review 会排除缺少该标注或标为 false 的工具(GitHub 文档)。退出码:有缺失为 1。"""
import asyncio, json, sys
from mcp import Client, StdioServerParameters
async def tools(command, args, env):
async with Client(StdioServerParameters(command=command, args=args, env=env)) as c:
return (await c.list_tools()).tools
def main(cmd=sys.executable, args=("notes_mcp.py",), env=None):
ts = asyncio.run(tools(cmd, list(args), env or {"NOTES_DIR": "notes"}))
bad = 0
for t in ts:
ro = bool(t.annotations and t.annotations.read_only_hint)
bad += not ro
print(("✓" if ro else "✗"), t.name, "readOnlyHint=" + json.dumps(t.annotations.read_only_hint if t.annotations else None))
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
第 4 步:测试
import pathlib, shutil, subprocess, sys
import agents_lint, check_mcp_readonly
HERE = pathlib.Path(__file__).parent
def test_good_agent_clean():
assert agents_lint.lint(HERE / ".github/agents/api-reviewer.agent.md") == []
def test_bad_agent():
msgs = " | ".join(m for _, m in agents_lint.lint(HERE / ".github/agents/Release Helper.agent.md"))
for s in ["只能包含", "infer 已停用", "target='copilot'", "SENTRY_TOKEN 不以 COPILOT_MCP_ 开头", "type='websocket'", "@latest"]:
assert s in msgs
def test_copilot_mcp_prefix_ok(tmp_path):
p = tmp_path / "ok.agent.md"
p.write_text('---\ndescription: 测试用的 agent\nmcp-servers:\n s:\n type: "local"\n command: "x"\n args: []\n'
' tools: ["a"]\n env:\n T: "${{ secrets.COPILOT_MCP_T }}"\n U: "$COPILOT_MCP_U"\n---\nhi\n', encoding="utf-8")
assert agents_lint.lint(p) == []
def test_readonly_hint_real_server():
assert check_mcp_readonly.main(sys.executable, ["notes_mcp.py"]) == 0
def test_readonly_hint_missing(tmp_path):
src = (HERE / "notes_mcp.py").read_text(encoding="utf-8").replace("@server.tool(annotations=READ_ONLY)", "@server.tool()")
(tmp_path / "notes_mcp.py").write_text(src, encoding="utf-8")
shutil.copytree(HERE / "notes", tmp_path / "notes")
r = subprocess.run([sys.executable, str(HERE / "check_mcp_readonly.py")], cwd=tmp_path, capture_output=True, text=True, check=False)
assert r.returncode == 1 and "✗ search_notes readOnlyHint=null" in r.stdout
第 5 步:运行
$ python agents_lint.py
Release Helper.agent.md:
[ERROR] 文件名 'Release Helper.agent.md' 只能包含 . - _ a-z A-Z 0-9,并以 .agent.md 结尾
[WARN] infer 已停用(Retired),改用 disable-model-invocation / user-invocable
[ERROR] target='copilot' 无效,只能是 vscode 或 github-copilot
[WARN] 工具 deploy 不是文档列出的别名 ['agent', 'edit', 'execute', 'read', 'search', 'todo', 'web'],请确认它确实存在
[ERROR] sentry: 缺少必填的 tools(文档建议只放行具体的只读工具)
[WARN] sentry: 用了 @latest,版本不固定
[ERROR] sentry: SENTRY_TOKEN 引用的 SENTRY_TOKEN 不以 COPILOT_MCP_ 开头,Copilot 拿不到它
[ERROR] docs: type='websocket' 无效,只能是 ['http', 'local', 'sse', 'stdio']
[ERROR] docs: 缺少必填的 tools(文档建议只放行具体的只读工具)
api-reviewer.agent.md: OK
$ python check_mcp_readonly.py
✓ search_notes readOnlyHint=true
✓ read_note readOnlyHint=true
$ python -m pytest -q
5 passed in 2.16s
调试示例
54 篇的 MCP 服务器在 code review 里会“消失”。 我直接复用了 54 篇的 notes_mcp.py,然后用官方 SDK 客户端读取 tools/list:
search_notes None
read_note None
两个工具都没有 annotations。按文档的说法,Copilot code review 会排除缺少 readOnlyHint 的工具,而且不会给出任何报错,agent 只是“用不了规范库”。用 inspect.signature(MCPServer.tool) 确认 SDK 2.x 的装饰器支持 annotations 参数之后,我给两个工具加上了 ToolAnnotations(read_only_hint=True, destructive_hint=False, idempotent_hint=True, open_world_hint=False),检查就变成了全部 ✓。测试里保留了“去掉标注”的反向用例,防止以后改回去。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| agent 没有出现在下拉菜单里 | 文件名有非法字符,或者还没合并到默认分支 | 改文件名并合并 |
| MCP 工具在 code review 里用不了 | 工具没有声明 readOnlyHint: true |
在服务器端加上 annotations |
| MCP 拿不到密钥 | 密钥名不是 COPILOT_MCP_ 开头,或者没有配置成 Agents secret |
改名,并在仓库或组织设置里添加 |
infer 不起作用 |
这个字段已经停用 | 改用 disable-model-invocation 和 user-invocable |
| VS Code 里的 agent 没有带上 MCP | mcp-servers 在 IDE 里不生效 |
在 IDE 里另外配置 MCP |
模板规则
- 审查类 agent 只给
read和search,以及具体的只读 MCP 工具。 - MCP 服务器给只读工具显式声明
readOnlyHint,并用检查脚本确认。 - 密钥统一使用
COPILOT_MCP_前缀,放在 Agents secrets 里。
项目资源
agent 配置和 MCP 服务器都做了检查,就不会出现“看起来配好了,实际用不了”的情况。