md-github
md-github
一个轻量级 MCP 服务器,让 claude.ai 自定义连接器 能够在一个账户的各个 GitHub 仓库中执行精准的 markdown 编辑,并将任意数量的更改批量合并为恰好一次提交。
Claude 通过 OAuth 2.1 + DCR 进行身份验证(这是连接器表单所要求的)。每个人的同意密钥选择各自的 GitHub PAT 和各自的账户。零运行时依赖,无数据库,所有状态都保存在内存中。
这不是一个 GitHub MCP 透传。它只暴露七个工具,仅此而已。
工具
Tool | 作用 |
| 一次调用即可在仓库中定位:其根目录 |
| 每个 .md 文件的字节大小和 git blob SHA,可选显示每个文件的标题大纲。只读。 |
| 一个文件的确切字节——或一次调用读取多个文件——每个文件都带有其 blob SHA 和带行范围的标题大纲。只读。 |
| 最近的提交——每个提交的作者、时间和消息。可选路径过滤。只读。 |
| 一次提交的作者、消息和逐文件差异。只读。 |
| 应用有序的编辑列表,并将它们作为一次提交推送。唯一会编辑 markdown 的工具。 |
| 创建仓库,并用其自己的 |
除 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 |
|
|
|
|
| 精确字节匹配;除非使用 |
|
| 按标题寻址一个 section,执行 |
|
| 删除一个文件。 |
为什么没有 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 重新运行:如果批次触及的内容没有移动,它会静默落地;如果确实有移动,它会停止并内联返回最新的上游内容,而不是覆盖。
环境
变量 | 说明 |
| 为此服务器颁发的令牌签名。 |
| 此服务自身的基础 URL,无尾部斜杠。 |
| 固定为 3000,以匹配生成的 Railway 域名。 |
| 默认为 |
每人一组编号三元组:
变量 | 说明 |
| 该人在同意页面上输入的内容。身份就是秘密。 |
| 该人的 GitHub PAT。仅用于他们自己的请求,也是他们全部的可达范围。 |
| 可选标签,默认为 |
这就是完整的按人配置:一个秘密和一个 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 连接
设置 → 连接器 → 添加自定义连接器。
URL:
<PUBLIC_URL>/mcp。将客户端 ID 和密钥留空。
连接,然后输入你自己的
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:smoketests/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 正好相反。
This server cannot be deployed
Maintenance
Related MCP Connectors
Connect AI assistants to your GitHub-hosted Obsidian vault to seamlessly access, search, and analy…
Connect AI assistants to GitHub - manage repos, issues, PRs, and workflows through natural language.
Edit your Overleaf LaTeX projects from Claude and ChatGPT; every change is a real Git commit.
Operate GitHub from your AI assistant: repos, PRs, issues, Actions. Previews and read-only mode.
Related MCP Servers
- AlicenseNot gradedqualityCmaintenanceConnects claude.ai to a private GitHub repo of markdown files as a personal second brain, providing guarded read and write tools for knowledge management.17 npmMIT
- AlicenseNot gradedqualityDmaintenanceConnects Claude to GitHub repositories for querying repos, reviewing PRs, managing issues, searching code, and automating workflows.MIT
- AlicenseNot gradedqualityDmaintenanceConnects Claude directly to GitHub repositories for reading code, making changes, committing, and managing branches and pull requests.281 npmMIT
- AlicenseNot gradedqualityAmaintenanceEnables Claude to maintain a team knowledge base stored as markdown in a GitHub repository, with tools for search, edit, linking, and interactive views.2 npm2AGPL 3.0