Skip to main content
Glama

md-github

1つのアカウントのGitHubリポジトリ全体に対して、claude.ai カスタムコネクタが外科的なmarkdown編集を行えるようにする小さなMCPサーバーです。任意の数の変更を正確に1つのコミットにまとめます。

ClaudeはOAuth 2.1 + DCR(コネクタフォームで必須)で認証します。各人の同意シークレットが、その人のGitHub PATと自分のアカウントを選択します。ランタイム依存関係ゼロ、データベースなし、すべての状態はメモリ内に保持されます。

これはGitHub MCPパススルーではありません。7つのツールだけを公開し、それ以外は何も公開しません。

The tools

Tool

説明

overview

リポジトリの全体像をつかむための1回の呼び出し: ルートの INDEX.md をそのまま返し、さらにすべてのファイルパスとそのサイズを返します。読み取り専用。

list_md

すべての .md ファイルをバイトサイズと git blob SHA 付きで返します。オプションで各ファイルの見出しアウトラインも返します。読み取り専用。

read_md

1つのファイルの正確なバイト列 — または1回の呼び出しで複数ファイル — それぞれに blob SHA と行範囲付きの見出しアウトラインを返します。読み取り専用。

history

最近のコミット — 各コミットの作者、日時、メッセージ。オプションのパスフィルター付き。読み取り専用。

show_commit

1つのコミットの作者、メッセージ、ファイルごとの差分を返します。読み取り専用。

commit_edits

順序付けられた編集リストを適用し、1つのコミットとしてプッシュします。markdownを編集する唯一のツールです。

create_repo

リポジトリを作成し、独自の INDEX.md をシードします。

create_repo を除くすべてのツールは repo を必須とします — ベア名 ("notes") または "owner/name" です。 セッションは overview(repo) で開始します。それが、その中に何があるかを教えてくれる唯一の呼び出しです。

AGENT-TEMPLATE.md は、このコネクタを使用するエージェントに貼り付ける指示ブロックです。./connector-prompt.sh <repo> はリポジトリ名を埋め込んでクリップボードにコピーします。

Related MCP server: GitHub MCP Server

多数のリポジトリ、1つの接続

1つのアイデンティティは、そのPATが見ることのできるすべてのリポジトリに到達します — 所有しているもの、共同作業しているもの、または組織経由のもの。設定するオーナーはありません。トークンはすでに1つのアカウントに属し、すでに独自のアクセス権を保持しているため、トークンが見ることのできるリポジトリの集合が名前空間です。それを再設定することは、トークンと矛盾し得る第二の真実の源を作るだけです。

ベアの repo:"notes" はその可視集合に対して解決されます。repo:"owner/notes" はルックアップをスキップしてリポジトリに直接アドレス指定します。2つのオーナーの下で見える名前は、推測ではなく、両方を挙げた拒否になります。起動時に何も列挙されないため、create_repo で作成されたリポジトリは、再デプロイなしで次の呼び出しで解決されます。

デフォルトのリポジトリは存在しない

repo は必須であり、それを省略した呼び出しは推測ではなくエラーになります。1つの接続の背後に複数のプロジェクトがある場合、「その」リポジトリというものは存在せず、あり得るすべてのフォールバック — 最初のもの、起動時に設定されたもの、最後に触れたもの — は、すべてのメッセージが成功のように読める一方で、編集が誤ったプロジェクトに着地する方法です。間違ったリポジトリへのコミットは、expect_sha が捕捉できない唯一のミスでもあります。なぜなら、それが保護するblobは誰も見ていないリポジトリの中にあるからです。

接続が到達できるリポジトリを列挙するものは何もありません — 結果にも、ハンドシェイクにも。すべてのツールはリポジトリを明示的に指定するため、呼び出しを行うために名簿は不要であり、広いスコープのPATの場合、すべての結果に無関係な名前が画面いっぱいに並ぶことになります。可視集合はベア名を解決するためだけにGitHubから読み取られ、決して出力されません。

その解決は GET /user/repos を読み取ります。GET /users/:owner/repos ではありません — 後者は自分のアカウントでも公開リポジトリしか返さないため、プライベートな notes リポジトリは名前ではまったく解決されません。5分間キャッシュされ、ミスした場合は失敗する前に一度だけ再取得するため、少し前に別の場所で作成されたリポジトリでも解決されます。

接続を絞り込むものはPAT以外にない

固定リポジトリも、リポジトリ許可リストも、名前プレフィックスも、ブランチ上書きも、サブツリー制限もありません。以前のバージョンにはすべてありましたが、削除されました。それぞれがPAT自身のスコープの隣にある第二の境界であり、PATと矛盾することしかできず、それぞれが create_repo を無意味にしました — まだ存在しないリポジトリは、起動時に書かれた許可リストに載ることはできません。すべてのリポジトリが独自のデフォルトブランチを使用するのも同じ理由です。1つのアイデンティティが多くのリポジトリにまたがるので、あるリポジトリに存在するブランチが次のリポジトリに存在することはほとんどありません。

PATが境界です。GitHubで、実際に効力を持つ場所でスコープを設定してください。

各リポジトリは自己文書化する

意図的にリポジトリ横断的なインデックスファイルはありません。各リポジトリは自身のルート INDEX.md で自己文書化します。これは create_repo がシードするファイルであり、このサーバーがコンテキストにフィードバックするファイルです。あるリポジトリ内のレジストリは、真実が存在する第二の場所になり、コネクタの外で誰かがプロジェクト名を変更した最初の時点で古くなります。

オリエンテーション: overview

セッションは overview(repo) を呼び出すことから始まります。1回のラウンドトリップで、そのリポジトリのルート INDEX.md をそのまま返します — どのファイルがどの質問に答えるかを示すルーター — さらにリポジトリ内のすべてのパスとそのサイズを返します。このプロジェクト自身のコンテキストリポジトリでは、152パス、~7kトークンです。その後、モデルは何かを取得する前に、すべてがどこにあり、どれだけ大きいかを把握します。

それ以外はすべてルーターに従います。read_md({paths:[...]}) でルーターが指すフォルダインデックスを取得し、list_md({path_prefix, outline:true}) で絞り込みます。

意図的にインライン化されていません: フォルダーレベルのインデックスファイルです。このプロジェクトのコンテキストリポジトリにあるそのうちの1つは66 KBです — それらすべてをインライン化すると、それらが説明するファイルを読むよりもコストがかかります。

それ以外の何も、結果にインデックスを添付しません。以前のバージョンではルーターをすべてのツール結果に追加していました。13 KBのインデックスは~3.5kトークンなので、10回呼び出すセッションでは、1つの情報に対して10回支払うことになります。overview は要求されたときに一度だけそれを提供し、それ以外の何もインデックスを添付しません。

インデックスは、リポジトリルートの INDEX.md、index.md、README.md のうち最初のものから読み取られ、2分間キャッシュされ、このサーバーを通じた任意のコミットによって無効化されます — そのため、モデルが書き換えたばかりのルーターが古い状態で読み戻されることは決してありません。そのキャッシュと名前解決リストは、このサーバーがキャッシュする唯一のものです。どちらもblob SHAのソースになることはないため、古いキャッシュが誤った書き込みを引き起こすことはありません。ツリーは意図的にキャッシュされません。そのためです。

誰が何を編集したか

各人の自分のPATが注入されるため、GitHubは実際の人間をコミット作者として記録します — これは本物のgit帰属であり、サーバーが合成するものではありません。history は "誰がこのファイルを変更したか" に答え、show_commit は実際の差分を表示し、git blame はアプリの外で通常どおり機能します。

知っておく価値のある2つの制限があります。history(path) はリネームを追跡しないため、リネーム前のコミットは古いパスの下にリストされます — --follow なしの git log と同じです。また、コミットのファイルリストはGitHubによって300ファイルでページネーションされます。ツールは、部分的なリストを完全なものとして提示するのではなく、その境界に達したことを報告します。

複数のファイルを一度に読む

read_md は path の代わりに paths: [...](最大20)を受け取ります。5つのフォルダインデックスを読むのは、5回ではなく1回のラウンドトリップになります。max_bytes はバッチ全体で共有される予算となり、指定された順序で消費されます。バッチは意図的に全か無かではありません。存在しないパスは独自のエラーを報告し、他のパスは依然として返されます。全か無かは commit_edits の性質です。そこでは部分的な結果がリポジトリの破損になるからです。ここでは、それは単にラウンドトリップのコストがかかるだけです。

list_md は outline: true を受け取り、各ファイルを読まずに見出しを表示します。これはファイルごとに1回の読み取りなので、40ファイルを超えると拒否され、絞り込み方法を示します。

リポジトリの作成

create_repo({name, overview}) は、接続のオーナーの下にリポジトリを作成し、# <name> と overview からなる INDEX.md をシードします。overview は、読者が最初にたどり着くドキュメントであることを意図しています。1行の要約ではなく、そのリポジトリのルーターです。

GitHubに対する2つの効果 — リポジトリの作成、次にその最初のコミット — は1つのトランザクションにはできません。そのため、それらは別々に報告されます。シードコミットが失敗した場合、結果はリポジトリが存在し空であることを示し、ジョブを完了する正確な commit_edits 呼び出しを指名します。作成したばかりのリポジトリを削除しません。エラーを整理するために名前空間を破壊することは、空のリポジトリよりもはるかに悪い失敗です。

コミットのないリポジトリへの書き込み

真新しいリポジトリは、GitHubのgit-data APIではまったく書き込めません。blob、ツリー、コミットはすべて 409 Git Repository is empty. と応答します。機能する唯一のエンドポイントは PUT /contents で、ブランチと最初のコミットを1つのリクエストで作成します。そのため、create_repo はこれでシードし、このサーバーが PUT /contents を呼び出すのはここが唯一の場所です。

これは正確に1つのファイルを書き込むため、空のリポジトリへのバッチが運べるのも正確に1つのファイルです。複数ファイルのバッチは、2つのコミットに分割されるのではなく、指示付きで拒否されます。"1回の呼び出し、1つのコミット" が設計全体の基盤となる保証だからです。

POST /user/repos は、リクエストボディの名前ではなく、トークンが属するアカウントの下に作成します。そのため、他人のリポジトリで単に共同作業しているだけのPATは、自分自身のアカウントに新しいリポジトリを作成します。結果は、このサーバーが想定した名前ではなく、GitHubが返した full_name を報告します。次の呼び出しで名前によって解決されます。

リポジトリの作成には、Contents: Read and write 以上のものが必要です。repo スコープを持つクラシックなPATで機能します。ファイングレインPATは Administration: Read and write が必要で、個人アカウントではリポジトリをまったく作成できず(組織のみ)、選択したリポジトリに制限されている場合、新しいリポジトリに書き込むこともできません。そのため、複数リポジトリでの使用にはAll repositoriesが必要です。403は、一般的なcontentsメッセージの代わりに、まさにこれを示します。

commit_edits は4つの操作を受け取ります:

操作

フィールド

備考

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

見出しで指定されたセクションを replace / append / prepend / delete します。

delete

path, expect_sha

ファイルを削除します。

なぜ start_commit / end_commit がないのか

明白な設計は、開いて後でメッセージ付きで閉じるステージングエリアです。それは意図的に却下されました。完成したように見える作業が未公開のまま置かれる場所を作り、"編集したがコミットするのを忘れた" が構築可能になるからです。3つの独立した設計レビューが同じ結論に収束しました。

代わりに、ステージング領域は一切ない。commit_edits はアトミックだ。多数のファイルにわたる変更のバッチ全体が、1回の呼び出しで適用・プッシュされるか、あるいはGitHubには一切何も送信されないかのどちらかだ。エージェントは自身のコンテキスト(モデルが確実に読み取れる唯一のストア)に計画を蓄積し、それを1回の呼び出しで使い切る。すべてのツール結果は、保留中のものは何もないと告げる常設の行で終わる。これにより、何かがキューに入っているという思い込みは、後で発見されるのではなく、継続的に否定される。

また、自動コミットのタイマーもない。どのタイムアウトでもだ。アイドルタイマーは、誰も承認していない作業(取り消された削除、中途半端な再構成)を公開してしまう。望まないコミットがゼロであることは、設計された特性であり、欠落ではない。シャットダウン時、サーバーは破棄する内容をログに記録し、何もコミットしない。

保持される状態は唯一つ、失敗時のみだ。失敗したバッチは retry_ref として30分間保持され、すでにコンパクト化されているかもしれないコンテキストから大きなバッチを再入力する必要がないようにする。これは後続のすべての結果で告知され、いかなる成功によってもクリアされ、成功の受領証を生み出すことは決してない。また、それが作成されたリポジトリに結び付けられている。別のリポジトリへの再生は拒否される。なぜなら、その編集は、他のリポジトリがかつて含んだことのないテキストから構築されたものだからだ。

アトミック性、正確に

commit_edits は2つのフェーズを実行し、その境界こそが保証だ。

  • プラン — 検証、スナップショット取得、expect_sha チェック、すべての操作をインメモリバッファに適用。ここでの失敗は、GET のみを発行して中断される。「ロールバックされた」のではない。変更を伴うリクエストは一度も送信されていないのだ。バッチ内のすべての失敗はまとめて報告されるため、3つの欠陥がある12操作のバッチは、3ターンではなく1ターンで済む。

  • エグゼキュート — 変更されたファイル数に関係なく、3つの変更を伴うリクエスト(POST /git/trees、POST /git/commits、PATCH /git/refs)。観測可能なのは最終的な PATCH のみ。

したがって、どの呼び出しの後でも、観測可能な状態は正確に2つしかない。コミットが1つ存在するか、ブランチが以前とバイト単位で同一であるかだ。

expect_sha は、操作がファイル全体を破壊する箇所、つまり delete と write mode=overwrite で正確に必須となる。観測したことのないファイルを丸ごと置き換えたり削除したりすることはできない。list_md は完全な blob SHA を返すため、削除にコンテンツの読み取りは一切不要だ。

並行性

コンテンツはコミット時に単一の固定スナップショットから再読み取りされるため、読み取り-変更-書き込みのウィンドウは、会話の長さではなく約1秒になる。PATCH ... force:false は本物のサーバーサイドの compare-and-swap であり、force: true がどこかに送信されることは決してない。衝突時には、プラン全体が新しいヘッドに対して再実行される。バッチが触れたものが何も移動していなければ、静かに着地する。移動していれば、停止し、上書きする代わりに、新しいアップストリームのコンテンツをインラインで返す。

環境

変数

備考

JWT_SECRET

このサーバーが発行するトークンに署名する。

PUBLIC_URL

このサービス自身のベースURL。末尾スラッシュなし。

PORT

生成されたRailwayドメインに合わせて3000に固定。

GITHUB_API_URL

デフォルトは https://api.github.com。テスト用の継ぎ目。

人ごとに番号付きの3つ組が1セット:

変数

備考

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 を入力する。

2人とも同じ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回の編集が正確に1つのコミットとゼロ回の PUT /contents 呼び出しを生むこと、削除がリテラルな "sha":null としてシリアライズを生き延びること、force:false がすべてのref更新に現れること、失敗したバッチが変更を伴うリクエストをゼロのまま残すこと、同僚の並行プッシュが決して上書きされないこと、1つのリポジトリを指定した呼び出しが他のリポジトリへのリクエストを発行しないこと、create_repo が正確に新しいリポジトリ内に正確に1つのコミットを生成し、GitHubが実際にそのリポジトリを作成したアカウントを報告すること、そして失敗後に保持されたバッチを別のリポジトリに再生できないことだ。

デプロイ

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

ビルドは意図的に Dockerfile 経由で行われる。Railwayのデフォルトビルダー(railpack)は、このサービスを failed to solve: secret RAILWAY_GIT_REPO_OWNER not found で失敗させる。railpackの生成プランが RAILWAY_GIT_* ビルドシークレットを宣言するが、これらはサービスのソースが接続されたGitHubリポジトリである場合にのみ存在し、CLIのtarballアップロードでは存在しないからだ。

注記

  • マークダウンスキャナーは実際のCommonMarkブロックスキャナーであり、^#{1,6} の正規表現ではない。フロントマターの閉じ --- は正当なsetext H2下線であるため、素朴なスキャンは最後のYAML行にちなんだ幻の見出しを発明し、エージェントがフロントマターに直接編集してしまう。フェンス内、インデントされたコード、HTMLブロック、ブロッククォート内の見出しは、正しくアドレス指定できない。

  • バイト忠実性は意図的だ。CRLFファイルは未変更の行でCRLFを維持し、BOMは先頭アンカーの old_string がマッチできるように分離され、何もトリミングされることはない。末尾の2つのスペースはマークダウンのハード改行である。

  • read_md は行番号ガターなしでコンテンツを返す。モデルが old_string にコピーしようとしているテキストの隣に数字があると、まさにそのようにしてガターがニードルに紛れ込むからだ。行番号はアウトラインとエラーメッセージにのみ現れる。そこは何もコピーされない場所である。

  • 全体が返される短いファイルには、アウトラインは付かない。アウトラインは読んでいないファイルの地図だ。それが説明する12行の上にアウトラインを印刷するのはノイズである。ファイルがページ送りするのに十分な長さになるか、ウィンドウが部分的になると、アウトラインは再び現れる。

  • GitHubの401はツールエラーテキストとして表面化され、/mcp からのHTTP 401としては決して表面化されない。旧プロキシはGitHubの WWW-Authenticate を転送していたため、claude.aiがGitHubに対して再認証を行い、再認証ループが発生する一方で、本当の問題である死んだPATは見えないままであった。

  • PATが本当のセキュリティ境界であり、今や到達範囲を決定する唯一のものでもある。このサーバーの設定でそれを狭めるものは何もない。PAT自体をGitHubでスコープ設定すること。create_repo との緊張関係に注意すること。create_repo は、トークンが作成された時点では存在しなかったリポジトリに到達できるトークンを必要とする。これは、選択されたリポジトリ向けのファイングレインPATとは正反対だ。

Related MCP Connectors

Related MCP Servers