🗓️ この記事の前提(2026-09-12 時点)
  • Python 3.12 / MCP 公式 SDK mcp 2.2.0 / FastAPI 0.141.1 / Claude Code 2.1 系 / Windows 11 で書いています。macOS/Linux では動かしていません
  • 前回作った Web アプリ(かんばん・SQLite・service 層)の続きです。MCP そのものの入門は MCP サーバーの作り方に書いたので、重なる部分はそちらを参照して繰り返しません
  • 画面と実行ログに出てくる項目・ワークスペース・AI の名前 ai:demo-assistant は、すべてこの記事のために用意した架空のデータです
  • ソースは GitHub の ai-taskboard(MIT)に全文があります。行番号は v1.0.0 のものです

🧭 はじめに:計算を返す MCP から、台帳に書く MCP へ

前作で作った MCP サーバーは、LED の抵抗や 555 の周波数を計算して返すだけでした。引数を渡せば答えが返り、何も覚えず、誰が呼んでも同じ。MCP の仕組みを覚えるにはちょうどよい題材でしたが、あれは「状態を持たない」MCP です。

今回は違います。相手は SQLite の台帳で、人間がブラウザで同時に見ている。AI が項目を足せば、かんばんにそのまま現れる。AI が「候補を着手に動かした」と書けば、その履歴が残り続ける。MCP サーバーの書き方そのものは前作と同じですが、考えなければいけないことが 5 つ増えました

前作(elec-calc) 今回(ai-taskboard)
状態 持たない。呼ぶたびに計算 SQLite に永続化。呼ぶたびに DB が変わる
誰が書いたか 関係ない 全操作に author を残す。AI に名乗らせない
権限 全部読める ワークスペースごとに read_write / read_only / hidden
同時アクセス 1 プロセス Web サーバーと MCP サーバーが別プロセスで同じファイルを開く
失敗の伝え方 ToolError で文言を返す 同じ。ただし「見つからない」「読み取り専用」「すでに完了」の使い分けが要る
戻り値 dataclass Pydantic モデル(REST と共有)

全体像はこうです。前回の図で点線だった入口を、今回実線にします。

flowchart TD CC["Claude Code"] -->|"stdio(子プロセス)"| MCP["MCP サーバー
uv run taskboard mcp
author = 環境変数"] HU["人間
ブラウザ"] --> WEB["Web UI
uv run taskboard serve"] MCP --> SVC["service.py
唯一の書き込み経路"] WEB --> SVC SVC --> DB[("taskboard.sqlite3(WAL)
event に author と source が残る")]

MCP は3 本目の入口にすぎません。書き込みは前回作った service.py の関数を呼ぶだけで、履歴の残り方も Web からの操作と同じです。だから今回のコードは、ツールの「口」を 9 本並べる mcp_server.py 1 ファイル(約 290 行)で済んでいます。

前作の復習を 3 文で。MCP サーバーは MCPServer を作って関数に @mcp.tool() を付け、mcp.run() で stdio 待ちに入るだけ。docstring と型注釈がそのまま AI への説明書になる。失敗は ToolError で投げないと、文言が AI に届かない。この 3 つを前提に進めます。


🛠️ 9 本のツールを載せる

src/taskboard/mcp_server.py の骨格です(L51-58)。

from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError

mcp = MCPServer(
    "taskboard",
    instructions=(
        "ai-taskboard: 人と AI が共有する候補・残項目ボード。ワークスペース(slug)ごとに項目を管理する。"
        "まず list_workspaces か get_workspace_summary で状況を見てから add_item / move_item / add_note を使う。"
        "状態は candidate/doing/waiting_human/waiting_ai/done/hold。書き込みの author はサーバー側で固定される。"
    ),
)

instructions は初期化時にクライアントへ渡される一言で、ツール一覧より先に AI が目にする説明です。「まず一覧を見てから書け」という使う順番をここに書いておくと、いきなり add_item を呼んで slug を間違える、という失敗が減ります。

載せたツールは 9 本。読み取り 4 本と書き込み 5 本です。

ツール 何をするか ToolError になる条件
list_workspaces() AI から見えるワークスペースと状態別件数 ―(hidden は出さない)
get_workspace_summary(workspace) 件数・期限切れ・waiting_ai の項目・直近 20 イベント slug 不明・hidden
list_items(workspace, status?, tag?, owner?, query?, limit, offset) 一覧(本文なし)と next_offset 同上・limit が 1〜200 の外
get_item(item_id) 本文・ノート・履歴 ID 不明・hidden
add_item(workspace, title, body?, priority?, owner?, due?, tags?, links?) 候補を追加 hiddenread_only・title 空・due の形式・owner の形式
update_item(item_id, …) 属性の変更。status は受けない 変更が 1 つも無い・read_only
move_item(item_id, status, reason?) 列の移動 同じ状態への移動・read_only
add_note(item_id, body) ノート追記 body 空・read_only
complete_item(item_id, summary?) 完了にする すでに完了・read_only

引数の説明は Annotated で 1 つずつ付ける

前作では「docstring は AI への仕様書」と書きました。引数が多くなると、docstring の Args 節より Annotated[型, Field(description=...)] で引数ごとに付けるほうが、JSON Schema の description に 1 対 1 で落ちて確実です(mcp_server.py L179-191 の add_item)。

@mcp.tool()
@tool_errors
def add_item(
    workspace: Annotated[str, Field(description="ワークスペースの slug")],
    title: Annotated[str, Field(min_length=1, max_length=200, description="タイトル(1〜200 文字)")],
    body: Annotated[str, Field(description="本文(Markdown・省略可)")] = "",
    priority: Annotated[Priority, Field(description="優先度")] = "normal",
    owner: Annotated[str | None, Field(description="次に動く人: 'human' か 'ai:<name>'(省略可)")] = None,
    due: Annotated[str | None, Field(description="期限 'YYYY-MM-DD'(省略可)")] = None,
    tags: Annotated[list[str], Field(description="タグ(英小文字・数字・._-)")] = [],
    links: Annotated[list[Link], Field(description="関連リンク(article/task/url)")] = [],
) -> Item:
    """候補として項目を追加する(状態は candidate)。作成者はサーバー側の設定で固定される。"""

PriorityLiteral["low", "normal", "high", "urgent"] です。Literal で書いておくと、スキーマに enum として出るので、AI が「高」や「high priority」のような値を渡してくることがなくなります。min_lengthge/le も同じで、範囲外は SDK の段階で弾かれ、こちらの関数まで来ません。

引数に author が無いことに注目してください。ここが今回の中心です。

1 呼び出し 1 接続。Web が止まっていても動く

各ツールは with session() as conn: で接続を開き、閉じます(L67-83)。初回だけマイグレーションを当て、ワークスペースが 1 つも無ければ初期の 3 つを作る。つまり Web サーバーを一度も起動していなくても、Claude Code が MCP を呼んだ時点で DB ができます。Web と MCP は互いを知らず、共有するのは SQLite ファイル 1 つだけです。

もう 1 つ、前作でも触れた stdio の鉄則。stdout はプロトコルそのものなので、print() を 1 つでも混ぜると接続が壊れます。 ログは logging.basicConfig() の既定(stderr)に出し、CLI の taskboard mcp は何も表示しません(L283-285・cli.pycmd_mcp)。


🔒 author は引数で受けない

Web UI からの書き込みは無条件に human でした。では MCP からの書き込みは誰の名前になるのか。AI に名乗らせません。 サーバーを起動するときの環境変数 TASKBOARD_AUTHOR だけで決まります(L86-97)。

def author() -> str:
    """書き込みの author。環境変数だけから決まり、ツール引数では変えられない。"""
    value = os.environ.get("TASKBOARD_AUTHOR", "").strip()
    if not value:
        raise ToolError("TASKBOARD_AUTHOR is not set on the server (expected 'ai:<name>'); writes are disabled")
    try:
        m.validate_author(value)
    except m.ValidationError as e:
        raise ToolError(f"TASKBOARD_AUTHOR is invalid: {e}") from e
    if not value.startswith("ai:"):
        raise ToolError("TASKBOARD_AUTHOR must be 'ai:<name>' for the MCP server")
    return value
  • 書き込み系のツールは全部 author=author() で service 層を呼ぶ。引数から受ける経路がそもそも無い
  • 未設定なら読み取り専用になる(読み取りツールは author() を呼ばないので動く)
  • human を設定しても弾く。MCP 経由で人間を名乗る道を塞いでいる

これはテストで固定しています。ツール一覧の add_item の入力スキーマに author無いこと、そして引数に author: "human" を混ぜて呼んでも created_byhuman にならないことです(tests/test_mcp.py L58・L116-123)。

assert "author" not in tools["add_item"].input_schema["properties"]  # author は引数で受け付けない

なぜここまでするのか。AI は「言われたとおりに引数を埋める」のが仕事なので、author という引数があれば、プロンプト次第で human と書いてしまいます。悪意がなくても起きます。誰が押したかを残す仕組みは、押す側ではなく道具側に置く。これが今回の設計でいちばん譲らなかった点です。

もう 1 つの効き目は、AI ごとに名前を変えられることです。ai:claude-codeai:gemini-analytics を別々に登録すれば、履歴の author でどの AI が書いたかまで区別できます。


🚧 権限は DB の値をサーバー側で強制する

前回、ワークスペースごとに ai_policyread_write / read_only / hidden)を持たせました。この値を見るのは MCP と REST だけで、判定は service.py の 3 つの関数に集めています(L117-137)。

def check_ai_writable(ws: Workspace) -> None:
    """MCP/REST の書き込み系が呼ぶ。hidden は「存在しない」、read_only は Forbidden(403 / ToolError)。"""
    if ws.ai_policy == "hidden":
        raise NotFound(f"workspace '{ws.slug}' not found")
    if ws.ai_policy == "read_only":
        raise Forbidden(f"workspace '{ws.slug}' is read-only for AI")


def ai_get_workspace(conn, slug, *, write=False) -> Workspace:
    ws = get_workspace(conn, slug, for_ai=True)   # hidden なら NotFound
    if write:
        check_ai_writable(ws)
    return ws


def ai_get_item(conn, item_id, *, write=False) -> Item:
    item = get_item(conn, item_id, for_ai=True)
    if write:
        check_ai_writable(get_workspace_by_id(conn, item.workspace_id))
    return item

ツール側は入口で ai_get_workspace(conn, workspace, write=True) を 1 行呼ぶだけです。流れにするとこうなります。

flowchart TD T["ツール呼び出し
add_item / move_item / add_note …"] --> G["ai_get_workspace / ai_get_item"] G --> H{"ai_policy は?"} H -->|"hidden → NotFound(存在しない扱い)
read_only + 書き込み → Forbidden"| TE["ToolError として AI に届く
workspace 'x' not found
workspace 'x' is read-only for AI"] H -->|"read_write"| S["service.add_item など
author = TASKBOARD_AUTHOR
source = mcp"] S --> EV[("event に 1 行追記")]

hiddenForbidden ではなく NotFound なのは前回書いたとおりで、「拒否された」という応答自体が「そこに何かある」という情報になるからです。一覧にも出ず、ID を指定しても「見つからない」しか返らない。AI から見れば、そのワークスペースは最初から無いのと同じです。

判定が service 層にあるので、MCP と REST でまったく同じ規則になります。REST では NotFound が 404、Forbidden が 403 に化けるだけで、文言も同じです。


📦 戻り値は Pydantic で宣言する

技術検証の段階で 1 つ引っかかったのがここでした。戻り値の型注釈を -> dict にしたツールには output_schema が付かず、クライアントが受け取る structured_contentNone になります。 SDK は型注釈からそのまま出力スキーマを作るので、中身が分からない dict では作りようがない。素の dict ではなく、dict[str, X]TypedDict・Pydantic モデルのどれかで宣言する必要があります。

今回は Pydantic を選びました。理由は、同じモデルを FastAPI の response_model にそのまま使えるからです(src/taskboard/schemas.py)。MCP の add_item と REST の POST /api/v1/workspaces/{slug}/items が同じ Item を返すので、AI がどちらの口から読んでも同じ形になります。

class Item(BaseModel):
    id: int
    workspace: str
    title: str
    body: str
    status: Status          # Literal[6 値]
    priority: Priority      # Literal[4 値]
    owner: str | None
    due: str | None
    tags: list[str]
    links: list[Link]
    created_by: str
    created_at: str
    updated_at: str
    completed_at: str | None
    url: str                # サーバーが組み立てる。AI が人間に「ここを見て」と渡す

もう 1 つ小さな決まりがあります。list[...] を直接返すと {"result": [...]} に包まれるので、一覧は ItemList {items, total, next_offset} のように自分のモデルで包みました。件数と次ページの offset を一緒に返せて、AI がページングを続けられます。

url フィールドは意図的です。AI が「#21 を追加しました。http://127.0.0.1:8765/w/blog/items/21 を見てください」と人間に返せるように、サーバーが URL を組み立てて返します。土台は環境変数 TASKBOARD_BASE_URL で、既定は http://127.0.0.1:8765 です。

9 本すべてに output_schema が付くことも、テストで固定しています(tests/test_mcp.py L56-57)。


🧯 失敗は ToolError に読み替える

前作で「ToolError 以外の例外だと、書いたメッセージが AI に届かない」と書きました。今回は service 層が NotFound / ValidationError / Forbidden / Conflict を投げるので、それをツールの境界で ToolError に読み替えるデコレータを 1 つ書いて、9 本全部に付けています(L100-110)。

def tool_errors(fn):
    """service の例外を ToolError に読み替える(モデルが読んで自己修正できるように)。"""
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        try:
            return fn(*args, **kwargs)
        except (m.NotFound, m.ValidationError, m.Conflict) as e:  # Forbidden は ValidationError の派生
            raise ToolError(str(e)) from e
    return wrapper

service 層の例外は Web でも REST でも使うので、そこに ToolError を混ぜたくない。境界で読み替えるのが素直でした。AI に届く文言は、たとえばこうです。

場面 AI に届く文言
無い slug を指定 workspace 'blogs' not found
read_only のワークスペースに書く workspace 'reading' is read-only for AI
同じ状態へ移動 item #22 is already 'waiting_ai'
update_item に何も渡さない nothing to update: pass at least one field
サーバーに author が無い TASKBOARD_AUTHOR is not set on the server (expected 'ai:<name>'); writes are disabled

文言はすべて英語にしています。AI 向けの出力なので、ツール名・引数名と同じ言語のほうが、AI が「どの引数を直せばいいか」を結びつけやすいからです。

「使えない」ときは、代わりに使うものを書く

update_itemstatus を受けません。状態は必ず move_item で変え、前後の値と理由を履歴に残すためです。ここで大事なのは、受けないことをスキーマから消すだけでなく、docstring に「代わりにこれを使え」と書くことです。

"""項目の属性を変更する(省略した引数は変えない)。状態(status)はここでは変えられない: move_item を使う。"""

REST でも同じで、PATCH /api/v1/items/21status を含めると 400 で status cannot be changed here: use POST /items/{id}/move が返ります。「できない」で終わらず「こうしろ」まで書いておくと、AI は次の一手を自分で選べます。

SDK のドキュメントはこれを、raise を 1 つ書いただけで self-correcting agent が手に入る、と表現しています。本当にそうなるのか、demo DB で 1 回試しました。わざと存在しない slug blogs を渡し、「失敗したら list_workspaces で正しい slug を探して 1 回だけやり直す」と頼んだ headless の claude -p です。

claude -p "Use ONLY the MCP tools of server 'taskboard'. Add an item titled 'ToolError テスト(デモ)' to workspace 'blogs'. If that fails, call list_workspaces to find the correct slug and retry once." `
  --output-format json `
  --allowedTools "mcp__taskboard__list_workspaces,mcp__taskboard__add_item" `
  --max-turns 6

セッションの transcript に残ったツール呼び出しを、時系列にそのまま並べます。

# 呼び出し 結果
0 ToolSearch(Claude Code 内部) MCP ツール 2 本のスキーマを取得。サーバーには届かない
1 add_item(workspace="blogs", …) ToolErrorError executing tool add_item: workspace 'blogs' not found
2 list_workspaces() blog / workshop / reading の 3 件
3 add_item(workspace="blog", …) 成功:{"id": 23, "workspace": "blog", "status": "candidate", "created_by": "ai:demo-assistant", …}

5 ターン・約 19 秒・約 $0.18。demo DB には #23 と item.created(source=mcp)が 1 行ずつ増え、弾かれた 1 回目は何も残していません。先ほどの文言一覧に書いた workspace 'blogs' not found は、実際には SDK が Error executing tool add_item: を頭に付けた形で届きます。表の 0 番目にある ToolSearch は Claude Code の内部処理で、遅延ロードされた MCP ツールのスキーマを使う直前に取りに行っているだけなので、サーバーには届きません。

注目したいのは、ToolError の文言には「代わりに list_workspaces を呼べ」とは一言も書いていないことです。次の手を選ばせたのは、instructions の「まず list_workspacesget_workspace_summary で状況を見てから」と、プロンプトに書いた指示でした。裏を返せば、今回はプロンプト側に次の手があったから 1 往復で済んだのであって、実運用で人間が毎回そう書いてくれるとは限りません。だから update_item の docstring や REST の detail には、プロンプトに頼らなくても済むよう「代わりに何を使うか」まで書いておきます。


🔗 Claude Code に登録する

サーバーができたので繋ぎます。前作と同じ claude mcp add ですが、今回は環境変数を 3 つ渡すのが違いです。

claude mcp add taskboard `
  -e TASKBOARD_AUTHOR=ai:claude-code `
  -e TASKBOARD_DB=C:/path/to/ai-taskboard/data/taskboard.sqlite3 `
  -e PYTHONUTF8=1 `
  -- uv run --directory C:/path/to/ai-taskboard taskboard mcp
  • -e-s-- より前、サーバーの起動コマンドは -- より後ろ。前作の「フルパスで書く」は、uv run --directory <絶対パス> に置き換わりました。uv がプロジェクトの .venv を見つけてくれるので、python.exe のパスを書く必要がありません
  • Windows のパスは C:/path/to/...スラッシュ区切りで通ります。バックスラッシュのエスケープを考えなくてよい
  • TASKBOARD_DB は絶対パスにしておくと、Claude Code をどのフォルダで起動しても同じ DB を指します

登録できたかは claude mcp get taskboard で見えます。環境変数まで表示されるので、author の綴りをここで確かめておきます。

taskboard:
  Scope: Local config (private to you in this project)
  Status: √ Connected
  Type: stdio
  Command: uv
  Args: run --directory C:/path/to/ai-taskboard taskboard mcp
  Environment:
    TASKBOARD_DB=C:/path/to/ai-taskboard/data/demo.sqlite3
    TASKBOARD_AUTHOR=ai:demo-assistant
    TASKBOARD_BASE_URL=http://127.0.0.1:8765
    PYTHONUTF8=1

(この記事の画面はすべてダミー DB なので、TASKBOARD_DBdemo.sqlite3 に、author を ai:demo-assistant にして登録したものです)

スコープと author の分け方

前作で書いたとおり、スコープは local(そのフォルダだけ)・project.mcp.json)・user(全プロジェクト)の 3 つです。ボードはどのフォルダで作業していても見たいので -s user が自然ですが、ここに落とし穴があります。

⚠️ user スコープの登録は、そのマシンの全セッションに見える

-s user で登録した taskboard は、どのフォルダで起動した Claude Code からも同じ author で書き込めます。Claude Code を役割ごとに複数動かしているなら、全員が ai:claude-code を名乗ることになり、履歴で区別できません。

対策は 2 つです。

  1. 役割ごとに名前を変えて別々に登録するtaskboard-writerai:writertaskboard-opsai:ops のように。名前が違えば別サーバーとして扱われ、author も別になる
  2. 特定のフォルダでしか使わないものは local のままにする。記事用の taskboard-demo(demo DB・ai:demo-assistant)はこの形で、実運用の DB とは混ざらない

author の名前は ai: の後ろが [a-z0-9][a-z0-9._-]{0,39} なら何でもかまいません。

書き込み系は毎回確認させる

Claude Code は MCP ツールの呼び出しごとに許可を求めます。読み取り 4 本は自動許可、書き込み 5 本は毎回確認、という設定にしています(.claude/settings.json または ~/.claude/settings.json)。

{
  "permissions": {
    "allow": [
      "mcp__taskboard__list_workspaces",
      "mcp__taskboard__get_workspace_summary",
      "mcp__taskboard__list_items",
      "mcp__taskboard__get_item"
    ]
  }
}

ツール名は mcp__<サーバー名>__<ツール名> です。サーバー丸ごとなら mcp__taskboard、ワイルドカードなら mcp__taskboard__*mcp__taskboard__get_* のように接頭辞で絞ることもできます)。逆に書き込みを禁止したいなら deny に 5 本を並べます。書式は Claude Code の permissions ドキュメントにあります。

💡 Windows の罠:PowerShell の -- は npx に食われる

claude mcp add … -- uv run …-- は PowerShell でもそのまま通ります。claude.exe がネイティブの実行ファイルだからです。

一方、MCP Inspector を npx @modelcontextprotocol/inspector --cli … -- --method tools/list のように呼ぶと、PowerShell では npxnpx.ps1 というシムに解決され、裸の -- をパラメータ終端として飲み込みますConnection closed としか出ないので原因に気づきにくい。npx.cmd を明示するか、Git Bash か cmd から実行すれば通ります。


🧪 Claude Code に頼むと、青いバッジで現れる

登録できたら、あとは日本語で頼むだけです。記事用には、結果を機械的に残すために headless の claude -p を使いました。プロンプトの要旨は「taskboard サーバーのツールだけを使って、(1) add_itemblog に候補を 1 つ足し、(2) list_items で探し、(3) その項目に add_note する」です。

claude -p "Use ONLY the MCP tools of server 'taskboard'. (1) add_item workspace='blog' title='Claude Code から追加: LED 点滅の記事案(デモ)' … tags=['demo','mcp']; (2) list_items workspace='blog' query='Claude Code から追加'; (3) add_note on that item body='MCP 経由のノート(デモ)。次は写真を撮る。' Reply with the three raw JSON results." `
  --output-format json `
  --allowedTools "mcp__taskboard__add_item,mcp__taskboard__list_items,mcp__taskboard__add_note" `
  --max-turns 8

--allowedTools に許すツールを列挙しておくと、確認なしで最後まで走ります。5 ターン・約 14 秒で終わり、3 つのツールの structured_content がそのまま返ってきました。1 つ目の add_item の結果です。

{
  "id": 21,
  "workspace": "blog",
  "title": "Claude Code から追加: LED 点滅の記事案(デモ)",
  "body": "- 555 と ESP32 の両方で LED を点滅させて比較する\n- ダミー項目",
  "status": "candidate",
  "priority": "normal",
  "tags": ["demo", "mcp"],
  "created_by": "ai:demo-assistant",
  "created_at": "2026-09-12T02:33:10Z",
  "url": "http://127.0.0.1:8765/w/blog/items/21"
}

created_byai:demo-assistant になっています。プロンプトには author を一言も書いていません。サーバーの環境変数が、そのまま履歴の名前になったということです。

ブラウザで同じボードを開くと、候補列の先頭に #21 が青いバッジ付きで入っています。

ブログのかんばん。候補列の先頭に Claude Code が追加した #21 のカードがあり、青い AI バッジと demo・mcp のタグが付いている

Claude Code が add_item した直後のかんばん。候補列の #21 に AI バッジ(ダミーデータ)

#21 の詳細画面。作成者は AI · demo-assistant、ノートに MCP 経由のノートが 1 件、履歴 2 行の右端に mcp と表示されている

#21 の詳細。ノートも履歴も author は ai:demo-assistant、右端の sourcemcp(ダミーデータ)

履歴の右端が mcp になっているのが、前回作った event.source 列です。同じ項目に人間がブラウザからノートを足せば、その行は human / ui で並びます。誰が・どの口から書いたかが、1 つの表に混ざって残る。これが欲しかった画面です。

対話セッションでも同じです。「ブログの候補を 3 つ足しておいて」と頼めば add_item が 3 回呼ばれ、それぞれに許可を求めてきます。get_workspace_summary を最初に呼ぶよう instructions に書いてあるので、AI はたいてい先に状況を見てから書きます。


🙈 hidden が本当に見えないことを確かめる

hidden の効き目は、画面ではなくAI 側の一覧に出ないことで確かめます。ダミー DB には hidden のワークスペースが無いので、「読書」を一時的に切り替えて試します。

  1. ブラウザで http://127.0.0.1:8765/w/reading/settings を開き、「AI からの見え方」を「AI: 非公開」(hidden)にして保存する。この変更も workspace.updated として履歴に残ります
  2. MCP Inspector の CLI で list_workspaces を呼ぶ
npx --yes @modelcontextprotocol/inspector@2.6.0 --cli --config docs/inspector.example.json \
  --server taskboard-demo --method tools/call --tool-name list_workspaces --format json

切り替える前は blog / workshop / reading の 3 つが返り、切り替えた後は reading が消えます。reading にある項目の ID で get_item を呼んでも、存在する ID なのに item #<ID> not foundToolError が返ります。REST でも同じで、GET /api/v1/workspaces/reading/summary は 404 になります。

  1. 試し終わったら read_only に戻す。これも履歴に残ります

demo DB でこのとおりに切り替えたときの、Inspector の実際の出力です。まず list_workspacesreadingai_policy: "hidden" として並ぶのではなく、配列から無くなります(抜粋)。

{
  "workspaces": [
    {"slug": "blog", "name": "ブログ", "ai_policy": "read_write", "counts": {"candidate": 1, "doing": 4, "waiting_human": 1, "waiting_ai": 2, "done": 1, "hold": 1}},
    {"slug": "workshop", "name": "工作室", "ai_policy": "read_write", "counts": {"candidate": 2, "doing": 1, "waiting_human": 1, "waiting_ai": 0, "done": 1, "hold": 1}}
  ]
}

続けて、消えたはずの reading を slug で直接指定します。読み取りも書き込みも、返ってくるのは同じ not found です(Inspector の CLI は isError: true を終了コード 5 で知らせるので、シェルスクリプトからも判定できます)。

$ … --method tools/call --tool-name get_workspace_summary --tool-args-json '{"workspace":"reading"}' --format json
{"result":{"content":[{"type":"text","text":"Error executing tool get_workspace_summary: workspace 'reading' not found"}],"isError":true}}

$ … --method tools/call --tool-name add_item --tool-args-json '{"workspace":"reading","title":"Inspector から(hidden 中)"}' --format json
{"result":{"content":[{"type":"text","text":"Error executing tool add_item: workspace 'reading' not found"}],"isError":true}}

同じ add_itemread_only のときに投げると、文言は workspace 'reading' is read-only for AI でした。hidden はそれと違い、「読めない」ではなく「無い」に倒しています。AI から見た 2 つの違いを並べるとこうです。

AI から見ると read_only hidden
list_workspaces の一覧 出る(ai_policy: "read_only" 出ない(配列から消える)
読み取り(get_workspace_summary など) 読める workspace 'reading' not found
書き込み(add_item など) workspace 'reading' is read-only for AI workspace 'reading' not found
AI に伝わること ある。でも書けない 無い

read_only に戻すと list_workspaces は 3 件に戻り、demo DB の履歴には切り替え往復の workspace.updated が 2 行(source=ui)残りました。

Inspector の CLI は親シェルの環境変数を子プロセスに渡さないので、TASKBOARD_DBTASKBOARD_AUTHOR は設定ファイル(docs/inspector.example.json)の env に書いて --config で渡します。ここを知らずに環境変数を $env: で設定して呼ぶと、既定の DB に繋がって「項目が無い」と言われます。

この挙動は pytest でも固定しています。初期ワークスペースの「仕事」(work・既定 hidden)が list_workspaces に出ず、get_workspace_summary("work") も、work の項目 ID を get_item に渡しても not found になることを確かめる test_read_tools_hide_hidden_workspace です(tests/test_mcp.py L62-70)。

前回も書いたとおり、アプリが守れるのは「AI が自分で読みに行けない」ことだけです。人間がチャットに貼れば渡ります。hidden は、AI が勝手に仕事の項目を読んで要約し始める、という事故を防ぐための境界です。


🔀 Web と MCP が同じ SQLite を同時に開く

Web サーバーは uvicorn、MCP サーバーは Claude Code が起動する子プロセス。互いを知らない 2 つのプロセスが、同じ taskboard.sqlite3 を開きます。 これが成り立つのは前回入れた WAL モードのおかげで、読み取りと書き込みが互いをブロックしません。

「本当に同時に動くか」は、両方を子プロセスとして起動する統合テストで確かめています(tests/test_integration.py)。

flowchart TD W["uvicorn を子プロセスで起動
/healthz が ok を返すまで待つ"] --> M["stdio の MCP クライアントで
taskboard mcp を子プロセス起動"] M --> A["MCP: add_item(blog)"] A --> B["HTTP: 返ってきた url を GET
→ HTML に項目名と badge-ai がある"] B --> C["HTTP: Web UI にノートを POST(author=human)"] C --> D["MCP: get_item
→ notes に human のノートが見える"] D --> E["MCP: list_workspaces
→ work(hidden)は出ない"]

MCP で足した項目が、Web を経由せずにその場で Web の画面に出る。逆に Web で書いたノートが、MCP の get_item で読める。どちらも同じファイルを見ているだけなので当然ですが、ロールバックジャーナル(SQLite の既定)のままだと、書き込み中の読み取りが database is locked で待たされる場面が出ます。

規模の話をしておくと、1 人の人間と数個の AI が使うボードで、書き込みが重なる瞬間はほとんどありません。重なっても busy_timeout の 5 秒で解けます。サーバー型の DB を持ち出す理由が無いのは、この規模だからです。


🌐 REST:別の AI から叩く口

MCP は Claude Code のような stdio のホスト向けです。それ以外の AI やスクリプトから同じ操作をしたいときのために、同じ service 関数を /api/v1 でも出しています。MCP の 9 ツールと 1 対 1 で対応し、監査用の GET /api/v1/events だけが REST にしかありません。

  • TASKBOARD_API_TOKEN を設定して起動したときだけ有効。未設定なら /api/v1/* は丸ごと 404 で、存在しない扱い
  • Authorization: Bearer <トークン>X-Taskboard-Author: ai:<名前> の 2 つのヘッダが必須。author は MCP の環境変数に相当するもので、呼び出し側が名乗る形になる分、トークンで守る
H1='Authorization: Bearer <TASKBOARD_API_TOKEN の値>'
H2='X-Taskboard-Author: ai:gemini-analytics'
curl -H "$H1" -H "$H2" http://127.0.0.1:8765/api/v1/workspaces
curl -H "$H1" -H "$H2" -H 'Content-Type: application/json' \
     --data-binary @item.json http://127.0.0.1:8765/api/v1/workspaces/blog/items    # 201

エラーは FastAPI 標準の {"detail": "..."} で、MCP の ToolError と同じ文言です。

状態コード 場面
401 トークンが違う
400 検証エラー・X-Taskboard-Author が無い/形式違い・PATCHstatus を含めた
403 read_only のワークスペースへの書き込み
404 不明な slug/ID・hidden・REST が無効
409 同じ状態への移動・二重完了

OpenAPI は /docs に自動で出るので、REST 経由で繋ぐ AI にはこの URL を渡せば済みます。Windows の Git Bash で日本語を -d '…' に直接書くと文字コードが壊れるので、JSON はファイルにして --data-binary @file で送るのが確実です。


🔬 動くことを確かめる 3 つの型

前作では SDK のクライアント API で自作サーバーを叩きました。今回は 3 段にしています。

1. pytest の in-memory クライアント。 子プロセスを立てずに、同じプロセス内で 9 ツールを呼べます(tests/test_mcp.py L39-41)。

async with Client(mcp_server.mcp, raise_exceptions=True) as c:
    r = await c.call_tool("add_item", {"workspace": "blog", "title": "MCP から追加 ✓"})
    assert r.structured_content["created_by"] == "ai:test-bot"

hidden / read_only の拒否、author の固定、source == "mcp"、検証エラーと競合が ToolError になること、TASKBOARD_AUTHOR 未設定で書き込みだけが止まること。MCP 分は 7 本のテストで、全体では 46 本が通ります。

2. MCP Inspector の CLI。 実際に stdio で子プロセスを起動して叩くので、「自分の PC で本当に起動できるか」まで含めて確かめられます。--config で環境変数を渡す点は前述のとおりです。

3. claude -p--allowedTools Claude Code が本当にツールを見つけて呼べるかの決定打です。上で使ったコマンドをそのまま「MCP の統合テスト」として使えます。数十円かかりますが、登録の書き方の間違いはここでしか分かりません。


💭 所感:誰が押したかを残す仕組みは、道具側に置く

📌 設計判断として残しておきたいこと

AI に道具を渡すとき、「誰が使ったか」を AI に申告させてはいけない、というのが今回いちばん強く感じたことです。AI は引数を埋めるのが仕事で、author という引数があれば埋めます。プロンプトに「あなたは human です」と書かれていれば、そう埋めます。悪意は要りません。

だから author は、AI が触れない場所——サーバーの環境変数——に置きました。REST では呼び出し側が名乗りますが、代わりにトークンで守っています。どちらも「名乗りの正しさをアプリが保証できる範囲」を明確にした形です。

同じ理屈で、hidden の判定もツール側ではなく service 層に置きました。ツールを 1 本足すたびに権限チェックを書き忘れる可能性を、構造で消しています。AI が正しく振る舞うことに期待しない設計のほうが、結果として AI に安心して任せられる。この逆説は、電子工作で「壊れても安全側に倒れる回路」を作るのと同じ感覚でした。


⚠️ できないこと・確認していないこと

  • stdio だけ。Streamable HTTP での常時公開はしていません。Claude Code が子プロセスとして起動する運用が前提で、認証・ポート・DNS rebinding を考えなくて済む代わりに、リモートからは繋げません
  • 言い直しの実験は、プロンプトに「失敗したら list_workspaces で探してやり直せ」と書いた 1 回だけです。その指示なしでも instructions だけで同じ手を選ぶかは確かめていません
  • MCP Inspector v2.6.0 は Node.js 22.19 以上を要求します。手元は 22.17.0 で、警告付きで CLI は動きましたが、全機能が動く保証はありません
  • Windows でしか動かしていません。OS 依存のコードは無いので macOS/Linux でも動くはずですが、確認はしていません
  • SDK は 2.x 前提です。FastMCPMCPServerstructuredContentstructured_content の改名は前作に書いたとおりで、1.x のサンプルは写しても動きません

✅ まとめ

  • 状態を持つ MCP サーバーで新たに考えることは 5 つ。author・権限・同時アクセス・失敗の伝え方・戻り値の型
  • author はツール引数で受けず、環境変数 TASKBOARD_AUTHOR で固定。未設定なら読み取り専用。AI ごとに名前を変えれば履歴で区別できる
  • 権限は DB の ai_policy を service 層で強制hidden は「存在しない」、read_only は書き込みだけ拒否。MCP と REST で同じ規則
  • 戻り値は Pydantic モデル-> dict では output_schema が付かない。一覧は自分のモデルで包む。url はサーバーが組み立てる
  • service の例外は境界で ToolError に読み替える。文言には「代わりに何を使うか」まで書く
  • 登録は claude mcp add -e … -- uv run --directory <絶対パス> taskboard mcpuser スコープは全セッションに見えるので、役割ごとに名前と author を分ける
  • Web と MCP は別プロセスで同じ SQLite を開く。WAL があれば足りる

台帳は、これで人と AI の両方から書けるようになりました。Claude Code のセッションが終わっても、残項目はボードに残っています。次にセッションを開いたときは、get_workspace_summary を 1 回呼べば、AI は前回どこまで進んだかを自分で読めます。チャットの末尾に散らばっていたものが、URL で指せる場所に集まった。作り始めた理由は、これで果たせました。


よくある質問(FAQ)

Q: Web サーバーを起動していないと、Claude Code からは書き込めませんか?

A: 書き込めます。MCP サーバーは Web サーバーとは別プロセスで、同じ SQLite ファイルを直接開きます。DB が無ければ作り、ワークスペースが無ければ初期の 3 つを用意します。Web サーバーは人間が画面で見るときだけ起動すれば足ります。

Q: author をツールの引数で受けないと、複数の AI をどう区別するのですか?

A: AI ごとに TASKBOARD_AUTHOR を変えて別の名前で登録します。たとえば taskboard-writerai:writertaskboard-opsai:ops のように登録すれば、それぞれの書き込みが履歴で区別されます。呼び出し側が名乗る方式にしなかったのは、AI がプロンプト次第で別の名前を埋めてしまう余地を無くすためです。

Q: hidden のワークスペースは、AI からは本当に見えないのですか?

A: 一覧に出ず、slug や項目 ID を直接指定しても not found が返ります。「アクセス拒否」ではなく「存在しない」として扱うので、拒否の応答から存在を推測することもできません。ただし守れるのは「AI が自分で読みに行けない」ことだけで、人間がチャットに内容を貼れば当然 AI に渡ります。

Q: 戻り値を dict で返してはいけないのですか?

A: 素の dict を返り値の型注釈にすると output_schema が付かず、クライアントが受け取る structured_contentNone になります。dict[str, X]TypedDict・Pydantic モデルのいずれかで宣言してください。今回は FastAPI の response_model と共有できる Pydantic を選びました。

Q: Claude Code の user スコープで登録すると何が起きますか?

A: そのマシンで起動するすべての Claude Code セッションから、同じ author で書き込めるようになります。役割ごとに複数のセッションを動かしているなら、全部が同じ名前を名乗ることになるので、名前と author を分けて別々に登録してください。特定のフォルダでしか使わない登録は既定の local スコープのままで十分です。

Q: MCP Inspector で環境変数が効かないのはなぜですか?

A: Inspector の CLI は親シェルの環境変数を子プロセスに渡しません。TASKBOARD_DBTASKBOARD_AUTHOR は設定ファイルの env に書いて --config で渡してください。リポジトリの docs/inspector.example.json がその例です。PowerShell では npx-- を飲み込むので、npx.cmd か Git Bash から実行する点にも注意してください。


関連記事


参考