4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Qiitaトレンド取得用のローカルMCPサーバーを、Claudeとの対話だけでNode.jsで作った話

4
Last updated at Posted at 2026-07-28

はじめに

この記事は社内LT向けにまとめたものです。想定読者は「Claudeはまだあまり触ったことがない」方。細かい前提より、"会話だけでこんなものが作れる"という空気感が伝わることを狙っています。

最近の技術動向(主に生成AI・MCP・デザイン系・フロントエンド系)を効率よくキャッチアップしたくて、Qiitaのトレンド記事をピックアップする仕組みを作りました。構成は「読み取り専用のローカルMCPサーバー(qiita-trend)+ Claude Coworkの定期実行」です。

いきなり結論

スクリーンショット 2026-07-24 14.47.38.png

面白いのは、このMCPサーバーを1行も自分で書いておらず、Claude(Cowork)との対話だけで完成させたこと。要件の壁打ちからAPI調査、設計判断、実装、起動検証までチャットの中で進みました。

「MCPサーバーを作る」と聞くと身構えるかもしれませんが、実態は Claudeと相談しながら進めるだけ です。プログラミングに自信がなくても、"こういうものが欲しい"を言葉にできれば形になります。この記事では、そうして出来上がった機能がどう動くのかを、図とコードで紹介します(コードも読めなくて大丈夫、「これくらいの分量で済むんだ」という空気だけ感じてください)。

なお、MCPそのものの概念(そもそもMCPとは何か、ホスト/クライアント/サーバーの役割、メリット・リスク)については以前、社内勉強会向けで基礎編を書いています。本記事では概念解説は最小限にとどめるので、前提から知りたい方はそちらもどうぞ。

📖 チャットでサービス連携!MCP基礎編 🐌

基礎編では、MCPを「AI用のUSB-Cポート」に例えて、AIと外部システムをつなぐ共通ルールとして解説しています。本記事はその実践編にあたる位置づけで、実際にローカルMCPサーバーを1つ作って動かすところまでを扱います。

今回作った機能の全体像

やりたかったのはシンプルで、「Qiitaから直近の人気記事を、関心キーワードで絞って取ってくる」機能です。これをMCPサーバーの1ツールとして実装しました。

基礎編の言葉で言うと、Claude Desktopがホスト、その中のCoworkがクライアント、自作の qiita-trend がサーバーという関係です。今回はこのサーバーを「自分のPC上のローカルプログラム」として動かしています。

最初に作ったツールは search_trend の1つだけです。あとから全体トレンド把握用の popular_tags を足しました。

ツール 役割
search_trend 指定キーワードで直近N日・最低ストック数以上の記事を検索し、重複排除してLGTM数の多い順に上位を返す 初期実装
popular_tags Qiita全体で記事数の多い人気タグ一覧を取得する(全体トレンドの把握用) 後から追加

ファイル構成は3つだけ。

qiita-trend/
├── index.js       # MCPサーバー本体
├── package.json   # 依存(@modelcontextprotocol/sdk, zod)
└── README.md      # セットアップ手順

このフォルダは、Claude Desktopから参照できる場所に置いておきます。手元では作業用フォルダにまとめて配置しています。

/Users/<あなたのPCユーザー名>/Documents/works/ClaudeDT/   # ← ここは自分の環境に合わせて変える
├── qiita-trend/   # 今回のMCPサーバー
└── log/           # 次回記事で扱う「良かった記事ログ」

<あなたのPCユーザー名> の部分や配置先は各自の環境に合わせて読み替えてください(後述の設定ファイルやログのパスも同様です)。

なぜ「取得のみ」なのに、あえてMCPにしたのか

最初は「読み取りのみだから安全そう、MCPもありかな」と考えていました。ですがClaudeと整理してみると、安全なのは"読み取りのみ"だからであって、MCPだから安全というわけではないんですよね。素のスクリプトでGETするのもMCP経由も、読み取り専用なら危険度は同じくらい低い。

これは基礎編で書いた「MCPそれ自体にセキュリティ機能があるわけではない」という話とも一致します。だから「安全だからMCP」ではなく「どう使いたいか」で選ぶことにしました。

決め手は Coworkの定期実行と組み合わせて、取得から要約まで会話で任せたかったから。素のスクリプトなら自分でデータを読む必要がありますが、MCPにすればClaudeが取得・選別・要約まで一気に面倒を見てくれます。

Qiitaから「トレンド」を取る3つの選択肢

作る前にClaudeに最新のAPI事情を調べてもらったところ、公式APIには「トレンド」専用エンドポイントがなく、取り方は大きく3つありました。

  1. 公式Qiita API v2: タグやストック数の条件で検索し、人気記事を近似する。安定していて絞り込みと相性が良い
  2. 非公式のトレンドAPI: 本当にいまバズってる記事が取れるが、非公式なので将来動かなくなるリスクがある
  3. RSS: 新着追跡はシンプルだが、LGTM数が取れず人気度の判定に弱い

長く安定して回したかったので 公式API v2 を選択しました。「本物のトレンド」ではなく「直近で人気の記事」の近似になりますが、動向キャッチアップという目的には十分です。

機能の中身: search_trend はどう動くか

このツールの動作を順に追います。ポイントは3つです。

  1. キーワードごとに1リクエストへ分割する(条件を1クエリに詰め込むとQiita側がエラーを返すことがあるため)
  2. 検索の絞り込みには、Qiita検索で安定して効く stocks:>=N(ストック数) を使う
  3. 並べ替えには、レスポンスに含まれる likes_count(LGTM数) を使う

「絞り込みはストック数、ランキングはLGTM数」と指標を役割で分けているのがこだわりです。

コードで見る

土台: サーバー初期化とAPIクライアント

MCP SDKの McpServer + StdioServerTransport の最小構成です。トークンは環境変数から任意で読み、あればヘッダに付けるだけ。

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const QIITA_API = "https://qiita.com/api/v2";
const TOKEN = process.env.QIITA_TOKEN;

async function qiitaGet(path, params = {}) {
  const url = new URL(`${QIITA_API}${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
  const headers = { Accept: "application/json" };
  if (TOKEN) headers.Authorization = `Bearer ${TOKEN}`;
  const res = await fetch(url, { headers });
  if (!res.ok) throw new Error(`Qiita API ${res.status}: ${await res.text()}`);
  return res.json();
}

const server = new McpServer({ name: "qiita-trend", version: "1.0.0" });

本体: search_trend

図で示した「キーワードごとに取得 → 重複排除 → LGTM降順」がそのままコードに対応します。

server.tool(
  "search_trend",
  "指定キーワードで直近のQiita人気記事を取得する(読み取り専用)",
  {
    keywords: z.array(z.string()).min(1).describe("検索キーワード(例: 生成AI, MCP)"),
    days: z.number().int().min(1).max(30).default(7).describe("遡る日数"),
    minStocks: z.number().int().min(0).default(5).describe("最低ストック数"),
    limit: z.number().int().min(1).max(20).default(10).describe("返す件数"),
  },
  async ({ keywords, days, minStocks, limit }) => {
    const since = new Date(Date.now() - days * 86400_000)
      .toISOString()
      .slice(0, 10);

    const seen = new Map();
    for (const kw of keywords) {
      const query = `${kw} created:>=${since} stocks:>=${minStocks}`;
      const items = await qiitaGet("/items", { query, per_page: 20 });
      for (const item of items) seen.set(item.id, item); // 記事IDで重複排除
    }

    const top = [...seen.values()]
      .sort((a, b) => b.likes_count - a.likes_count)
      .slice(0, limit)
      .map((i) => ({
        title: i.title,
        url: i.url,
        likes: i.likes_count,
        stocks: i.stocks_count,
        tags: i.tags.map((t) => t.name),
        created_at: i.created_at,
      }));

    return { content: [{ type: "text", text: JSON.stringify(top, null, 2) }] };
  }
);

返り値を整形済みJSONにしているのは、受け手がClaudeだから。人間向けの装飾を頑張るより、構造化された素のデータを返してLLM側に要約・整形させたほうが柔軟でした。

補助: popular_tags(後から追加)

初期実装は search_trend だけでしたが、運用しはじめてから「キーワードに依存せず、いまQiita全体で何が多いか」も見たくなり、あとから足したのがこのツールです。

server.tool(
  "popular_tags",
  "Qiita全体で記事数の多い人気タグ一覧を取得する(読み取り専用)",
  {
    limit: z.number().int().min(1).max(50).default(20).describe("返すタグ数"),
  },
  async ({ limit }) => {
    const tags = await qiitaGet("/tags", { sort: "count", per_page: limit });
    const result = tags.map((t) => ({
      name: t.id,
      items_count: t.items_count,
      followers_count: t.followers_count,
    }));
    return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
  }
);

const transport = new StdioServerTransport();
await server.connect(transport);

Claude Desktopへの登録

claude_desktop_config.json に登録して再起動すれば使えます。トークンは任意で、使う場合も read_qiita スコープのみでOKです。基礎編で触れた通り、設定ファイルにはトークンが入りうるので外部公開しないよう注意します。

{
  "mcpServers": {
    "qiita-trend": {
      "command": "node",
      "args": ["/Users/<あなたのPCユーザー名>/Documents/works/ClaudeDT/qiita-trend/index.js"],
      "env": {
        "QIITA_TOKEN": "read_qiitaスコープのみのトークン(任意)"
      }
    }
  }
}

Claudeとの対話だけで作るとどうなるか

開発は次の流れで、すべてチャット上で進みました。

  1. 要件の壁打ち: 「Qiitaのトレンドをピックアップしたい」から始め、Claudeが「トレンドの定義は?」「絞り込みは?」と選択肢付きで質問してくる
  2. API調査: Qiita API v2のクエリ仕様・レート制限・非公式APIの存在をClaudeがWeb検索で確認し、3択に整理
  3. 設計判断: 「スクリプトかMCPか」の比較をClaudeが率直に提示(「取得のみならスクリプトが軽い」と正直に言ってくる)。Cowork定期実行との組み合わせで最終判断
  4. 実装: MCP SDKの最新仕様とQiitaの検索記法を確認したうえでコード生成
  5. 検証: stdioでの簡易ハンドシェイク(initialize → tools/list)まで自動で実施し、ツール登録を確認

印象的だったのは、Claudeができていないことを正直に報告してきた点です。開発に使ったサンドボックスはqiita.comへ通信できない設定だったため、「起動・ツール登録・スキーマは確認済みだが、実際に記事が返るかのライブ確認は未実施。手元で疎通確認してほしい」と明示され、READMEに手順が入りました。

セットアップと動作確認

  1. npm install
  2. claude_desktop_config.jsonqiita-trend を登録し、Claude Desktopを再起動
  3. Coworkで「qiita-trend の search_trend で直近4日の上位5件を見せて」と頼み、記事が返れば疎通OK

未認証だとIPあたり1時間60リクエストの制限があるので、毎日回すならトークン発行が安心です。

まとめ

  • 「トレンド」の定義を先に決めると、データソース選定がぶれない
  • 機能の肝は「絞り込み=ストック数 / ランキング=LGTM数」と「キーワードごとに分割して重複排除」
  • MCPは「安全だから」ではなく「AIに取得+要約まで任せたいか」で選ぶ(基礎編の"MCP自体は安全装置ではない"とも一致)
  • 実装をAIに任せるなら、「確認済み/未確認」を区別して報告してくれるかが信頼の分かれ目

次回は、このMCPをCoworkの定期タスクにつなぎ、「良かった記事をログに残して好みを学習させる」仕組みに育てた話を書きます。

▶︎続きはこちら

MCP紹介記事

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?