😸

1ソースでMCPもCLIも動く:Hono中心エージェント開発

に公開

TL;DR

  • AI エージェントに外部ツールをつなぐ経路は MCP(HTTP/stdio) と CLI の 2 系統。Hono を芯に据えると、アプリ本体のロジックを 1 箇所に集約し、同じ実装を MCP(Remote/Local)と CLI の両方で使い回せる。
  • 役割分担は「Hono にロジック」「薄いアダプタで運ぶ」。
  • 今回の記事にサンプルのリポジトリはこちら
  • すぐ試す → npx -y gpt-search@latest --stdio または npx gpt-search@latest search "検索したい内容"(OPENAI_API_KEY が必要)。

はじめに

LLM/AI エージェントから外部ツールを呼ぶには、大きく 2 つの経路があります。

  1. MCP(Model Context Protocol): 通信は Streamable HTTP(SSE) と stdio の 2 種類。Remote/Local どちらの配置でも運用できる。
  2. CLI 連携: gh のようなコマンドを直接叩く。

Hono を使うと、これら 2 系統を 1 つのソースに集約し、MCP(Remote/Local)と CLI を同じ実装で提供できます。この記事では、こちら を参考に作成した GPT-5 ベースの検索ツールを題材に、アプリケーションロジックを Hono に集約し、Remote / Local / CLI の 3 形態で動かす設計を紹介します。

構成(ディレクトリと責務)

  • src/search.ts:検索の中核。OpenAI Responses API を呼び出し、結果テキストを返す。
  • src/app.ts:MCP サーバー本体。ツール名 gpt-web-search として runSearch() を公開。
  • src/index.ts:Hono アプリ。/search と /mcp を提供。
  • src/cli.ts:エントリーポイント。CLI / HTTP サーバ / stdio MCP を切り替える。

アーキテクチャ

  • コアは runSearch() に集約(トランスポート非依存)。
  • MCP は buildServer() で gpt-web-search を公開。
  • Hono は /search と /mcp を提供(StreamableHTTPTransport で接続)。
  • 1 エントリ(src/cli.ts) で CLI / HTTP / stdio を切り替え。

コア: src/search.ts

OpenAI の responses.create を呼び出し、Web 検索ツールを有効化します。戻り値はモデルのテキスト出力です。

// src/search.ts(抜粋)
import OpenAI from "openai";
import type { Env } from "./env";

export async function runSearch(input: string, env: Env): Promise<string> {
  const client = new OpenAI({ apiKey: env.OPENAI_API_KEY! });
  const response = await client.responses.create({
    model: env.OPENAI_MODEL || "gpt-5",
    input,
    tools: [
      {
        type: "web_search_preview",
        user_location: { type: "approximate", timezone: env.TZ || "Asia/Tokyo" },
        search_context_size: env.SEARCH_CONTEXT_SIZE || "medium",
      },
    ],
    tool_choice: "auto",
    parallel_tool_calls: true,
    reasoning: { effort: env.REASONING_EFFORT || "low" },
    store: false,
  });
  return response.output_text ?? "No response text available.";
}

MCP サーバー: src/app.ts

検索ロジックを gpt-web-search という 1 ツールに束ね、MCP として公開します。

// src/app.ts(抜粋)
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import type { Env } from "./env";
import { runSearch } from "./search";

export const buildServer = (env: Env) => {
  const server = new McpServer({ name: "gpt-web-search", version: "0.0.1" });
  server.tool(
    "gpt-web-search",
    "Advanced web search agent.",
    { input: z.string().describe("Ask questions in natural language.") },
    async ({ input }) => {
      const text = await runSearch(input, env);
      return { content: [{ type: "text", text }] };
    }
  );
  return server;
};

HTTP エンドポイントと MCP: src/index.ts

Hono で /search と /mcp を提供します。/mcp は @hono/mcp の StreamableHTTPTransport で接続します。

// src/index.ts
import { Hono } from "hono";
import { StreamableHTTPTransport } from "@hono/mcp";
import { buildServer } from "./app";
import { runSearch } from "./search";

export interface Env {
  OPENAI_API_KEY: string
  SEARCH_CONTEXT_SIZE?: 'low' | 'medium' | 'high'
  REASONING_EFFORT?: 'low' | 'medium' | 'high'
  TZ?: string
}
const app = new Hono<{ Bindings: Env }>()

app.post("/search/:input", async (c) => {
  const input = c.req.param("input");
  if (!input) return c.text("Missing 'input'", 400);
  const text = await runSearch(input, c.env as Env);
  return c.text(text);
});

app.all("/mcp", async (c) => {
  const server = buildServer(c.env);
  const transport = new StreamableHTTPTransport();
  await server.connect(transport);
  return transport.handleRequest(c);
});

export default app;

実行エントリ: src/cli.ts

1 つのエントリで CLI / HTTP / stdio の 3 モードを切り替えます。hono-cli-adapter により、CLI コマンド(例: search)も同じ Hono アプリから提供できます。

// src/cli.ts
#!/usr/bin/env node
import { cli as honoCli } from 'hono-cli-adapter'
import { serve } from "@hono/node-server";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import app from "./index";
import { buildServer } from "./app";
import type { Env } from "./env";

const args = process.argv.slice(2);
const useStdio = args.includes("--stdio");
const isServe = args[0] === "serve" || args[0] === "server" || args.includes("--server") || args.includes("--http");
const port = Number(process.env.PORT || "9876");

if (useStdio) {
  (async () => {
    const server = buildServer(process.env as unknown as Env);
    const transport = new StdioServerTransport();
    await server.connect(transport);
  })().catch((e) => {
    console.error("stdio server failed:", e?.message || e);
    process.exit(1);
  });
} else if (isServe) {
  console.log(`HTTP mode: http://localhost:${port}/mcp`);
  serve({ port, fetch: app.fetch });
} else {
  await honoCli(app);
}

まとめ

Hono にアプリケーションロジックを集約し、HTTP / stdio / CLI の運び方は薄いアダプタに切り分けます。
これにより、同一コードをローカル、CLI 利用、Cloudflare Workers 配備まで一貫して使い回せます。
ぜひ試してみてください。

参考

Discussion