CATHODE COASTaccess to tools
11 min read

ローカル LLM に MCP で「今」を教える——Open WebUI + mcpo で時刻ツールを繋ぐまでの7つのつまずき

静的な文書では埋められない「今この瞬間」の情報を、MCP ツールで LLM に与える。Open WebUI に mcpo 経由で時刻サーバーを繋ぐまでの作業ログ。タイムゾーンの 500 エラー、host.docker.internal が効かない罠、小型モデルがツールを自発的に呼ばない問題まで、実際に踏んだエラーを全部並べる。

shareB!

このシリーズでは、放置していた Ollama を入れ直し、日報を書かせOpen WebUI で RAG を組みナレッジベースで「新しいチャットでも社内文書を参照する」ところまでやってきました。ここまでの RAG は静的です。あらかじめ用意した文書の中にある情報しか答えられません。

では「今何時か」はどうでしょう。これは文書には書けません。刻一刻と変わるからです。LLM は学習した時点で時間が止まっているので、現在時刻を聞いても答えられない(そして平然と嘘をつく)。この「今この瞬間」の情報を LLM に与えるのが、MCP(Model Context Protocol)の出番です。今回は、Open WebUI に時刻を返す MCP サーバーを繋いで、モデルに本物の現在時刻を答えさせるまでをやります。

結論を先に言うと、動かすまでに 7 つのつまずきを踏みました。ポート衝突、タイムゾーンの 500 エラー、原因切り分けの盛大な回り道、host.docker.internal が効かない罠、そして「小型モデルはツールを使えと言わないと使わない」問題まで。どれも公式ドキュメントの手順どおりでは見えないものでした。この記事は、その全部の記録です。

環境は前回までと同じで、執筆時点(2026 年 7 月)は以下のとおりです。

  • macOS 15 系(Apple Silicon / arm64、メモリ 16GB)
  • Docker 28.4.0 / Open WebUI v0.6.22 / Ollama v0.11.4(ホスト側)
  • mcpo(ghcr.io/open-webui/mcpo:main)/ mcp-server-time v1.28.1

先にまとめ

急いでいる方向けに、要点と最終構成を先に置きます。

  • v0.6.22 では mcpo を使う。 ネイティブ MCP 対応は v0.6.31 以降かつ Streamable HTTP 限定。stdio の時刻サーバーには mcpo(MCP を OpenAPI に変換するプロキシ)が要る。
  • タイムゾーンの 500 エラーの真犯人は「送信値」。 Swagger UI がプレースホルダの string をそのまま送っていた。tzdata は OS にも uvx 環境にも存在した。
  • UI からのツール接続は localhost ブラウザ起点で動くため host.docker.internal は名前解決できない。前回の Ollama 接続との決定的な違い。
  • Auth に「なし」がない。 選択肢は BearerSession のみ。認証なしの mcpo には Bearer + 適当なトークンで通る。
  • 小型モデルはツールを自発的に呼ばない。 「今何時?」では嘘を生成し、「このツールを使って」と明示して初めて呼ぶ。

最終的に動いた起動コマンドはこれです。

mcpo + 時刻サーバーの起動(認証なし)
docker run -d -p 8100:8000 --name mcpo --restart always \
  ghcr.io/open-webui/mcpo:main \
  --host 0.0.0.0 --port 8000 \
  -- uvx --with tzdata mcp-server-time --local-timezone=Asia/Tokyo

以下、実際にたどった順序で書いていきます。

つまずき 0:どの方式で繋ぐか(mcpo かネイティブか)

MCP を Open WebUI に繋ぐ方式は 2 つあります。一つは Open WebUI が v0.6.31 以降で搭載したネイティブ MCP 対応。もう一つは mcpo という、MCP サーバーを OpenAPI(普通の REST API)に変換するプロキシを挟む方式です。

私の環境は v0.6.22 なので、ネイティブ対応は使えません。さらにネイティブ対応は Streamable HTTP 形式限定で、今回使う時刻サーバー(mcp-server-time)の標準的な起動は stdio 形式です。仮にアップグレードしても、stdio のサーバーを繋ぐには結局 mcpo のような橋渡しが要る。選択の余地なく mcpo 方式に決まりました。ここは教訓で、「新しいネイティブ機能があるから使うべき」とは限りません。題材が stdio なら、結局プロキシが要ります。

つまずき 1:ポート 8000 が埋まっている

mcpo の標準例はポート 8000 を使います。ですが前回までの作業で、私の Docker には別プロジェクトの開発環境や、ローカルの DynamoDB が動いていました。案の定、8000 は塞がっていました。

8000 は Docker が使用中
❯ lsof -i :8000
com.docke ... TCP *:irdmi (LISTEN)

前回のポート衝突(Open WebUI の 3000)と同じ轍は踏まないので、最初から 8100 に載せます。8100 が空いていることを確認して起動しました。

mcpo 起動ログ(抜粋)
INFO -   API Key: Not Provided
INFO - Configuring for a single Stdio MCP Server with command: uvx mcp-server-time --local-timezone=Asia/Tokyo
INFO:     Application startup complete.
INFO:     Uvicorn running on http://0.0.0.0:8000

API Key: Not Provided(認証なし構成)、Stdio MCP Server(stdio の時刻サーバーを mcpo がラップしている=橋渡しが動いている)。ここまでは順調でした。

つまずき 2:タイムゾーンの 500 エラー

mcpo が起動すると、http://localhost:8100/docs に自動生成された OpenAPI ドキュメント(Swagger UI)が現れます。ここから get_current_time を「Execute」して、単体の動作確認をしようとしました。すると 500 エラーです。

最初のエラー
{
  "detail": {
    "message": "Unexpected error",
    "error": "500: {'message': \"Error processing mcp-server-time query: Invalid timezone: 'No time zone found with key string'\"}"
  }
}

Invalid timezone: 'No time zone found with key string'。ここから、恥ずかしい回り道が始まります。

つまずき 3:原因の切り分けを、2 回外す

最初に立てた仮説は「コンテナにタイムゾーンデータ(tzdata)が入っていないのでは」でした。軽量イメージにはよくある話です。ところが確認すると、tzdata は存在していました。

仮説1は外れ:OSにtzdataはある
❯ docker exec mcpo sh -c "ls /usr/share/zoneinfo/Asia/Tokyo 2>&1"
/usr/share/zoneinfo/Asia/Tokyo

次に「uvx が作る隔離環境の方に tzdata が無いのでは」と考えました。mcp-server-time は uvx が専用の一時環境を作って実行するので、そこにだけ無い可能性はある、と。--with tzdata を足して起動し直しましたが、エラーは変わりません。そこで、時刻サーバーが動くのと同じ環境で、実際に Asia/Tokyo が解決できるか直接叩いてみました。

仮説2も外れ:uvx環境でも解決できている
❯ docker exec mcpo sh -c "uvx --with tzdata --from mcp-server-time python -c '
from zoneinfo import ZoneInfo
import datetime
print(\"resolve OK:\", datetime.datetime.now(ZoneInfo(\"Asia/Tokyo\")).isoformat())
'"
resolve OK: 2026-07-07T02:31:00.026275+09:00

resolve OK で、正しい日本時間まで返っています。tzdata も解決処理も、何も壊れていない。私の仮説は 2 回とも外れました。ただ、この 2 回の空振りには意味がありました。「環境側は完全に正常」という事実を潰せたので、残るはリクエストの投げ方しかない、と消去法で絞り込めたのです。

つまずき 4:真犯人は Swagger UI のプレースホルダ

環境が正常なら、犯人はリクエストです。Swagger UI のフォームではなく、値を明示して直接叩いてみました。

timezone を明示すると成功する
❯ docker exec mcpo sh -c "curl -s -X POST http://localhost:8000/get_current_time \
  -H 'Content-Type: application/json' \
  -d '{\"timezone\": \"Asia/Tokyo\"}'"
{"timezone":"Asia/Tokyo","datetime":"2026-07-07T02:31:43+09:00","day_of_week":"Tuesday","is_dst":false}

あっさり成功。そして OpenAPI スキーマを見ると、答えが書いてありました。

get_current_time の入力スキーマ(抜粋)
"get_current_time_form_model": {
  "properties": {
    "timezone": {
      "type": "string",
      "description": "IANA timezone name ... Use 'Asia/Tokyo' as local timezone if no timezone provided."
    }
  },
  "required": ["timezone"]
}

timezone は必須(required)で型は文字列。つまり真相はこうでした。Swagger UI で「Execute」を押したとき、timezone 欄にプレースホルダの文字列 string がそのまま入って送信されていた。mcpo は「string という名前のタイムゾーンなど無い」と正しく弾いた。エラー文 'No time zone found with key string' の末尾の "string" は、送られてきた値そのものだったのです。

環境の問題だと思い込んで tzdata を 2 回疑いましたが、犯人はフォームのプレースホルダでした。教訓は「エラーメッセージは正確に読む」。with key stringstring を、文法上の飾りではなく値として読めていれば、もっと早く気づけました。

なお --with tzdata は今回のエラーの直接原因ではありませんでしたが、付けて害はなく、環境によっては必要な保険なので、最終構成には残してあります。

つまずき 5:host.docker.internal では繋がらない

時刻ツール単体が動いたので、Open WebUI に登録します。設定 → ツール → ツールサーバーを追加、で URL を入れます。前回 Ollama を繋いだときは host.docker.internal:11434 で通ったので、同じ発想で http://host.docker.internal:8100 を入れました。すると接続失敗です。

接続エラー
http://host.docker.internal:8100/openapi.json
OpenAPIツールサーバーへの接続に失敗しました。

まず疑ったのはネットワーク到達性です。Open WebUI コンテナから mcpo に届くか、直接叩きました。

コンテナからは届いている
❯ docker exec open-webui sh -c "curl -s -o /dev/null -w '%{http_code}' http://host.docker.internal:8100/openapi.json"
200

200。コンテナからは完全に届いています。なのに UI からは失敗する。この矛盾が手がかりでした。

答えは「接続の起点」です。前回の Ollama 接続は、Open WebUI コンテナのサーバー側がホストを叩くので host.docker.internal が正解でした。ところが今回のツールサーバー接続は、**ブラウザ(Mac 本体)**が起点で動きます。ブラウザにとって host.docker.internal は名前解決できない——あれはコンテナの中からホストを指すための名前だからです。だから、コンテナからの curl は通る(200)のに、ブラウザ起点の UI 接続は失敗していた。

URL を http://localhost:8100 に変えたら、あっさり接続できました。同じ 8100 番の mcpo に繋ぐのに、繋ぐ主体がコンテナかブラウザかで、正しいホスト名が変わる。これが今回いちばんの落とし穴でした。前回の成功体験をそのまま持ち込んだのが遠回りの原因です。

つまずき 6:Auth に「なし」がない

localhost に気づく前、認証設定も疑いました。接続編集画面の Auth が Session(セッション認証情報を転送)になっていたためです。mcpo は認証なしなので、これを「なし」にしようとしたのですが——ドロップダウンを開くと、選択肢は BearerSession の 2 つだけ。None(なし)がありません。

v0.6.22 の Open WebUI は、OpenAPI ツールサーバーに「認証なし」で繋ぐ UI を用意していないようでした。対処は、Bearer を選んで適当なトークン(dummy など)を入れることです。mcpo 側は --api-key 未指定で受け取ったトークンを検証しないので、ダミーでも素通しで通ります。

(結果的に、接続失敗の主因は認証ではなく前述の URL でしたが、この「None がない」問題は、認証なしで mcpo を立てた人が必ず出くわすので記録しておきます。)

つまずき 7:小型モデルは「ツールを使え」と言わないと使わない

接続できたので、いよいよチャットで試します。素の qwen2.5:7b を選び、こう聞きました。

ツールを呼ばず、嘘をつく
Q: 今、日本は何時ですか?
 
A: ……日本はJST(UTC+9)です。ただし、夏時間(DST)が始まる
   3月第2日曜日から終わりまでUTC+9.5に1時間ずれ込みます。……

時刻ツールは登録済みで有効なのに、モデルはツールを呼びませんでした。そして自前の知識で答え、あまつさえ嘘をついています。日本に夏時間はありませんし、UTC+9.5 という時差もでたらめです。ツールを使わないと、静的ナレッジのとき(前回の「17 時」の嘘)と同じハルシネーションが出る。

ちなみに、ツール一覧の画面には個別のオン/オフのトグルはありませんでした。ツールサーバーを登録した時点で、その配下のツール(tool_get_current_time_post など)はモデルから使える状態になる設計です。つまり有効化の問題ではない。問題はモデルが「今はツールを使う場面だ」と判断しなかったことです。

そこで、聞き方を変えました。ツールの使用を明示的に促します。

ツールを明示すると、正しく呼ぶ
Q: get_current_timeツールを使って、今の東京の時刻を調べて教えてください。
 
A: 今の東京の時刻は……
   - 日付と時刻: 2026年7月7日 02時47分56秒
   - 曜日: 火曜日
   - 夏時間適用フラグ: なし(is_dst: false)
   現在の東京時間は 2026年7月7日午前2時47分56秒(火曜日)です。

今度はモデルがツールを呼び、mcpo 経由で時刻サーバーから取得した本物の現在時刻を答えました。学習データからは絶対に出せない「今この瞬間」の情報です。

ここが実践的な知見です。ローカルの小型モデル(7B クラス)では、ツールがあっても、モデルが自発的に「これはツールを使うべきだ」と判断してくれるとは限りません。人間が「このツールを使え」と誘導して初めて確実に動く。大規模モデルなら暗黙に呼んでくれる場面でも、小型モデルには明示が要る。公式ドキュメントに大きくは書かれていない、手を動かして初めて分かる部分でした。

ハマりどころまとめ

症状原因対処
ポート 8000 で起動できないDocker(DynamoDB 等)が使用中-p 8100:8000 で別ポートへ
get_current_time が 500 エラーSwagger UI がプレースホルダ string を送信値を明示して呼ぶ(Asia/Tokyo)。LLM 経由なら自動で埋まる
tzdata を疑ったが違ったOS にも uvx 環境にも tzdata は存在した環境ではなくリクエストを疑う。エラー文の値を正確に読む
ツール接続が失敗するUI 接続はブラウザ起点で host.docker.internal が名前解決不可URL を localhost:8100 にする
Auth に「なし」がないv0.6.22 は認証なし UI を持たないBearer + ダミートークン(mcpo が未検証なら通る)
モデルがツールを呼ばず嘘をつく小型モデルは自発的にツールを使わないプロンプトで「このツールを使って」と明示する

やってみての所感

今回は、これまでで一番つまずきました。ですが、そのつまずきの質が良かった。単なる設定漏れではなく、「エラーメッセージの読み方」「接続の起点という概念」「モデルの地力の限界」といった、次にも効く学びに繋がるものばかりでした。

とりわけ大きかったのは 2 つです。ひとつは、原因を環境のせいにして 2 回外したこと。エラー文 'No time zone found with key string'string を、飾りではなく「送信された値」として読めていれば一発でした。推測で対処を重ねる前に事実を一つずつ潰す——結局これが最短でした。もうひとつは host.docker.internallocalhost の使い分けで、叩く主体がコンテナのサーバー側かブラウザかで正しいホスト名が変わる、という点です。「コンテナからは届くのに UI からは繋がらない」という矛盾は、この起点の違いを知らないと延々ハマります。

そして MCP そのものについては、静的な RAG との役割分担がはっきり見えました。前回までのナレッジベースは「変わらない事実」に向き、MCP は「今この瞬間・変化する情報」を取ってくるのに向く。時刻はその象徴で、文書には決して書けない情報をツール呼び出しで補えました。向いているのは、現在時刻・最新データ・外部システムの状態のように「聞かれた瞬間に取りに行く」情報を扱いたい人です。ただし小型ローカルモデルで使うなら「ツールを明示的に促す」工夫が要る、という前提つきで。

まとめ

Open WebUI に mcpo 経由で時刻 MCP サーバーを繋ぎ、ローカルの小型モデルに本物の現在時刻を答えさせるところまでを、7 つのつまずきとともにたどりました。ポート衝突、タイムゾーンの 500 エラー、切り分けの回り道、host.docker.internal の罠、Auth の選択肢、そしてモデルがツールを呼ばない問題。どれも、手を動かさなければ出会えないものでした。

静的な文書で「変わらないこと」を固め、MCP で「今この瞬間」を取りに行く。この二段構えがそろって、ローカル LLM は机の上で、外に出さないデータと、外から取ってくる現在とを、両方扱えるようになります。次はいよいよ、時刻のようなサンプルではなく、自分にとって意味のある外部データ源を MCP で繋いでみようと思います。