APIキーを配らずに「社内の人とAIだけが使えるCLI/MCP」を作る

会社を AI Ready にする、と言ったときに最初に必要なのは、AIが人間と同じ文脈を読みに行ける場所です。顧客情報、案件の進捗、社内ナレッジ。人間がいちいちコピペして渡さなくても、AIが自分で取りに行ける状態を作りたい。

そのための入口として AI に扱いやすいのは CLI と MCP です。ところが、ここで必ず認証の問題にぶつかります。

「APIキーを発行して配ればいいのでは?」と考えたくなりますが、これには3つの問題があります。

  • AIに秘密情報を渡したくない。 APIキーは漏れたら終わりの秘密情報です。AIの設定ファイルや会話ログに書く運用は避けたい
  • 発行・配布・保管が面倒。 人数分のキーを作り、安全に渡し、各自に保管させる手間が発生する
  • 失効管理が地獄。 退職者のキーを消し忘れたら、その人は退職後もデータにアクセスできてしまう。有効期限も個別管理になる

この記事では、APIキーを一切配らずにこの問題を解く構成を説明します。考え方はひとつです。

利用者ごとにキーを発行するのではなく、API自体をIdP(Google Workspace等)のログインで守り、その認証をCLIとMCPで共有する。

後半では、この構成を私の環境(ひとり税理士事務所+Cloudflare+Google Workspace)に導入する手順を書きます。

全体像 ― 守るのはAPIひとつだけ

登場人物は3つです。

要素役割実体
API業務データの正本。唯一の保護対象Cloudflare Workers + D1
CLIターミナルからAPIを呼ぶ入口単一バイナリ(npm配布)
MCPサーバAIホスト(Claude等)からAPIを呼ぶ入口CLIと同じバイナリ<cli> mcp で起動)

ポイントは、CLIもMCPもAPIを呼ぶだけの「入口」にすぎないという点です。データを持つのはAPIだけ。だから守るのもAPIだけでいい。入口を何個増やしても、セキュリティの設計は1か所で済みます。

人間(ターミナル)──→ CLI ─┐
                            ├─→ Cloudflare Access ──→ API(Workers + D1)
AI(Claude等)────→ MCP ─┘      (IdPで本人確認)

そしてAPIの手前に Cloudflare Access を置きます。これは「リクエストが届く前に、エッジで本人確認を済ませる門番」です。Google Workspace にログインしていない人のリクエストは、APIに到達する前に弾かれます。

門番にCloudflareを選ぶ理由は3つあります。

  • Google Workspace 等のIdPとそのまま連携でき、「誰が社員か」の判断をIdPに任せられる
  • cloudflared というローカルエージェントが認証トークンの取得とキャッシュを肩代わりするので、CLIもMCPも秘密情報を1バイトも持たずに済む
  • APIの実行基盤(Workers)もデータベース(D1)もCloudflareで作れば、認証から実行まで同じプラットフォームで完結する

認証の流れ ― トークンの面倒は cloudflared が見る

利用者から見た認証は、こうなります。

  1. CLIがログイン操作を始め、ブラウザが開く
  2. ブラウザに Google のログイン画面が出る(普段の Google ログインと同じ)
  3. ログインに成功すると、Cloudflare Access が**短命のトークン(JWT)**を発行する
  4. cloudflared がそのトークンをローカルにキャッシュし、以降のAPI呼び出しで使い回す。期限が切れたら再ログイン

利用者がやることは「ブラウザでGoogleにログインする」だけです。パスワードもAPIキーも、CLIの設定ファイルには何も書きません。トークンの取得・保管・更新はぜんぶ cloudflared の仕事です。

AIも同じです。AI専用のキーは発行しません。AIは、その端末の持ち主がすでに済ませたログインのセッションをそのまま使います。 だから「このリクエストは誰の操作か」が常に明確で、その人が退職すればAIのアクセスも同時に消えます。

二段階のJWT検証 ― 門番を信じすぎない

トークンを持ったリクエストは、2回検証されます。

  1. エッジ(Cloudflare Access)での検証。 クライアントはトークンをヘッダ(cf-access-token)に載せて送る。Access がこれを検証し、ダメならこの時点で拒否
  2. オリジン(API)での再検証。 Access は検証済みリクエストに Cf-Access-Jwt-Assertion ヘッダを付けてAPIへ転送する。API側はこのJWTを、Access が公開する公開鍵セット(JWKS、/cdn-cgi/access/certs)でもう一度検証する。署名に加えて、発行者(issuer=チームドメイン)と宛先(audience=アプリ固有のAUD値)の一致も確認する

「Access が通したなら信用すればいいのでは?」と思うかもしれませんが、オリジンでも検証するのは多層防御のためです。設定ミスや想定外の経路でAPIに直接届いたリクエストを、確実に弾けます。

注意点として、検証用の公開鍵は固定値で持ってはいけません。鍵は既定で6週間ごとにローテーションされるので、必ずJWKSエンドポイントから取得します。

入退社はIdPの操作だけで完結する

この構成のいちばんおいしいところです。「誰が社員か」の名簿は IdP(Google Workspace)ただ1か所で管理します。Cloudflare Access も API も、独自のユーザ名簿を持ちません。

  • 入社時: Google Workspace にアカウントを追加するだけ。CLI・MCP・Webすべてに即アクセスできる
  • 退職時: Google Workspace からアカウントを削除(または無効化)するだけ。Access は認証のたびにIdPを参照するので、削除されたユーザは新しいトークンを取れない。手元に残った短命トークンも期限切れで死ぬ

「退職者のAPIキーを消し忘れた」という事故が、構造的に起きません。

CLIとMCPは同じバイナリ ― 入口が違うだけ

CLIに <cli> mcp というサブコマンドを用意し、これを実行するとMCPサーバとして振る舞うようにします。同じバイナリなので、認証・通信・データアクセスのコードはすべて共有です。

入口主な利用者通信形式
CLI人間(ターミナル)、シェルを扱うAIエージェントコマンド実行・標準出力(パイプ時はJSON)
MCPMCP対応のAIホスト(Claude Desktop / Claude Code等)stdio上のJSON-RPC

CLIは出力先がパイプのときJSONに自動で切り替わるので、Claude Code のようにシェルを叩けるAIはCLIを直接使えます。MCPしか話せないホストにはMCPで応えます。どちらの入口から入っても、同じトークンで同じAPIを呼ぶので、人間とAIが完全に同じ認可のもとで同じデータを見ます。

MCP側の実装で押さえる点は3つです。

  • stdioの規律: プロトコルが流れる標準出力は専有し、ログは標準エラー出力へ出す
  • 遅延認証: 起動時には認証しない。起動時に認証で落ちると、ホスト側から原因を追えなくなる。ツール呼び出しのたびにトークンを取り、401/403が返ったら一度だけ取り直して再試行する
  • 読み取り専用: AIに公開するツールは参照系に絞る。AIからデータを変更できないことを型レベルで保証する

CLIの配布 ― GitHub Orgメンバーシップが第二のゲート

CLIは単一ファイルにバンドルして、GitHub Packages のnpmレジストリに非公開パッケージとして置きます。こうすると、別のレジストリや認証基盤を立てずに、ソースコードと同じGitHubの権限管理を配布のゲートに流用できます。

  • publish側(CI): GitHub Actions が自動発行する GITHUB_TOKEN で publish する。このトークンはワークフロー実行中だけ有効で、終わると失効する。長期トークンをsecretに保管・ローテーションする作業が消える
  • install側(利用者): 非公開パッケージの取得には「Org メンバーのトークン(read:packages)」が要る。つまり GitHub Org のメンバーであることが、CLIをインストールできる条件そのものになる。トークンは GitHub CLI(gh)のログインセッションからその都度取り出して環境変数で渡せば、ディスクに平文保存しなくて済む

ここで誤解しやすいのが、パッケージの非公開化はセキュリティ境界の本体ではないという点です。境界はあくまで Cloudflare Access(IdP認証)。CLIのバイナリ自体は秘密情報を含まないので、仮に流出してもIdPログインなしではAPIに触れません。非公開配布は「無関係な人がそもそも入手できない」ようにする追加の一段です。

結果として、ゲートが二重になります。

  • 配布のゲート = GitHub Org メンバーシップ
  • APIのゲート = IdP(Google Workspace)認証

退職時に両方の名簿から外せば、入手経路と実行権限が同時に消えます。

IdPログインできない経路の扱い

外部サービスからのWebhookや、外形監視のヘルスチェックは、ブラウザでGoogleにログインできません。これらは Cloudflare Access の Bypassポリシーで保護対象から外し、別の手段で正当性を担保します。

  • Webhook: 送信元が付与する署名(HMAC等)をオリジン側で検証する
  • ヘルスチェック: 認証不要の軽量エンドポイントを用意し、機密情報を返さない

ローカル開発用に認証をバイパスする経路を作る場合は、本番に絶対持ち込まない運用が前提です。

費用 ― 50人以下ならIdP代だけ

  • Cloudflare Access(Zero Trust)は 50ユーザーまで無料
  • Cloudflare Workers / D1 にも無料枠があり、小〜中規模ならその範囲に収まる
  • 実質的な費用は IdP(Google Workspace のライセンス)のみ。多くの組織はすでに契約している

つまり50人以下の組織なら、追加コストほぼゼロで社内限定のAPI基盤を持てます。


税理士事務所に導入する ― 私の環境での手順

ここからは、この構成を税理士業務に導入する手順です。前提となる私の環境はこうです。

  • Google Workspace 契約済み(独自ドメインのメール運用)
  • Cloudflare アカウントあり(このブログ自体が Pages + Workers + D1 で動いている)
  • GitHub アカウントあり
  • AIホストは Claude Code / Claude Desktop

何を載せるか ― 税理士業務の「文脈API」

まず、APIに集約する「業務の文脈」を決めます。税理士業務なら、たとえばこのあたりが候補です。

データAIへの効き方
顧問先マスタ(決算期・消費税の課税方式・税務代理の範囲)「○○社の申告期限いつ?」「簡易課税だっけ?」に即答できる
申告・届出の期限と進捗ステータス「今月締切の作業を一覧して」が一発で出る
顧問先ごとの留意事項メモ(過去の指摘・特殊論点)申告書レビュー時にAIが過去の経緯を踏まえられる
業務手順・チェックリスト「年末調整の手順を顧問先別の注意点込みで」と聞ける

逆に、マイナンバー・口座情報などの特定個人情報はこのAPIに載せないと最初に決めておきます。Access で守られているとはいえ、AIが読める場所に置く情報は「漏れても守秘義務違反の深刻度が一段低いもの」に絞るのが安全側です。

Step 1: Cloudflare Zero Trust を有効化し、Google を IdP 連携する

  1. Cloudflare ダッシュボードで Zero Trust を有効化する(Freeプランで50ユーザーまで無料。チームドメイン <team>.cloudflareaccess.com を決める)
  2. Settings → Authentication → Login methods で Google Workspace を追加する。Google Cloud Console 側で OAuth クライアントを作成し、クライアントID/シークレットを Cloudflare に登録する流れ

手順の詳細は公式ドキュメントが正確です: → Cloudflare Zero Trust: Google Workspace integration

ひとり事務所なら、まず「Google アカウントでのログイン(汎用Google連携)+メールアドレス個別指定ポリシー」でも動きます。スタッフが増えてグループ単位で制御したくなったら Workspace 連携に格上げすれば十分です。

Step 2: API を Workers + D1 で作る

最小構成のAPIを作ります。D1に顧問先テーブルを置き、Workersで読み取りエンドポイントを生やします。

npm create cloudflare@latest jimusho-api -- --template hono
cd jimusho-api
npx wrangler d1 create jimusho-db
-- schema.sql(最初は本当に最小でいい)
CREATE TABLE clients (
  id INTEGER PRIMARY KEY,
  name TEXT NOT NULL,
  fiscal_month INTEGER,        -- 決算月
  tax_scheme TEXT,             -- 'general' | 'simplified' | 'exempt'
  notes TEXT
);
// src/index.ts(Hono。検証ミドルウェアは Step 4 で足す)
import { Hono } from 'hono'

const app = new Hono<{ Bindings: { DB: D1Database } }>()

app.get('/clients', async (c) => {
  const { results } = await c.env.DB.prepare(
    'SELECT id, name, fiscal_month, tax_scheme FROM clients ORDER BY name'
  ).all()
  return c.json(results)
})

export default app

wrangler deploy でデプロイし、独自ドメインのサブドメイン(例: api.example.com)をWorkersのカスタムドメインに割り当てます。Access はCloudflareが管理するドメインに対して効かせるので、ここは独自ドメイン推奨です。

Step 3: Access アプリケーションでAPIを囲う

  1. Zero Trust ダッシュボードの Access → Applications → Add an application → Self-hosted
  2. 対象ドメインに api.example.com を指定
  3. ポリシーを作成: Action = Allow、Include = Emails ending in @example.com(自社ドメイン)。ひとり事務所なら自分のメールアドレスを直接指定でもいい
  4. 作成後に表示される AUD(Application Audience)値を控える。Step 4 の検証で使う

この時点で https://api.example.com/clients をブラウザで開くと、Googleログイン画面にリダイレクトされるはずです。ログインできれば成功です。

Step 4: オリジン側でJWTを再検証する

Workers側に検証ミドルウェアを足します。jose を使うと短く書けます。

import { createRemoteJWKSet, jwtVerify } from 'jose'

const TEAM_DOMAIN = 'https://<team>.cloudflareaccess.com'
const AUD = '<Step 3で控えたAUD値>'
const JWKS = createRemoteJWKSet(new URL(`${TEAM_DOMAIN}/cdn-cgi/access/certs`))

app.use('*', async (c, next) => {
  const token = c.req.header('Cf-Access-Jwt-Assertion')
  if (!token) return c.json({ error: 'missing token' }, 403)
  try {
    const { payload } = await jwtVerify(token, JWKS, {
      issuer: TEAM_DOMAIN,
      audience: AUD,
    })
    c.set('userEmail', payload.email as string)  // 誰の操作かをログに残せる
  } catch {
    return c.json({ error: 'invalid token' }, 403)
  }
  await next()
})

TEAM_DOMAINAUD はコードに直書きせず、wrangler.toml の vars か secret に置きます。鍵そのものはJWKSから毎回取るので、ローテーションを意識する必要はありません。

Step 5: cloudflared を入れてCLIなしで疎通確認

利用者の端末(自分のPC)に cloudflared を入れます。

winget install Cloudflare.cloudflared

CLIを書く前に、cloudflared 単体でトークンが取れることを確認しておくと切り分けが楽です。

# ブラウザが開いてGoogleログイン → トークンがローカルにキャッシュされる
cloudflared access login https://api.example.com

# キャッシュ済みトークンを取り出してAPIを叩く
curl -H "cf-access-token: $(cloudflared access token --app=https://api.example.com)" \
  https://api.example.com/clients

JSONが返ってくれば、認証基盤は完成です。

Step 6: CLI を作る(mcp サブコマンド込み)

CLIの本体は「cloudflared access token を呼んでヘッダに載せ、APIを叩く」だけの薄いラッパーです。

// トークン取得は cloudflared に丸投げする(これが秘密情報ゼロの正体)
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'

const exec = promisify(execFile)
const API = process.env.JIMUSHO_API_URL ?? 'https://api.example.com'

const getToken = async (): Promise<string> => {
  const { stdout } = await exec('cloudflared', ['access', 'token', `--app=${API}`])
  return stdout.trim()
}

export const apiGet = async (path: string): Promise<unknown> => {
  const res = await fetch(`${API}${path}`, {
    headers: { 'cf-access-token': await getToken() },
  })
  if (res.status === 401 || res.status === 403) {
    // 期限切れ → 一度だけログインし直して再試行
    await exec('cloudflared', ['access', 'login', API])
    const retry = await fetch(`${API}${path}`, {
      headers: { 'cf-access-token': await getToken() },
    })
    if (!retry.ok) throw new Error(`API error: ${retry.status}`)
    return retry.json()
  }
  if (!res.ok) throw new Error(`API error: ${res.status}`)
  return res.json()
}

MCPサーバは同じ apiGet を使い回して、参照系ツールだけを公開します。

// `jimusho mcp` で起動する部分
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { z } from 'zod'

const server = new McpServer({ name: 'jimusho', version: '1.0.0' })

server.tool(
  'list_clients',
  '顧問先の一覧(決算月・消費税方式つき)を取得する',
  {},
  async () => ({
    content: [{ type: 'text', text: JSON.stringify(await apiGet('/clients')) }],
  })
)

await server.connect(new StdioServerTransport())
// 注意: console.log は使わない(stdoutはプロトコル専用)。ログは console.error へ

Step 7: Claude Code / Claude Desktop に登録する

Claude Code なら1コマンドです。

claude mcp add jimusho -- jimusho mcp

Claude Desktop なら設定ファイルに追記します。

{
  "mcpServers": {
    "jimusho": {
      "command": "jimusho",
      "args": ["mcp"],
      "env": { "JIMUSHO_API_URL": "https://api.example.com" }
    }
  }
}

これで「○○社の決算月と消費税の方式を教えて」とClaudeに聞くと、MCP経由でAPIを叩いて答えてくれます。AIに渡した認証情報はゼロ。使われるのは自分がブラウザで済ませたGoogleログインのセッションだけです。

Step 8: 配布 ― ひとり事務所なら省略していい

GitHub Packages での非公開配布は「複数人にCLIを配る」ための仕組みなので、ひとり〜数人の事務所なら最初は省略できます。

  • ひとりの間: private リポジトリを clone して npm link(または npm install -g .)で足りる
  • スタッフに配り始めたら: GitHub Organization を作り、本文前半のとおり GitHub Packages + Actions に移行する。スタッフのGitHubアカウントをOrgに入れることが配布のゲートになる

順番として「認証基盤(Step 1〜5)が先、配布の仕組みは人が増えてから」で問題ありません。セキュリティ境界は Access 側にあるからです。

運用で決めておくこと

  • 載せない情報を文書化する: マイナンバー・口座番号・本人確認書類の類はAPIに入れない、と最初に書き残しておく。後から「ちょっとだけ」と載せ始めるのがいちばん危ない
  • スタッフの退職時: Google Workspace のアカウント停止+(配布をGitHubに移行済みなら)Orgからの除外。この2操作で入手経路もアクセス権も消える
  • freee等の外部SaaSとの関係: 外部SaaSのデータを丸ごと複製するのではなく、「AIが頻繁に参照する要約・マスタ情報」だけをこのAPIに置く。正本が二重になると更新漏れの温床になる
  • ローカル開発: wrangler dev ではAccessを通らないので、開発時だけ検証ミドルウェアをスキップするフラグを持たせる。ただしそのフラグが本番ビルドに混ざらないことをデプロイ前チェックに入れる

まとめ

  • APIキーは配らない。API自体をIdPログインで守り、CLIとMCPがその認証を共有する
  • トークンの取得・キャッシュは cloudflared の仕事。CLIにもMCPにも秘密情報は置かない
  • 検証はエッジとオリジンの二段階。オリジンではJWKSでissuerとaudienceまで確認する
  • 「誰が社員か」はIdPだけが知っている。入退社はIdPの操作1回で全経路に反映される
  • CLIとMCPは同一バイナリ。人間とAIが同じ認可で同じデータを見る。AIには参照系だけ公開する
  • 50人以下なら費用はIdP代だけ。ひとり税理士事務所なら、配布の仕組みは後回しにして認証基盤から始めればいい

「AIに業務の文脈を読ませたい。でもAPIキーをAIに渡すのは嫌だ」という人にとって、現時点でいちばん筋のいい落とし所だと思います。