Skip to main content
Glama

md-github

一个轻量级 MCP 服务器,让 claude.ai 自定义连接器 能够在一个账户的各个 GitHub 仓库中执行精准的 markdown 编辑,并将任意数量的更改批量合并为恰好一次提交。

Claude 通过 OAuth 2.1 + DCR 进行身份验证(这是连接器表单所要求的)。每个人的同意密钥选择各自的 GitHub PAT 和各自的账户。零运行时依赖,无数据库,所有状态都保存在内存中。

这不是一个 GitHub MCP 透传。它只暴露七个工具,仅此而已。

工具

Tool

作用

overview

一次调用即可在仓库中定位:其根目录 INDEX.md 的原文,以及每个文件路径及其大小。只读。

list_md

每个 .md 文件的字节大小和 git blob SHA,可选显示每个文件的标题大纲。只读。

read_md

一个文件的确切字节——或一次调用读取多个文件——每个文件都带有其 blob SHA 和带行范围的标题大纲。只读。

history

最近的提交——每个提交的作者、时间和消息。可选路径过滤。只读。

show_commit

一次提交的作者、消息和逐文件差异。只读。

commit_edits

应用有序的编辑列表,并将它们作为一次提交推送。唯一会编辑 markdown 的工具。

create_repo

创建仓库,并用其自己的 INDEX.md 初始化。

除 create_repo 之外,每个工具都要求 repo 参数——一个裸名称("notes")或 "owner/name"。用 overview(repo) 开始一个会话;它是唯一能告诉你仓库里有什么的调用。

AGENT-TEMPLATE.md 是要粘贴到将使用此连接器的 agent 处的指令块,./connector-prompt.sh <repo> 会填入仓库名称并将其复制到剪贴板。

Related MCP server: GitHub MCP Server

多个仓库,一个连接

一个身份可以访问其 PAT 能看到的每个仓库——拥有的、协作的,或通过组织访问的。没有需要配置的 owner:令牌已经属于某个账户,并且已经携带了它自己的访问权限,所以它能看到的仓库集合就是命名空间。再次配置它只会制造第二个可能与令牌不一致的事实来源。

裸的 repo:"notes" 会针对该可见集合进行解析;repo:"owner/notes" 跳过查找,直接寻址该仓库。一个在两个 owner 下都可见的名称会返回一个同时列出两者的拒绝,而不是猜测。启动时不会枚举任何内容,因此由 create_repo 创建的仓库会在紧接着的下一次调用中解析成功,无需重新部署。

没有默认仓库

repo 是必需的,省略它的调用会报错,而不是猜测。当一个连接背后有多个项目时,不存在所谓的“那个”仓库,而每一个看似合理的回退——第一个、启动时配置的那个、最后碰过的那个——都是一种让编辑落入错误项目的方式,同时每条消息看起来仍然像成功。错误仓库的提交也是这里 expect_sha 无法捕获的唯一错误,因为它所保护的 blob 位于一个没人关注的仓库中。

没有任何东西会列出连接可以访问的仓库——结果中不会,握手中也不会。每个工具都显式地指名其仓库,因此发起调用永远不需要名册;而对于权限范围很广的 PAT,名册会把一屏不相关的名称放到每一个结果上。可见集合只用于解析裸名称时从 GitHub 读取,并且永远不会被打印出来。

该解析读取的是 GET /user/repos,而不是 GET /users/:owner/repos——后者即使对你的自有账户也只返回公开仓库,因此私有笔记仓库根本无法按名称解析。它会被缓存五分钟,未命中时会先重新获取一次再失败,所以刚刚在别处创建的仓库仍然可以解析。

除了 PAT,没有任何东西会收窄连接

没有固定仓库,没有仓库白名单,没有名称前缀,没有分支覆盖,也没有子树限制。早期版本都有这些;它们被移除了。每一个都是紧挨着 PAT 自身权限范围的第二道边界,只能与 PAT 产生分歧,而且每一个都让 create_repo 变得不自洽——一个尚不存在的仓库不可能出现在启动时写入的白名单上。每个仓库都使用自己的默认分支,原因相同:一个身份横跨多个仓库,而存在于一个仓库的分支很少会存在于下一个仓库。

PAT 就是边界。在 GitHub 中设置它的权限范围,那里才是它真正生效的地方。

每个仓库自我记录

这里刻意没有跨仓库的索引文件。每个仓库在自己的根目录 INDEX.md 中自我记录,这个文件既是 create_repo 初始化的文件,也是此服务器回填到上下文中的文件。某个仓库中的注册表会成为事实存在的第二个位置,而且一旦有人在连接器之外重命名项目,它就会立刻过期。

定位:overview

会话从调用 overview(repo) 开始。一次往返返回该仓库根目录 INDEX.md 的原文——这个路由器说明哪个文件回答哪个问题——以及仓库中每个路径及其大小。在本项目自己的上下文仓库中,这是 152 个路径和 ~7k 个 token,之后模型在获取任何内容之前就知道一切在哪里、有多大。

其他一切都遵循路由器:用 read_md({paths:[...]}) 读取它指向的文件夹索引,用 list_md({path_prefix, outline:true}) 来缩小范围。

刻意不内联:文件夹级别的索引文件。在本项目的上下文仓库中,其中一个有 66 KB——把它们全部内联的代价比读取它们所描述的文件还要高。

其他任何东西都不会把索引附加到结果上。早期版本会把路由器附加到每个工具结果上;一个 13 KB 的索引是 ~3.5k 个 token,所以一个十次调用的会话为同一条信息付了十次费。overview 在被请求时只交付一次,其他任何东西都不会再附加一个。

索引从仓库根目录的 INDEX.md、index.md、README.md 中的第一个读取,缓存两分钟,并且会被通过此服务器的任何提交失效——所以模型刚刚重写的路由器永远不会被读回过期版本。该缓存和名称解析列表是此服务器仅有的两样缓存:两者都永远不会成为 blob SHA 的来源,所以过期的缓存不会导致错误的写入。出于这个原因,树被刻意不缓存。

谁编辑了什么

每个人自己的 PAT 会被注入,因此 GitHub 会把真实的人记录为提交作者——这是真正的 git 归属,不是服务器合成的任何东西。history 回答“谁改了这个文件”,show_commit 显示实际的差异,git blame 在应用之外也能正常工作。

有两个值得了解的局限。history(path) 不跟踪重命名,所以重命名之前的提交会列在旧路径下——与不带 --follow 的 git log 相同。另外,提交的文件列表被 GitHub 以 300 个文件分页;工具在到达该边界时会报告,而不是把部分列表呈现为完整列表。

一次读取多个文件

read_md 接受 paths: [...](最多 20 个)而不是 path。这样读取五个文件夹索引就是一次往返而不是五次,max_bytes 成为跨批次共享的预算,按给定顺序消耗。该批次刻意不是全有或全无:不存在的路径会报告自己的错误,而其他路径仍然返回。全有或全无是 commit_edits 的属性,在那里部分结果意味着仓库损坏;而在这里它只会多花一次往返。

list_md 接受 outline: true 来显示每个文件的标题,而无需读取文件。这需要每个文件一次读取,所以超过 40 个文件会被拒绝,并说明如何缩小范围。

创建仓库

create_repo({name, overview}) 在连接的 owner 下创建仓库,并用 # <name> 加上 overview 组成 INDEX.md 来初始化它。overview 意在成为读者最先看到的文档,而不是一行摘要——它是该仓库的路由器。

它在 GitHub 上的两个效果——先创建仓库,再创建首次提交——不可能是一个事务,所以它们被分别报告。如果初始化提交失败,结果会说明仓库已存在且为空,并指出完成该工作的确切 commit_edits 调用。它不会删除刚刚创建的仓库:为了收拾一个错误而销毁一个命名空间,是远比一个空仓库更糟糕的失败。

写入没有提交的仓库

一个全新的仓库根本无法通过 GitHub 的 git-data API 写入:blob、tree 和 commit 都会返回 409 Git Repository is empty.。唯一可用的端点是 PUT /contents,它在一个请求中同时创建分支和初始提交——所以这就是 create_repo 用来初始化的方式,也是此服务器唯一调用 PUT /contents 的地方。

它只写一个文件,所以写入空仓库的批次也只能携带一个文件。多文件批次会被拒绝并附上说明,而不是拆成两次提交,因为“一次调用,一次提交”是整个设计所依赖的保证。

POST /user/repos 在令牌所属的账户下创建,而不是在请求体中的名称下创建——所以一个仅仅在别人的仓库上协作的 PAT 会在它自己的账户中创建新仓库。结果报告的是 GitHub 返回的 full_name,而不是此服务器假设的名称。它会在紧接着的下一次调用中按名称解析成功。

创建仓库需要的权限不止 Contents: Read and write。带有 repo 权限范围的经典 PAT 可以;细粒度 PAT 需要 Administration: Read and write,完全无法在个人账户中创建仓库(只能在组织中创建),而且如果它被限制在选定的仓库,它也无法写入新仓库——所以多仓库使用需要 All repositories。403 会准确说明这一点,而不是通用的 contents 错误消息。

commit_edits 支持四种操作:

op

Fields

Notes

write

path, content, mode

create(默认)、overwrite(需要 expect_sha)、append。

str_replace

path, old_string, new_string, replace_all

精确字节匹配;除非使用 replace_all,否则必须唯一。

edit_section

path, heading, mode, content

按标题寻址一个 section,执行 replace / append / prepend / delete。

delete

path, expect_sha

删除一个文件。

为什么没有 start_commit / end_commit

显而易见的设计是一个暂存区,你打开它,之后用一条消息关闭它。它被刻意否决了:它创造了一个地方,让看起来已完成的工作可以搁置而不发布,于是“我做了编辑但忘了提交”就变得可以构造出来。三次独立的设计评审得出了相同的结论。

相反,根本没有暂存区。commit_edits 是原子的——跨多个文件的一整批更改,在一次调用中应用并推送,或者完全不向 GitHub 发送任何内容。代理在自己的上下文中(模型唯一能可靠读取的存储)积累计划,并在一次调用中将其消耗掉。每个工具结果都以一行常驻说明结尾,表明没有待处理的内容,因此“有东西已排队”的信念会不断被驳斥,而不是留待以后发现。

同样也没有自动提交定时器,任何超时时间下都没有。空闲定时器会发布无人批准的工作——一次撤回的删除、一次半完成的重构。零多余提交是设计使然,而非疏忽。关闭时,服务器会记录它丢弃的内容,并且不提交任何内容。

唯一保留的状态是仅限失败的:失败的批次会作为 retry_ref 保留 30 分钟,这样大批次就不必从可能已被压缩的上下文中重新输入。它会在每个后续结果中公布,任何成功都会清除它,而且它永远不会产生成功回执。它还绑定到其编写时所针对的仓库:拒绝将其重放到另一个仓库,因为这些编辑是根据另一个仓库从未包含的文本构建的。

原子性,精确地说

commit_edits 运行两个阶段,而两个阶段之间的边界就是保证。

  • 计划——验证、快照获取、expect_sha 检查、将每个操作应用到内存缓冲区。任何失败都会在此中止,且只发出过 GET 请求。不是“已回滚”:从未发送过任何变更请求。批次中的所有失败会一起报告,因此一个 12 个操作、3 个缺陷的批次只花费一轮,而不是三轮。

  • 执行——三个变更请求(POST /git/trees、POST /git/commits、PATCH /git/refs),无论更改了多少文件。只有最终的 PATCH 是可观察的。

因此,在任何调用之后,恰好有两种可观察状态:要么存在一个提交,要么分支与之前逐字节相同。

expect_sha 恰好是在操作销毁整个文件的地方必需的——delete 和 write mode=overwrite。你不能整体替换或删除你从未观察过的文件。list_md 返回完整的 blob SHA,因此删除永远不需要读取内容。

并发

内容在提交时从单个固定的快照重新读取,因此读-修改-写窗口大约为一秒,而不是一次对话的长度。PATCH ... force:false 是真正的服务器端比较并交换;force: true 永远不会发送到任何地方。发生冲突时,整个计划会针对新的 head 重新运行:如果批次触及的内容没有移动,它会静默落地;如果确实有移动,它会停止并内联返回最新的上游内容,而不是覆盖。

环境

变量

说明

JWT_SECRET

为此服务器颁发的令牌签名。

PUBLIC_URL

此服务自身的基础 URL,无尾部斜杠。

PORT

固定为 3000,以匹配生成的 Railway 域名。

GITHUB_API_URL

默认为 https://api.github.com。测试接缝。

每人一组编号三元组:

变量

说明

USER<N>_SECRET

该人在同意页面上输入的内容。身份就是秘密。

USER<N>_PAT

该人的 GitHub PAT。仅用于他们自己的请求,也是他们全部的可达范围。

USER<N>_NAME

可选标签,默认为 user<N>。成为令牌的 sub。

这就是完整的按人配置:一个秘密和一个 PAT。没有其他需要设置的内容——PAT 能看到的每个仓库都是可达的,每次调用都会指明它作用于哪个仓库。

USER<N>_NAME 是身份键,而不是标签:重命名某人会使他们的活动令牌失效,他们必须重新连接。更改他们的 PAT 会立即生效,无需重新连接。

迁移旧部署:如果你有 USER<N>_REPO、USER<N>_OWNER、USER<N>_REPOS、USER<N>_REPO_PREFIX、USER<N>_BRANCH 和 USER<N>_ROOT,请删除它们。它们都不再被读取,一个秘密加一个 PAT 就是全部配置。

从 claude.ai 连接

  1. 设置 → 连接器 → 添加自定义连接器。

  2. URL:<PUBLIC_URL>/mcp。

  3. 将客户端 ID 和密钥留空。

  4. 连接,然后输入你自己的 USER<N>_SECRET。

两个人都添加相同的 URL;各自输入的秘密会将他们的会话绑定到他们自己的 PAT 和仓库。

测试

npm install && npm run build
npm run test:unit       # 92 assertions: scanner, edit ops, byte fidelity — no network

# integration: 258 assertions against a stateful fake GitHub
USER1_NAME=alice USER1_SECRET=secret-alice USER1_PAT=pat-alice \
USER2_NAME=bob   USER2_SECRET=secret-bob   USER2_PAT=pat-bob \
USER3_NAME=frank USER3_SECRET=secret-frank USER3_PAT=pat-frank \
JWT_SECRET=test-jwt PUBLIC_URL=http://127.0.0.1:8787 PORT=8787 \
GITHUB_API_URL=http://127.0.0.1:8899 npm start &
npm run test:smoke

tests/fake-github.mjs 是一个有状态的假实现,具有真实的 git-blob-SHA 实现、提交 DAG、按令牌的仓库可见性(拥有的加上协作的,因此名称解析能证明一些东西)、请求日志和可注入故障,因此通过服务器进行的提交可以被后续读取观察到。它让测试套件能够断言真正重要的事情:N 次编辑恰好产生一个提交和零次 PUT /contents 调用,删除在序列化后保留为字面量 "sha":null,force:false 出现在每次 ref 更新上,失败的批次留下零个变更请求,同事的并发推送永远不会被覆盖,指定一个仓库的调用不会向其他仓库发出请求,create_repo 恰好在新仓库中产生一个提交,并报告 GitHub 实际在其下创建该仓库的账户,以及失败后保留的批次不能被重放到不同的仓库。

部署

railway up --service mcp-github-proxy --detach

刻意通过 Dockerfile 构建。Railway 的默认构建器(railpack)会使此服务失败,并报错 failed to solve: secret RAILWAY_GIT_REPO_OWNER not found——其生成的计划声明了 RAILWAY_GIT_* 构建密钥,而这些密钥仅在服务的源代码是已连接的 GitHub 仓库时才存在,而不是 CLI tarball 上传。

备注

  • markdown 扫描器是真正的 CommonMark 块扫描器,而不是 ^#{1,6} 正则表达式。front matter 的结束 --- 是合法的 setext H2 下划线,因此天真的扫描会凭空产生一个以最后一行 YAML 命名的幻影标题,代理会直接编辑到 front matter 中。围栏内、缩进代码、HTML 块和块引用中的标题正确地不可寻址。

  • 字节保真是刻意的:CRLF 文件在未触及的行上保持 CRLF,BOM 会被分离出来,以便以开头锚定的 old_string 能够匹配,而且任何内容都不会被修剪——两个尾随空格是 markdown 的硬换行。

  • read_md 返回的内容不带行号边栏,因为模型即将复制到 old_string 的文本旁边的数字,正是边栏最终出现在搜索词中的方式。行号只出现在大纲和错误消息中——这些地方不会复制任何内容。

  • 完整返回的短文件没有大纲。大纲是你尚未阅读的文件的地图;在它所描述的十二行上方打印一份大纲是噪音。当文件长到需要分页浏览,或窗口是部分的时候,它就会重新出现。

  • GitHub 401 会以工具错误文本的形式呈现,而绝不会以来自 /mcp 的 HTTP 401 呈现。旧的代理转发了 GitHub 的 WWW-Authenticate,这使 claude.ai 去 GitHub 重新认证,并产生了重新认证循环,而真正的问题——失效的 PAT——仍然不可见。

  • PAT 是真正的安全边界,现在它也是唯一决定可达范围的边界:此服务器的配置中没有任何内容会缩小它。在 GitHub 中为 PAT 本身设置范围。注意与 create_repo 的紧张关系:它需要一个能够访问令牌创建时尚不存在的仓库的令牌,这与选定仓库的细粒度 PAT 正好相反。

Related MCP Connectors

Related MCP Servers