开发技巧

GitHub Copilot:自定义 Agent 与 MCP 配置

Copilot 支持在仓库里用 .github/agents/NAME.agent.md 定义自定义 agent:指定可用的工具,并在 frontmatter 里声明 MCP 服务器。 这篇定义一个只读的接口审查 agent,接入 54 篇的 notesmcp.py 作为规范库,并写两个检查: - agentslint.py 按文档检查文件名、字段、targ

BINBHO · 图文教程

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 里。

项目资源

用途 地址
Copilot 文档首页 https://docs.github.com/en/copilot
自定义 agent 配置参考 https://docs.github.com/en/copilot/reference/custom-agents-configuration
创建自定义 agent https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/create-custom-agents
用 MCP 扩展 cloud agent https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/extend-coding-agent-with-mcp
MCP Python SDK https://github.com/modelcontextprotocol/python-sdk

agent 配置和 MCP 服务器都做了检查,就不会出现“看起来配好了,实际用不了”的情况。