QLITRE DIALY

飯ログにMCP登録機能を追加した

2026年05月03日

以前このブログでも紹介をしたが、自分は「飯ログ」というサービスをやっている。

新サービス"meshi-log"をはじめた

その名の通り飯を食ったらログに残すというサービスだ。個人的に行った飯屋を記録できるツールが欲しかったので、昨年の10月に自作して立ち上げた。技術スタックはこの日記と同じくmicroCMSとHonoX。

ちなみに一応はサービスの形をとっているが検索流入はほとんどない。

以下の写真はGoogle Search Consoleにおける3ヶ月間の検索流入結果だ。

クリック回数は合計で19回となっている。

これはどんな数字かというと、一言でいえば「誰からも見向きされていない」というような状態である。しかし、行ったお店の記録をする行為は楽しい。だから地道に続けている。

一部例外を除いて基本的に行ったお店は登録し訪問ログを残すようにしている。2026年5月3日現在で訪問記録は172件、店舗登録は125件となっている。

現状の課題について

この飯ログは新しいお店に行ったらまずは店舗登録をして、それに紐づく日記を入稿する、というようなデータ構造にしている。下の図でいうshopが店舗データ、visitsが訪問日記だ。

ある程度構造的に検索をするために、外部参照として地区コードであるareaやカテゴリを示すgenreデータを持たせている。そしてこれらの外部参照データのメンテナンスが面倒くさくなってきた。

フィールドは少ないので登録自体はそこまで手間がかからない。しかし、データが増えてくると、登録してあるかどうかをまず調べる、という確認が必要だ。例えば町田市のお店にいったとして「町田市」という地区データが登録されているか調べたりする。これがなかなか面倒だ。しかも地区コードはインターネットで調べる必要があるし、また緯度経度もデータとして持たせていて、こちらも外部サイトに住所を入力して調べたり…などなど意外と店舗登録するにあたりやることは多い。

このサービスの幹の部分は「訪問日記を書く」という点にある。一方で店舗情報は決まり切った情報のため、本質的な部分ではなく、そこに時間と手間を取られていたのが課題だった。

MCP登録機能の追加

長くなったがここからが本題。これらの課題をAIで解決しようということで改修を行った。

具体的にはClaudeなどのアプリからmeshi-logのMCPサーバーに接続をする。そして店名を入れて登録を依頼するとクライアントが自律的に働き店舗の登録ができるようにした。mcpサーバー自体はもともと実装していて、おすすめのお店を調べたりみたいなツールが用意されていた。今回はこれに登録系の機能を持たせた形になる。

具体的にはデモで動かしてみた動画がわかりやすい。

町田市にある「龍聖軒」というラーメン屋を登録してみた例である。

店名から住所をweb検索して、登録されているエリアを確認して、町田市が未登録であることを確認。

そして町田市の地区コードを調べて、エリアAPIに登録し、そのエリアを参照する形で新店舗を登録…という一連の流れを自動で行ってくれた。

店舗の説明文、おすすめフラグなどは自分で修正する必要があるが、かなりの手間を省いてくれていると感じる。

工夫した点

基本的にはClaude Codeで実装。設計部分で意識した話など。

クライアントに任せる

クライアントアプリ側で出来ることはクライアント側に任せてサーバー側の実装は最小限にした。

具体的にはiOSのClaudeアプリの利用を前提で考えた。WEB検索、緯度経度検索などは標準機能で実現可能だ。そのため、サーバー側は難しいことはせずに値を受け取って、そのままmicroCMSにWrite APIを投げる、という構成にした。

例えばWEB検索などはTavilyを使ったり緯度経度取得に関してはGoogleのgeocoding APIを使う方法も検討した。しかしこの機能を使うのは自分だけであるし、クライアント側で標準で出来るものをわざわざサーバー側で実装する意味がないよなと思い、実装しないことにした。

例えば「鶴川の龍聖軒を登録して」というメッセージをクライアントアプリが受け取ったら以下のような棲み分けで動きを行うことを期待している。

  1. WEBで住所、ジャンル、緯度経度などを調べる(クライアント機能)
  2. mcpでエリアAPI、ジャンルAPIを調べる(サーバーツール)
  3. 存在してなかったらmcpを使って登録をする(サーバーツール)
  4. 店舗登録に必要なデータが揃ったらmicroCMSに登録する。(サーバーツール)

ネイティブ機能が豊富にあるため、microCMS以外の外部APIを使わずに実装ができた点がよかった。

このあたりはmcpの店舗登録ツールの説明文に記載することで制御した。

「microCMSで新しい店舗情報を作成します。areaはエリアのコンテンツID、genreはジャンルのコンテンツIDの配列です。area_codeはJIS市区町村コードを指定してください。任意でidを指定することでカスタムコンテンツID(URLフレンドリーなスラッグなど)を設定できます。省略した場合はmicroCMSによって自動生成されます。

重要: このツールを呼び出す前に、必ずWeb検索または「Searching for place」ツールを使用して、正確な「住所(address)」および「緯度(latitude)/経度(longitude)」を調査してください。実際の店舗名を検索し、その結果から住所と座標を確認する必要があります。推測や類推、おおよその値を使用しないでください。」

server.registerTool(
      'create_shop',
      {
        title: 'Create Shop',
        description:
          'Create a new shop in microCMS. `area` is the area content ID, `genre` is an array of genre content IDs. `area_code` is the JIS municipality code. Optionally specify `id` for a custom contentId (e.g. a URL-friendly slug); if omitted, microCMS auto-generates one. IMPORTANT: Before calling this tool, you MUST look up both the precise `address` and `latitude`/`longitude` via web search or the "Searching for place" tool — search for the actual shop name and verify the address and coordinates from the results. Do NOT guess, infer, or use approximate values.',
        inputSchema: {
          id: z.string().optional(),
          name: z.string().min(1),
          address: z.string().min(1),
          latitude: z.number(),
          longitude: z.number(),
          area: z.string().min(1),
          area_code: z.string().min(1),
          genre: z.array(z.string().min(1)).min(1),
          memo: z.string(),
          is_recommended: z.boolean().default(false),
          rating: z.number().min(0).max(5).optional().default(4),
          nearest_station: z.string().optional(),
        },
      },
      async (params: {
        id?: string
        name: string
        address: string
        latitude: number
        longitude: number
        area: string
        area_code: string
        genre: string[]
        memo: string
        is_recommended: boolean
        rating?: number
        nearest_station?: string
      }) => {
        const { id, ...body } = params
        const result = await createShop({ client, contentId: id, body })
        return {
          content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
        }
      }
    )

既存機能との切り分けOauth認証

MCPサーバー自体は一般にも公開しているため、サービスを分岐する必要がある。具体的にはおすすめのお店を聞いたりは誰でも出来る必要がある。一方で登録系のMCPは管理者である自分だけが行うように制御する必要がある。

最初はURLにクエリ文字を含めて認証しようかと思ったが、どうもアクセスログが残るためセキュリティ的にあまりよろしくないらしい。調べたらClaudeアプリからの接続でOauth認証が使えること、またCloudflare Workersにヘルパーがあったのでそちらを使ってみるとよいらしいので実装をした。

切り分けについて。mcpサーバーに登録系かどうかを判定するオプションを持たせてツールを分岐させた。

type McpServerOptions = {
  includeWriteTools?: boolean
}

export const getMcpServer = async (c: Context<Env>, options: McpServerOptions = {}) => {
  const serviceDomain = c.env.SERVICE_DOMAIN
  const apiKey = c.env.API_KEY
  const client = getMicroCMSClient(c)
  const server = new McpServer({
    name: 'meshi-log MCP Server',
    version: '0.0.1',
  })
// 通常のGET系のツール
server.registerTool(
    'get_popular_visits',
    {
      title: 'Get Popular Visits',
      description: 'Get Popular Visits',
      inputSchema: {},
    },
    async () => {
      const result = await getPopularPages(c.env.DB)
      return {
        content: [
          {
            type: 'text',
            text: JSON.stringify(result, null, 2),
          },
        ],
      }
    }
  )
 // Write系のツール
  if (options.includeWriteTools) {
    server.registerTool(
      'create_area',
      {
        title: 'Create Area',
        description:
          '...',
        inputSchema: {
          id: z.string().min(1),
          code: z.string().min(1),
          name: z.string().min(1),
        },
      },
      async (params: { id: string; code: string; name: string }) => {
        const { id, ...body } = params
        const result = await createArea({ client, contentId: id, body })
        return {
          content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
        }
      }
    )
}

そして、登録系ルートにはOptionをtrueにしてURLルートを生やす。

import { Hono } from 'hono'
import type { Env } from 'hono'
import { HTTPException } from 'hono/http-exception'
import { StreamableHTTPTransport } from '@hono/mcp'
import { getMcpServer } from './mcp'

const adminApp = new Hono<Env>()

adminApp.all('/mcp/admin', async (c) => {
  const mcpServer = await getMcpServer(c, { includeWriteTools: true })
  const transport = new StreamableHTTPTransport()
  await mcpServer.connect(transport)
  return transport.handleRequest(c)
})

export default adminApp

そしてserver.tsOauthProviderでこのルートをラップする。

import { showRoutes } from 'hono/dev'
import { createApp } from 'honox/server'
import { OAuthProvider } from '@cloudflare/workers-oauth-provider'
import mcpApp from './routes/mcp'
import adminMcpApp from './routes/mcp-admin'

// 通常のWEBサイト
const honoxApp = createApp()
// mcpツール
honoxApp.route('/mcp', mcpApp)

showRoutes(honoxApp)
// 登録系のmcp
export default new OAuthProvider({
  apiRoute: '/mcp/admin',
  apiHandler: adminMcpApp,
  defaultHandler: honoxApp,
  authorizeEndpoint: '/oauth/authorize',
  tokenEndpoint: '/oauth/token',
  clientRegistrationEndpoint: '/oauth/register',
  scopesSupported: ['mcp:write'],
  accessTokenTTL: 3600,
  refreshTokenTTL: 30 * 24 * 3600,
})

こうすることでClaudeアプリからMCPに接続を試みる際にOauth認証ページが立ち上がりパスワード認証ができるようになる。認証の動きはauthorizeEndpointで指定したルート/oauth/authorize.tsxで定義。

こんなコードになった。

const Page = ({
  clientName,
  scope,
  error,
}: {
  clientName: string
  scope: string
  error?: string
}) => (
  <html lang="ja">
    <head>
      <meta charset="utf-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1.0" />
      <meta name="robots" content="noindex,nofollow" />
      <title>meshi-log MCP Authorize</title>
      <Style />
    </head>
    <body class={bodyClass}>
      <h1 class={headingClass}>MCP Authorization</h1>
      <dl class={infoClass}>
        <dt>Client</dt>
        <dd>{clientName}</dd>
        <dt>Scope</dt>
        <dd>{scope || '(none)'}</dd>
      </dl>
      {error && <p class={errorClass}>{error}</p>}
      <form method="post">
        <input
          type="password"
          name="password"
          placeholder="Admin password"
          autofocus
          required
          class={inputClass}
        />
        <button type="submit" class={buttonClass}>
          Authorize
        </button>
      </form>
    </body>
  </html>
)

export const GET = createRoute(async (c) => {
  const oauthReqInfo = await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw)
  const client = await c.env.OAUTH_PROVIDER.lookupClient(oauthReqInfo.clientId)
  const clientName = client?.clientName ?? oauthReqInfo.clientId
  const scope = oauthReqInfo.scope.join(' ')
  return c.html(<Page clientName={clientName} scope={scope} />)
})

export const POST = createRoute(async (c) => {
  const oauthReqInfo = await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw)
  const formData = await c.req.formData()
  const password = formData.get('password')

  if (typeof password !== 'string' || password !== c.env.ADMIN_PASSWORD) {
    const client = await c.env.OAUTH_PROVIDER.lookupClient(oauthReqInfo.clientId)
    const clientName = client?.clientName ?? oauthReqInfo.clientId
    return c.html(
      <Page
        clientName={clientName}
        scope={oauthReqInfo.scope.join(' ')}
        error="パスワードが正しくありません"
      />,
      401
    )
  }

  const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({
    request: oauthReqInfo,
    userId: 'admin',
    scope: oauthReqInfo.scope,
    metadata: {},
    props: {},
  })
  return c.redirect(redirectTo)
})

以上、ざっと認証周りの動きとなる。正直に申し上げて認証認可回りはあまり理解していないがこれは再現できるとこれから役に立ちそう。最小の構成で再現してみたのがこちらのリポジトリ。

https://github.com/qlitre/hono-mcp-oauth-sample

飯ログ自体のソースコードはこちら。

https://github.com/qlitre/meshi-log

おわりに

長い間ずっとやりたいと思っていたことが実現できてよかった。これで訪問ログを残すという本質的な作業により集中できる。ではでは。