ローカルのOllamaをMCPサーバーとして公開し、Claudeから作業を任せられるようにするNode.jsのサーバーです。 公式のMCP TypeScript SDK v2で作っています。
入口は次の2つです。
どちらもMCPの2026-07-28版(server/discover)と2025年版(initialize)の両方に応答します。
| 入口 | 用途 | ファイルの読み込み | 出力の保存 |
|---|---|---|---|
stdio(docker exec -i ollama-mcp node stdio.ts) |
同じPCのClaude CodeとClaude Desktopから使います。こちらを勧めます | 使えます(FILE_ROOTSの配下だけ) |
OUTPUT_DIRを設定したときだけ使えます |
HTTP(POST /mcp、ポート3000) |
Cloudflare Tunnelを通して、claude.aiなどから使います | HTTP_ALLOW_FILES=trueと認証を両方設定したときだけ使えます |
HTTP_ALLOW_WRITES=trueと認証とOUTPUT_DIRが要ります |
[ローカル]
Claude Code / Claude Desktop ──stdio──> docker exec ollama-mcp node stdio.ts ──> Ollama(ホストの11434番)
[リモート]
claude.ai ──HTTPS──> Cloudflare Access ──> Cloudflare Tunnel ──> 127.0.0.1:3000/mcp ──> Ollama
claude.aiとClaude Desktopのカスタムコネクタは、このPCではなくAnthropicのクラウドから接続します。 同じPCで使うだけなら、ローカル(stdio)の接続が確実です。 トンネルとAccessは要りません。
| ツール | 内容 | 既定のモデル |
|---|---|---|
ollama_chat |
下書き、要約、翻訳などの作業を任せます。profileでphp、docker、git、code_reviewの定型の指示を選べます |
DEFAULT_MODEL |
ollama_review_code |
コードを確かめます。行番号付きで「重大度、行、問題、改善案」を返します。取得したリポジトリとPull Requestの差分も確かめられます | DEEP_MODEL |
ollama_explain_error |
エラーやログの原因の候補と対処を返します | DEEP_MODEL |
ollama_list_models |
入っているモデルの一覧を返します | - |
ollama_health |
Ollamaが動いているかと、サーバーの設定を返します | - |
ollama_job |
background: trueで受け付けた生成の状態と、保存先を返します。HTTPで保存が使えるときだけ出ます |
- |
list_files |
許可ルートの中のファイルとディレクトリを一覧します。filesに渡すパスを探すときに使います。ファイルを扱えるときだけ出ます |
- |
read_file |
1つのファイルを、ローカルのモデルに渡さずにそのまま読みます。save_outputで書いた結果を読み返すときに使います。ファイルを扱えるときだけ出ます |
- |
git_clone |
GitHubのリポジトリをCLONE_ROOTの配下に取得します。owner/repoだけを受け、URLは受けません。CLONE_ROOTを設定したときだけ出ます |
- |
git_read |
取得したリポジトリの状態を読みます。status、log、diff、show、branches、remotesです |
- |
git_write |
ブランチの作成、staging、commit、pushです。GIT_ALLOW_WRITE=trueにしたstdioでだけ出ます |
- |
github_read |
Pull RequestとIssueと差分とコメントとチェックを読みます。失敗したCIの注釈とログの末尾も読めます(check_log)。GITHUB_MCP_TOKENを設定したときだけ出ます |
- |
github_write |
Pull Requestの作成とコメントです。GITHUB_ALLOW_WRITE=trueにしたstdioでだけ出ます |
- |
- ファイルを扱えるとき(stdioと、設定したHTTP)は、
files引数にWindowsの絶対パスを渡すと、サーバーがファイルを読み込みます。Claudeはファイルの中身を引数として書き出さずに済むため、トークンを節約できます filesは、1つのパスのほかにグロブと行範囲も取ります- グロブは
C:\dev\app\src\**\*.phpのように書きます。list_filesで探してから渡す往復を省けます - 行範囲は
C:\dev\app\src\Main.php#L10-200のように末尾に付けます。GitHubの永続リンクと同じ書き方です。#L10は1行、#L10-は末尾までです - ディレクトリをそのまま渡すことはできません。グロブの書き方を添えて拒みます
- グロブは
inline_filesには、サーバーが読めないファイルの中身を{"name": ..., "content": ...}の形で渡します。許可ルートの外にあるファイルや、Claudeが別の環境で開いているファイルに使いますollama_review_codeで差分を確かめるときは、差分を写さずに、出どころを渡します。サーバーが差分を取り、Claudeを通さずにローカルのモデルへ渡しますgit_diffには取得したリポジトリを{"repo": "owner/repo", "ref": ..., "staged": ...}の形で渡します。CLONE_ROOTを設定したときだけ出ますpull_requestにはPull Requestを{"repo": "owner/repo", "number": 12}の形で渡します。GITHUB_MCP_TOKENを設定したときだけ出ます- 差分の追加行と文脈の行には、新しいファイルでの行番号をサーバーが振ります。指摘は「ファイル:行」の形で返ります
- 秘密のファイルは
git_readのdiffとgithub_readのpr_diffと同じ判定で外します。入力の予算に入らないファイルは丸ごと落とし、名前を応答とプロンプトの両方に書きます
- CIの失敗は
check_logで調べますgithub_readのcheck_logは、Pull Requestのheadで失敗したチェック(failure、timed_out)ごとに、注釈と、GitHub Actionsのジョブのログの末尾200行を返します。行頭の時刻と色の制御文字は落とします- ログには、GitHubの伏せきれなかった秘密も混ざりえます。Claudeに読ませたくないときは、
ollama_explain_errorにcheck_log: {"repo": "owner/repo", "number": 12}を渡します。ログはローカルのモデルにだけ渡り、応答には原因の候補だけが返ります - ログのAPIは保存先へのリダイレクトを返します。サーバーはリダイレクトを自分でたどり、保存先にはトークンを送りません
- GitHubのAPIの呼び出しは、
GITHUB_API_TIMEOUT(既定30秒)で打ち切ります
ollama_review_codeにstructured: trueを付けると、指摘をJSON(file、line、severity、problem、fix、uncertain)で受けます- Ollamaの
formatで出力の形を絞ります - 渡していないファイルや、渡した行の範囲の外を指す指摘をサーバーが落とし、落とした数と理由を応答に書きます。差分では、番号を振った
@@の範囲だけを通します - 残った指摘は、これまでと同じ形の文章と、MCPの
structuredContentの両方で返ります - 既定は付けない(文章のまま)です。
outputSchemaは宣言しません。宣言すると、すべての応答にstructuredContentが要るためです - これはトークンを節約しません。
contentの分はどちらにせよ払います。サーバーが読めるパスなら必ずfilesを使います
- Ollamaの
- 渡せる量の上限は、文字数ではなくトークン数の目安で測ります。日本語のコメントが多いコードは1文字がほぼ1トークンになるためです
- 既定の上限は約24000トークンで、コンテキストを32kトークンと見込んでいます。実際の長さはOllamaの設定(
OLLAMA_CONTEXT_LENGTH、ModelfileのPARAMETER num_ctx)で決まり、サーバーからは見えません OLLAMA_NUM_CTXを設定すると、その値をnum_ctxとしてOllamaに送り、上限もそこから出力の分と余白(2048)を引いた量にします- 入力が上限に張り付いたとき(
prompt_tokensがコンテキスト長の9割以上)は、入力の一部が落とされた疑いとして警告します ollama_healthのloaded modelsに、読み込み中のモデルと、Ollamaが返せば実際のコンテキスト長が出ます- 上限を超えた分は丸ごと落とし、落としたファイル名を応答とプロンプトの両方に書きます。黙って切りません
- 既定の上限は約24000トークンで、コンテキストを32kトークンと見込んでいます。実際の長さはOllamaの設定(
list_filesはpatternにグロブを取ります。大文字と小文字は区別しません*は直下、**/*.phpは下の階層のPHPのファイル、*.{js,ts}は選択肢、末尾の/はディレクトリだけです**でたどるのはpathから8階層までです。それより深いときは、そのことを結果に書き添えます- 秘密のファイルと、
node_modules、vendor、.gitは出しません。シンボリックリンクはたどりません
modelには、モデルの名前の代わりに別名fast(DEFAULT_MODEL)とdeep(DEEP_MODEL)を渡せます。入っていないモデルを渡したときは、入っているモデルの一覧を添えて返します- 応答の末尾に
[ollama] model=... prompt_tokens=... output_tokens=... done_reason=... elapsed=...が付きます。done_reason=lengthやdone_reason=timeoutのときは、出力が途中で切れています - 小さいモデルは同じ内容を繰り返し続けることがあるため、出力のトークン数に上限を設けています。
ollama_chatは4096、ほかの2つは1536で、max_tokensで変えられます - ローカルのモデルの出力は誤りを含みます。Claudeの側で確かめてから使います
docker compose up -d --buildrestart: unless-stoppedのため、Docker Desktopを起動すると一緒に立ち上がります。
%APPDATA%\Claude\claude_desktop_config.jsonのmcpServersに次を足し、Claude Desktopを終了してから起動し直します。
チャットとCodeタブの両方で使えます。
{
"mcpServers": {
"ollama": {
"command": "docker",
"args": ["exec", "-i", "ollama-mcp", "node", "stdio.ts"]
}
}
}Claude Codeだけで使う場合は、次のコマンドでも登録できます。
claude mcp add --scope user ollama -- docker exec -i ollama-mcp node stdio.tsclaude/agents/ollama-worker.mdを%USERPROFILE%\.claude\agents\に写すと、Claude Codeからollama-workerのサブエージェントとして呼べます。
Ollamaに作業を任せ、その結果をClaudeが確かめてから返すための指示です。
ツールを呼ぶたびの確認を省く場合は、%USERPROFILE%\.claude\settings.jsonのpermissions.allowにmcp__ollama__*を足します。
OUTPUT_DIRを設定しないかぎり、どのツールもファイルを書き換えません。
設定したときに書くのはOUTPUT_DIRの配下だけで、読み込みのツールは何も書き換えません。
.envに書きます。
docker-compose.ymlは.envの値を差し込むことにだけ使い、次の変数だけをコンテナーに渡します。
| 変数 | 既定値 | 説明 |
|---|---|---|
OLLAMA_URL |
http://host.docker.internal:11434 |
OllamaのURLです |
DEFAULT_MODEL |
nucbox-fast:latest |
ollama_chatの既定のモデルです |
DEEP_MODEL |
qwen2.5-coder:14b |
コードの確認とエラーの解析の既定のモデルです |
OLLAMA_TIMEOUT |
300000 |
Ollamaから何も届かない状態の上限(ミリ秒)です。キューの待ち、モデルの読み込み、プロンプトの評価も含みます |
OLLAMA_MAX_DURATION |
3000000 |
1回の生成全体の上限(ミリ秒)です |
OLLAMA_MAX_CONCURRENCY |
2 |
同時に走らせる生成の数です。OllamaはGPUを1つずつ使うため、並べても全体は速くなりません |
OLLAMA_MAX_QUEUE |
8 |
待ち行列の長さの上限です。ここも一杯なら、待たせずにその場で断ります |
OLLAMA_NUM_CTX |
0 |
Ollamaに送るコンテキスト長(num_ctx)です。0なら送らず、Ollamaの設定に任せます。上げるとVRAMを多く使うため、載り切る値にします |
HTTP_REQUEST_TIMEOUT |
60000 |
HTTPの要求を受け取り終えるまでの上限(ミリ秒)です。応答を返している時間(生成の時間)には効きません |
ALLOWED_HOSTS |
localhost,127.0.0.1,[::1],host.docker.internal,mcp.223n.tech |
HTTPで受け付けるHostとOriginです |
HTTP_ALLOW_FILES |
false |
HTTPでもファイルの読み込みを許すかどうかです。認証(MCP_AUTH_TOKENかCF_ACCESS_*)がないときは無視します |
MCP_AUTH_TOKEN |
なし | 設定すると、HTTPにAuthorization: Bearer <値>を求めます |
CF_ACCESS_TEAM_DOMAIN、CF_ACCESS_AUD |
なし | 設定すると、HTTPにCloudflare AccessのJWT(Cf-Access-Jwt-Assertion)を求めます |
CF_ACCESS_ALLOWED_EMAILS |
なし | 設定すると、AccessのJWTのemailがこの一覧にある人だけを通します。カンマで区切って並べます。サービストークンのJWTにはemailがないため、拒まれます |
FILE_ROOTS |
docker-compose.ymlで設定 |
ホストのパス=コンテナのパスを;で区切って並べます |
OUTPUT_DIR |
なし | ローカルのモデルの出力を書き出す先です。ホストのパス=コンテナのパスを1件だけ書きます。設定したときだけsave_outputが出ます |
HTTP_ALLOW_WRITES |
false |
HTTPでも書き出しを許すかどうかです。HTTP_ALLOW_FILESとは別に持ちます。認証がないときは無視します |
CLONE_ROOT |
docker-compose.ymlで設定 |
リポジトリを取得する先です。ホストのパス=コンテナのパスを1件だけ書きます。サーバーが書き換えてよいのはここの配下だけです |
GIT_ALLOWED_OWNERS |
docker-compose.ymlで設定 |
取得してよいGitHubのownerです。カンマで区切って並べます。空なら取得そのものを拒みます |
GIT_ALLOW_WRITE |
false |
commitとpushを許すかどうかです。stdioでだけ効き、HTTPでは常に無効です |
GIT_TIMEOUT、GIT_MAX_DURATION |
120000、600000 |
gitから何も届かない状態の上限と、1回の操作全体の上限です(ミリ秒) |
GIT_USER_NAME、GIT_USER_EMAIL |
なし | commitに使う名前とメールアドレスです。commitするなら両方とも要ります |
GITHUB_MCP_TOKEN |
なし | GitHubのAPIに使うトークンです。fine-grainedを使い、対象のリポジトリを列挙します |
GITHUB_ALLOW_WRITE |
false |
Pull Requestの作成とコメントを許すかどうかです。stdioでだけ効き、HTTPでは常に無効です |
GITHUB_API_TIMEOUT |
30000 |
GitHubのAPIの1回の呼び出し(本文を読み終えるまで)の上限です(ミリ秒)。CIのログを読むときも使います |
AUDIT_LOG_DIR |
docker-compose.ymlで設定 |
監査ログを1日1ファイルで書き出す先(コンテナーの中の絶対パス)です。空なら標準エラーにだけ出します。FILE_ROOTSの中は拒みます |
AUDIT_RETENTION_DAYS |
30 |
監査ログのファイルを残す日数です |
- タイムアウトしても、それまでに生成された部分は
done_reason=timeoutと警告を付けて返します - 無通信の上限(
OLLAMA_TIMEOUT)は300秒のままです。全体の上限だけを延ばし、Ollamaが固まったときは早く気付けるようにしています ollama_healthとollama_list_modelsの問い合わせは、OLLAMA_TIMEOUTと15秒の短いほうで打ち切ります。Ollamaが固まったときに、状態の確認そのものが300秒待たないようにするためです- HTTPの
requestTimeout(HTTP_REQUEST_TIMEOUT)は、要求を受け取り終えるまでの上限です。応答を返している時間には効かないため、長い生成のために上げる必要はありません。起動時に[http] request timeoutとして出します- 以前は
OLLAMA_MAX_DURATION+60秒に合わせていましたが、Node 22と26で、requestTimeoutより長い応答が切れないことを確かめたうえで切り離しました。長くしておくと、本文をゆっくり送り続ける相手に、その間ずっと接続をつかまれます
- 以前は
- 認証は、本文を読む前に確かめます。認証のない要求は、本文を解析せずに401を返します
- 失敗した要求(状態コードが400以上)が同じ接続元から1分に60回を超えると、その接続元からの要求を1分ほど429で断ります。偽のトークンの連打で、重い認証の処理を回させないためです
- 成功した要求は数えません。認証を通った普段の利用は妨げません
- 接続元は相手のIPで見分け、
X-Forwarded-Forは信じません。Cloudflare Tunnelを通る要求は、どれもcloudflaredから届くため、同じ枠を分け合います - 3000秒まで使えるのはstdioと、同じPCから直にHTTPを叩くときです。claude.aiのコネクタは約240秒、Cloudflareは無通信が約100秒で打ち切ります
MCP_AUTH_TOKENとCF_ACCESS_*の両方を設定したときは、どちらかを満たせば通します- どちらも設定しないと、このPCのほかのコンテナーからも
host.docker.internal:3000を通してHTTPを呼べます
curl.exe http://127.0.0.1:3000/healthz
docker logs --tail 20 ollama-mcpHTTPのアクセスログには、メソッド、パス、ステータス、Host、JSON-RPCのメソッドが出ます。
本文は記録しません。
トンネルを通したリクエストがサーバーまで届いているかを確かめるときに使います。
claude.aiから使うときは、Cloudflare TunnelとCloudflare Accessを前に置きます。
- Cloudflare Tunnelで、公開するホスト名(例:
mcp.223n.tech)をhttp://127.0.0.1:3000に向けます - Cloudflare Zero Trustで、そのホスト名に「Self-hosted」のAccessのアプリを1つだけ作ります
- アプリに「Allow」のポリシーを足し、使う人のメールアドレスを入れます
- アプリの「Managed OAuth」を有効にし、「Allowed redirect URIs」に
https://claude.ai/api/mcp/auth_callbackを足します .envにCF_ACCESS_TEAM_DOMAINとアプリのCF_ACCESS_AUDを書き、コンテナーを作り直します- claude.aiの「設定」の「コネクタ」で、
https://<ホスト名>/mcpをカスタムコネクタとして足します
- claude.aiとClaude Desktopのリモートのコネクタは、1回の呼び出しを約240秒で打ち切ります。Cloudflareは応答が約100秒途切れると打ち切ります。長い生成は、次の「長い生成をジョブにする」を使うか、ローカルで行います
- うまくつながらないときはdocs/troubleshooting.mdを見てください
HTTPでも、files引数とlist_filesを使えます。
認証がないまま有効にすると誰でもファイルを読めてしまうため、認証を設定したときだけ有効になります。
- 前の手順で
CF_ACCESS_TEAM_DOMAINとCF_ACCESS_AUDを設定しておきます .envにHTTP_ALLOW_FILES=trueを足します- 使う人をさらに絞るときは、
.envのCF_ACCESS_ALLOWED_EMAILSにメールアドレスを書きます docker compose up -dでコンテナーを作り直します。ログにFile tools are enabled over HTTPと出れば有効です。CF_ACCESS_ALLOWED_EMAILSを書いたときは、ログの[auth]の行に登録した件数が出ます- claude.aiの「設定」の「コネクタ」で、このコネクタのツールリストを更新します。
list_filesが加わり、ollama_chatなどにfiles引数が付きます
- 読めるのは
FILE_ROOTSの配下だけで、秘密のファイルを拒むのはstdioと同じです - ファイルの中身はこのPCのOllamaにだけ渡ります。ただし、Ollamaの出力はclaude.aiに返るため、Anthropicのサービスを通ります
HTTP_ALLOW_FILESを外したときも、ツールリストを更新します。更新しないと、Claudeがなくなったツールや引数を呼んで失敗します- 大きなファイルを14Bのモデルに読ませると、1回の呼び出しの上限(約240秒)を超えることがあります。そのときは
DEFAULT_MODELの7Bのモデルを使うか、ファイルを分けます - コネクタが約240秒で打ち切ったときは、途中まで生成された部分も返りません。長くなりそうな生成は、
background: trueでジョブにします(次の節)
HTTPで保存(OUTPUT_DIRとHTTP_ALLOW_WRITES=true)が使えるときは、生成のツールにbackground: trueを付けられます。
対象はollama_chat、ollama_review_code、ollama_explain_errorです。
付けると、受け付けた時点でジョブのIDを返します。
生成はクライアントの打ち切りを越えて続き、結果はOUTPUT_DIRに書かれます。
background: trueを付けて呼びます。応答にジョブのIDが返りますollama_jobにIDを渡して、状態(待ち、生成中、完了、失敗)を確かめます。IDを省くと、自分のジョブの一覧が返ります- 完了すると、保存先のパス、先頭と末尾の抜粋、
[ollama]の行が返ります。全文はread_fileで読みます
- stdioでは使えません。クライアントが終わるとプロセスごと止まり、ジョブも消えるためです。stdioには打ち切りの問題もありません
- ジョブは、ほかの呼び出しと同じ枠(
OLLAMA_MAX_CONCURRENCYとOLLAMA_MAX_QUEUE)を使います。待ち行列が一杯なら、受け付けの時点で断ります - 生成中のジョブは、クライアントが切れても止めません。
OLLAMA_MAX_DURATIONで必ず終わります - 終わったジョブの記録は1時間で消えます。結果のファイルは
OUTPUT_DIRに残ります - 記録はプロセスのメモリに持ちます。コンテナーを作り直すと記録は消えますが、ファイルは残ります
- ほかの識別子(Accessのメールアドレスなど)のジョブは読めません
- 監査ログには、受け付けの記録とは別に、終わったときの記録(ツール名に
:jobを付けたもの)が残ります
長い下書きや翻訳は、save_outputでファイルに書き出せます。
応答にはパスと先頭と末尾の抜粋だけが返るため、Claudeが全文を読まずに済みます。
- ホスト側に書き出し先のディレクトリを作ります。コンテナーの
nodeユーザーが書ける権限にしますdocker-compose.ymlはC:\devを読み取り専用でマウントし、C:\dev\ollama-outだけを読み書きできる形で重ねています。別の場所にするときは、docker-compose.ymlのvolumesも合わせます
.envにOUTPUT_DIR=C:\dev\ollama-out=/work/dev/ollama-outのように書きます- HTTPでも使うときは、認証を設定したうえで
HTTP_ALLOW_WRITES=trueを足します docker compose up -dでコンテナーを作り直します。ollama_healthのoutput savingがenabledになれば有効です
- ファイル名は
output_nameで指定します。使えるのは英数字と_と-だけで、.とパス区切りは拒みます - 拡張子はサーバーが
.mdに決めます。.phpや.jsをサーバーに書かせないためです - すでにあるファイルは上書きせず、
-2、-3と後ろに足して新しく作ります - 書き出したファイルの先頭には、ローカルのモデルが書いたものだという断りが入ります
- 書き出したファイルは
read_fileで読み返せます。#L120-200を付けると一部だけ読めます - ローカルのモデルは同じ行を繰り返して終わることがあります。末尾の重複を数え、疑わしいときは警告を付けます
ファイルを扱えるときは、MCPのresourcesとしても同じファイルを公開します。
resources/listは許可ルートだけを返し、resources/readはディレクトリなら一覧を、ファイルなら中身を返します。
- URIは
file:///C:/dev/app/src/Main.phpの形です。#L10-200を付けると行範囲になります - 防御は
files引数とまったく同じ経路を通ります。許可ルートの外、..、秘密のファイル、シンボリックリンクは同じように拒みます resources/readにはツール名がないため、mcp__ollama__*の許可の対象になりません。そのぶん、ファイルのツールと完全に同じ条件でだけ公開しますresources/subscribeとページングには対応していません。一覧は許可ルートだけに絞っています
CLONE_ROOTを設定すると、GitHubのリポジトリを取得して、そのままローカルのモデルにレビューさせられます。
- ホスト側に取得先のディレクトリを作ります(例:
C:\dev\claude)docker-compose.ymlはC:\dev\claudeを読み書きできる形でマウントしています。別の場所にするときは、docker-compose.ymlのvolumesも合わせます
.envにCLONE_ROOTとGIT_ALLOWED_OWNERSを書きます- privateのリポジトリを扱うときは、fine-grainedのトークンを
GITHUB_MCP_TOKENに書きます。対象のリポジトリは列挙して絞ります - commitとpushまで任せるときは、
GIT_ALLOW_WRITE=true、GIT_USER_NAME、GIT_USER_EMAILを足します - Pull Requestの作成まで任せるときは、
GITHUB_ALLOW_WRITE=trueを足します docker compose up -d --buildでコンテナーを作り直します。ollama_healthで状態を確かめられます
SSHの鍵は要りません。
GITHUB_MCP_TOKENにfine-grainedのトークンを設定すると、HTTPS経由でそのまま取得できます。
サーバーはトークンをx-access-tokenのBasic認証としてgitに渡します。
子プロセスの環境変数だけで渡すため、argvに現れず、.git/configにも残りません。
-
GitHubの「Settings」→「Developer settings」→「Personal access tokens」→「Fine-grained tokens」で発行します
-
「Resource owner」に、対象のリポジトリを持つ利用者か組織を選びます
-
「Repository access」は「Only select repositories」にして、使うリポジトリだけを選びます
-
「Repository permissions」を次のように設定します
権限 必要な場面 Metadata: Read 必須です。ほかの権限を選ぶと自動で付きます Contents: Read git_cloneです。pushもするならRead and writePull requests: Read pr_list、pr_view、pr_diff、pr_commentsです。PRを作るならRead and writeIssues: Read issue_list、issue_viewです。コメントするならRead and writeChecks: Read pr_checksとcheck_log(注釈)ですActions: Read check_log(ログ)です。ollama_explain_errorのcheck_logも使います -
.envにGITHUB_MCP_TOKEN=github_pat_...と書き、docker compose up -dで作り直します -
ollama_healthのgithub apiがenabledになれば有効です
トークンを設定していないと、privateリポジトリの取得はRepository not foundで失敗します。
GitHubが認証のない要求に404を返すためで、名前の打ち間違いと見分けが付きません。
サーバーはこのとき、トークンが未設定であることを書き添えます。
- 取得先は
CLONE_ROOT/owner/repoです。パスはownerとrepoから組み立てるため、渡した文字列がパスの区切りとして働く余地がありません - URLは受け取りません。
owner/repoだけを受け、https://github.com/owner/repo.gitはサーバーが組み立てます - 取得したリポジトリは
list_filesとfilesとread_fileから読めます。ローカルのモデルにレビューさせる目的なので、これは意図した動きです - 書き込みはstdioでだけ有効です。
GIT_ALLOW_WRITEとGITHUB_ALLOW_WRITEをtrueにしても、HTTP経由ではgit_writeとgithub_writeが出ません main、master、developへの直pushは、設定にかかわらず拒みます。それらをheadにしたPull Requestの作成も拒みますghコマンドは入れていません。GitHubのRESTのAPIを直に呼ぶため、gh apiやgh aliasのような別の実行経路がそもそもありません
サーバーはファイルを書き、リポジトリを取得し、pushし、Pull Requestを作れます。 何が行われたかを後から言えるよう、ツールの呼び出しを1行1JSONで記録します。
{"ts":"2026-09-22T12:00:00.000Z","identity":"you@example.com","kind":"tool","tool":"git_write","ok":true,"ms":842,"args":{"repo":"223n/mcp-server","op":"push","branch":"feature/x"}}-
出力先は標準エラーです。stdioのとき標準出力はMCPの通信路なので、そちらには出しません
-
AUDIT_LOG_DIRを設定すると、標準エラーに加えてaudit-YYYYMMDD.jsonl(UTCの日付)へ1行ずつ追記します- stdioの入口は
docker execで起動する別のプロセスで、その標準エラーはClaude DesktopやClaude Codeの側に流れ、docker logsには残りません。書き込みのツール(git_write、github_write)はstdioでだけ出るため、その記録はこのファイルに残します docker-compose.ymlは、名前付きボリュームaudit-logを/var/log/ollama-mcpにマウントし、既定でここに書きます。FILE_ROOTSの外に置き、ファイルのツールから読めないようにしています。FILE_ROOTSの中を指定すると、起動時に警告してファイルには書きません- HTTPとstdioの両方のプロセスが同じファイルに追記します。古いファイルは、HTTPのプロセスが起動時と1日ごとに消します(
AUDIT_RETENTION_DAYS、既定30日) - 読むときは
docker exec ollama-mcp sh -c 'cat /var/log/ollama-mcp/audit-*.jsonl'です。jqを通すと絞り込めます
- stdioの入口は
-
ローカルのモデルに生成を任せた呼び出しには
usageが付きます。実際に使ったモデル(別名は読み替えたあとの名前)、prompt_tokens、output_tokens、done_reason、枠を待った時間(queued_ms)です{"ts":"2026-09-26T12:00:00.000Z","identity":"stdio","kind":"tool","tool":"ollama_chat","ok":true,"ms":1200,"args":{"model":"fast"},"usage":{"model":"nucbox-fast:latest","prompt_tokens":4096,"output_tokens":301,"done_reason":"stop","queued_ms":0}}-
どれだけ任せたかをモデルごとに数えるときは、次のようにします
docker exec ollama-mcp sh -c 'cat /var/log/ollama-mcp/audit-*.jsonl' | jq -s 'map(select(.usage)) | group_by(.usage.model) | map({model: .[0].usage.model, calls: length, prompt_tokens: (map(.usage.prompt_tokens // 0) | add), output_tokens: (map(.usage.output_tokens // 0) | add)})'
-
ollama_healthは、そのプロセスが動き始めてからの合計を、モデルごとと識別子ごとに出します。stdioのプロセスはクライアントごとに起動し直されるため、長い期間はファイルで数えます
-
-
identityは、Cloudflare AccessのJWTのemail、サービストークンならservice:<クライアントID>、静的なトークンならtoken、stdioならstdioです。認証がない構成ではanonymousになります -
argsには記録してよい鍵だけを残します。prompt、code、system、context、message、body、inline_filesの中身は出しません- 渡したファイルのパス(
filesとpaths)は残します。何をローカルのモデルに渡したかは、監査でいちばん知りたいことだからです inline_filesは件数だけにします。名前と中身のどちらも呼び出し側が決めるためです
- 渡したファイルのパス(
-
resources/readにはツール名がなく、ツールの記録に載りません。読み取りの経路としては同じ重さなので、"kind":"resource"として別に記録します -
HTTPの入口の記録は、
docker logs ollama-mcpでも見られます。ログは10MBを3世代まで残します
OllamaはGPUを1つずつ使うため、生成を並べて投げても待ち行列に並ぶだけで、全体は速くなりません。 待っている間もクライアントの上限(claude.aiは約240秒)は進みます。
OLLAMA_MAX_CONCURRENCY(既定2)までを同時に走らせ、それを超えた分はOLLAMA_MAX_QUEUE(既定8)まで待ち行列に並べます- 待ち行列も一杯のときは、待たせずにその場で断ります。Claudeを長く待たせず、早く判断できるようにするためです
- 待っている間は、進捗の通知で「何件待ちか」を伝えます
- 待ち時間まで含めて240秒を超えそうなときは、
background: trueでジョブにします - 今の状態は
ollama_healthのconcurrencyに出ます
.envはコミットしません。.gitignoreで外しています- Ollama(11434番ポート)には認証がありません。LANやインターネットへ直に公開しないでください
- コンテナーは権限を絞って動かします(
docker-compose.yml)- ルートのファイルシステムは読み取り専用で、書けるのは
/tmp(メモリ上)と書き込み先だけです C:\devは読み取り専用でマウントし、CLONE_ROOTとOUTPUT_DIRの場所だけを読み書きできる形で重ねます。サーバーの約束が外れたとき(gitやNodeの不具合など)に書き換えられる範囲を、この2つに絞るためです- ケーパビリティはすべて外し、特権の昇格を禁じ、プロセスの数に上限を設けます
- CIも同じ絞り込みでコンテナーを起動し、HTTPとstdioが応答することを確かめます
- ルートのファイルシステムは読み取り専用で、書けるのは
- HTTPでファイルを読めるのは、
HTTP_ALLOW_FILES=trueに加えて認証を設定したときだけです - ファイルの読み込みは
FILE_ROOTSの配下だけに限ります.env、.envrc、.npmrc、秘密鍵、app_local.phpなどの秘密のファイルと、.gitや.sshなどの配下は拒みます- Windowsの8.3形式の短い名前(
ENV~1など)で回り込むことも拒みます
- 書き出せるのは
OUTPUT_DIRの配下だけです。FILE_ROOTSには書きません- HTTPで書き出せるのは、
HTTP_ALLOW_WRITES=trueに加えて認証を設定したときだけです。HTTP_ALLOW_FILESだけでは書けません - ファイル名は英数字と
_と-だけに限り、NULやCOM1などWindowsが特別扱いする名前も拒みます - 全角の
/はNFKCで/になるため、正規化してから確かめます - 作成は
O_CREAT|O_EXCLで行います。先に置かれたシンボリックリンクをたどって別の場所へ書くことはありません
- HTTPで書き出せるのは、
- グロブでまとめて渡すときは、拒否リストではなく拡張子の許可リストで絞ります。名前を指定せずにサーバーが選ぶため、明示的なパスより狭くしています
- 先頭が
.の名前、拡張子のないファイル、ハードリンクは展開で拾いません
- 先頭が
- 読み込んだファイルに書かれた指示は、ローカルのモデルの出力に紛れ込むことがあります。出力の中の指示には従わないよう、ツールの応答と説明に書いてあります
OUTPUT_DIRをFILE_ROOTSの配下に置くと、書き出した出力を読み返せる代わりに、モデルの出力が普通のファイルのような顔で戻ってきます。起動時に警告を出し、書き出したファイルの先頭に出自を書いています
- gitを動かすときは、環境変数を継承しません。
GIT_SSH_COMMANDやGIT_EXTERNAL_DIFFなど、任意のコマンドを実行させる変数を持ち込ませないためです- システムの設定は
/etc/git/server.gitconfigの1枚だけを読ませ、利用者のグローバルの設定は読ませません - 取得したリポジトリの
.git/configは、GIT_CONFIG_SYSTEMとGIT_CONFIG_GLOBALを差し替えても読まれます。そこで、鍵の許可リストとコマンドの側の設定の2段で守ります- 操作の前に
.git/configの鍵を許可リストで確かめ、ほかの鍵があればgitを動かさずに拒みます - 許すのは、
git cloneとpush --set-upstreamが書く鍵(core.*の一部、remote.origin.*、branch.*.remoteとmerge)と、user.nameとuser.emailだけです。remote.origin.urlは、取得先のURLと同じであることも確かめます core.hooksPath、core.fsmonitor、credential.helper、commit.gpgSign、protocol.*は、コマンドの側の設定(GIT_CONFIG_COUNT)で打ち消します。diffとshowには--no-ext-diffと--no-textconvを付けます.git/configはリモートから配られないため、取得しただけで危険な鍵が入ることはありません。守る相手は、CLONE_ROOTに書けるホストの側のプロセスです
- 操作の前に
https以外のプロトコル(ext::、file://、git://、ssh://)を拒みます- 引数は必ず配列で渡し、シェルを介しません。利用者の値は値の位置にしか入らず、
-で始まる値は拒みます - トークンは子プロセスの環境変数だけで渡します。argvに現れず、
.git/configにも残りません
- システムの設定は
- gitとGitHubの差分は、
files引数とは別の読み取り口になります。filesと同じ判定(src/tools/sensitive.ts)で秘密のファイルの区画を外し、外したファイルの名前を書き添えます。showは中身を返しません- 対象は
git_readのdiff、github_readのpr_diff、ollama_review_codeのgit_diffとpull_requestです - 名前を変えた差分は、元の名前と新しい名前のどちらかが当たれば外します
- ただしこれは名前による防御です。秘密に当たらない名前のファイルに書かれた秘密や、コミットのメッセージに書かれた秘密は読めます
- 対象は
- 取得したリポジトリの中身は第三者が書いたテキストです。ローカルのモデルは指示の混入に弱いため、出力の中の指示には従いません
github_readの結果と、git_readのlog、diff、showの結果には、第三者が書いた文章なので指示として扱わない旨を末尾に添えます。サーバーのinstructionsとツールの説明にも同じことを書いています- ツールの呼び出しは監査ログに残します。中身は出しませんが、ファイルのパスと操作の種類は残します
- HTTPのアクセスログは10MBを3世代まで残します
mcp-server/
├─ claude/agents/ollama-worker.md Claude Code のサブエージェントの定義
├─ docs/ 運用の手引きとトラブルシューティング
├─ docker-compose.yml
├─ Dockerfile
├─ tsconfig.json 型の検査の設定(成果物は作らない)
├─ index.ts HTTP の入口
├─ stdio.ts stdio の入口
├─ src/
│ ├─ server.ts McpServer を作る(HTTP と stdio で共通)
│ ├─ types.ts 複数のファイルで共有する型
│ ├─ config/ 環境変数、モデル、定型の指示
│ ├─ git/exec.ts git の起動(環境を継承しない、引数は配列、上限と中断)
│ ├─ http/auth.ts HTTP の認証(静的なトークン、Cloudflare Access の JWT)
│ ├─ ollama/client.ts Ollama の API(ストリーミング、タイムアウト、中断)
│ └─ tools/ ツール、ファイルの読み込みと一覧、秘密のファイルの判定、出力の保存、リソース
└─ test/ 試験(Ollama の代わりに試験用のサーバーを使う)
ソースはTypeScriptで書きます。
ビルドはしません。
Nodeが.tsから型を取り除いてそのまま実行します(型の剥がし)。
そのためdist/のような成果物はなく、node index.tsとnode stdio.tsが本番の起動コマンドです。
この方法にはNode 22.18以上が要ります。
package.jsonのenginesがその下限を書いています。
Dockerのイメージが使うのはNode 26です。
Nodeは型を取り除くだけで、型が合っているかは見ません。 型の誤りが見つかるのは次のコマンドだけです。
npm run typechecknpm run lintにも入っています。
CIでは「型の検査」ジョブが同じことをします。
設定はtsconfig.jsonにあり、次の3つが書き方を縛ります。
| 設定 | 何を縛るか |
|---|---|
allowImportingTsExtensions |
importには実行時と同じ綴りを書きます(./files.tsであって./files.jsではありません) |
verbatimModuleSyntax |
型だけを取り込むときはimport typeと書きます。こう書かないとNodeが値の取り込みと区別できません |
erasableSyntaxOnly |
enum、namespace、コンストラクターのパラメータープロパティは使えません。取り除くだけでは消えないためです |
strictとnoUncheckedIndexedAccessを有効にしています。
arr[0]やobj[key]の型にはundefinedが入ります。
取り出した値は、そのまま使わずに確かめてください。
複数のファイルで使う型はsrc/types.tsに置きます。
MCPの通信で使う形は写さず、SDKの型(@modelcontextprotocol/server)をそのまま使います。
npm testで試験します。
Ollamaの代わりに試験用のサーバー(test/helpers/mock-ollama.ts)を使うため、GPUとOllamaは要りません。
npm install
npm test次のことを確かめます。
- HTTPとstdioで、MCPの2025年版と2026-07-28版の両方につながること
- ファイルの読み込みの防御(許可ルートの外、
..、シンボリックリンク、秘密のファイル、大きさの上限)と、list_filesの絞り込み - 同じ防御が、グロブの展開と
resources/readでも働くこと - 行範囲の切り出しで、行番号が元のファイルのまま振られること
inline_filesの名前で、見出しやフェンスを偽装できないこと- 書き出しの防御(パス区切り、
..、二重の拡張子、Windowsの装置名、全角の区切り、置かれたシンボリックリンク) - 認証のないHTTPで、
resourcesも書き出しの引数も出ないこと owner/repoの検証(..、パスの区切り、Windowsの装置名、.gitで終わる名前、URL)-で始まる値をgitの引数として拒むこと- 守るブランチへのpushと、それらをheadにしたPull Requestの作成を拒むこと
git_readのdiffとgithub_readのpr_diffから、filesが拒むのと同じ秘密のファイルが外れること(名前の変更、引用符で囲まれた名前を含む)ollama_review_codeのgit_diffとpull_requestで、差分がローカルのモデルへのプロンプトにだけ入り、秘密のファイルが入らないこと。振った行番号が新しいファイルの行番号と一致することollama_review_codeのstructuredで、渡していないファイルや範囲の外の行を指す指摘が落ち、付けたときだけformatとstructuredContentが使われることcheck_logが失敗したチェックの注釈とログの末尾だけを返し、ログの保存先にトークンを送らないこと。ollama_explain_errorのcheck_logでログが応答に返らないこと。GitHubが応答を返さないときGITHUB_API_TIMEOUTで打ち切ることbackground: trueの呼び出しがすぐにIDを返し、クライアントが切れたあとも生成が続いて保存されること。ほかの識別子からジョブを読めないこと、終わってから1時間で記録が消えること、stdioには出ないこと- 取得したリポジトリの
.git/configに許可していない鍵(core.fsmonitor、core.hooksPath、diff.external、include.pathなど)があれば、gitを動かさずに拒むこと - 許可リストを通り抜けても、フックと
core.fsmonitorがコマンドの側の設定で止まること github_readとgit_readのlog、diff、showの結果に第三者の文章だという断り書きが付き、statusと空の差分には付かないこと- HTTPでは
git_writeとgithub_writeを出さないこと - 監査ログに
promptやcodeの中身が出ず、識別子とファイルのパスは出ること resources/readで復号できないURI(壊れた符号化、NUL)を拒んだときも、監査ログに残ることOLLAMA_NUM_CTXを設定したときだけnum_ctxを送り、入力の予算と上限の警告にその値を使うこと。ollama_healthが読み込み中のモデルを出すこと- 生成を任せた呼び出しの記録に
usageが付き、ollama_healthがモデルごとと識別子ごとの合計を出すこと - 監査ログを日付ごとのファイルにも追記し、
FILE_ROOTSの中の書き出し先を拒み、古いファイルだけを消すこと。CIでは、stdioの呼び出しの記録が名前付きボリュームに残ることを確かめます - 同時に走らせる数の上限と、待ち行列が一杯のときに断ること
- すでに中断された呼び出しを待ち行列に並ばせないことと、枠を渡す間にも上限を超えて走らないこと
- HTTPの認証(静的なトークン、Cloudflare AccessのJWT、メールアドレスの絞り込み)と、エラーの形
- Cloudflare Accessの鍵の取得を、同時に届いた知らない
kidのJWTで分け合い、失敗した直後は取り直さないこと - クライアントからの中断と、stdioのstdinが閉じたときに、Ollamaへの呼び出しが止まること
OLLAMA_MAX_DURATIONを超えたときに、途中までの出力を警告付きで返し、Ollamaへの呼び出しも止まること- HTTPの
requestTimeoutより長い生成が、途中で切れずに最後まで返ること - Ollamaが固まったとき、状態の確認が短い上限で打ち切られ、効いた上限の名前を知らせること
- 認証を設定したHTTPで、認証のない要求には本文を読み終える前に401を返すこと
- 失敗した要求が1分に60回を超えると429で断り、成功した要求は数えないこと
list_filesのグロブが、*を並べた意地の悪いパターンでもすぐ終わること(ReDoSを防ぐ)- ツールの説明に決め打ちのモデルの名前が出ず、別名
fastとdeepが設定したモデルに読み替わること。入っていないモデルには一覧を添えて返すこと - サーバーが読む環境変数を、
docker-compose.ymlがすべてコンテナーに渡していること docker-compose.ymlがコンテナーの権限を絞り、C:\devを読み取り専用にして、書き込み先だけを読み書きできる形で重ねていること。CIが同じ絞り込みで起動すること- 環境変数の不正な値で、起動時に止まること
CIは、Node 22.18(enginesの下限)と最新の22と26で試験し、Dockerのイメージを作って起動したうえでHTTPとstdioの応答を確かめます。
あわせてTrivyでイメージの脆弱性を見ます。
apkで入れたパッケージの版はDependabotが追わないため、ここで拾います。
直せるもの(上流に修正がある高・重大)が見つかると失敗し、直せないものは記録に残すだけにします。
ブランチの運用、リリース、ラベル、ワークフローはdocs/repository-operations.mdにあります。 変更の進め方はCONTRIBUTING.mdにあります。
変更したらnpm run lintを通します。
型の検査(tsc --noEmit)、Markdownの書式、日本語の書き方をまとめて確かめます。
文書だけを変えたときも型の検査が先に走ります。
ここで落ちたらnpm run typecheckを単体で実行し、どちらの検査が落ちたかを切り分けてください。
npm install
npm run lintApache License 2.0です。 LICENSEを見てください。