🕸️

【 脱 GraphQL Playground 】GraphQL MCP を自作してみた

に公開

こんにちは。株式会社キカガクの tetsuro_b です。

キカガクでは 2025 年 4 月より Cursor を全エンジニアに導入しています。

そこで「Cursor を使いこなして、開発生産性上げていきたいぜ!」ということで、Cursor から呼び出せる GraphQL MCP サーバーを作ってみましたので紹介します。

なぜ GraphQL MCP サーバーなのか

昨今の LLM の進化により、コーディングにかける労力はどんどん減ってきていますが、それとは相反して 「テスト(QA)」の労力はほぼほぼ変わっていません。

例えばテストを進める中で「まずは 〇〇 の条件を満たしたユーザーを作りたい」というケースは多くありますが、 UI 上からポチポチやるのでは非効率な場面も存在します。
※ 同じ操作を何回も行わないとその条件を満たせない場合など。

GraphQL の場合は GraphQL Playground から Query, Mutation を実行することもできますが、実行都度 GraphQL Playground を立ち上げて、目的の Query, Mutation を探して...とやるのは少々手間だなと思っていました。

そこで GraphQL 自体を MCP サーバー化することで少しはこのあたりの操作が楽になるのでは?という考えで GraphQL MCP サーバーを自作してみることにしました。

今回作りたい&実現したいこととしては以下のイメージです。

実装

実装に関しては MCP のドキュメントの QuickStart を参考にしました。
実装について主要な部分(ツール)のみ触れていきます。

ツールの定義は server.tool で行います。

server.tool(
  'backend-graphql', // 第1引数
  'Execute a GraphQL query or mutation against the backend server.', // 第2引数
  {
    // 第3引数
    query: z.string().describe('The GraphQL query or mutation string.'),
    variables: z
      .record(z.string(), z.any())
      .optional()
      .describe(
        'An optional JSON object containing variables for the GraphQL query/mutation.',
      ),
  },
  async ({ query, variables }) => {
    // 第4引数
    // ...ツールの実行ロジック...
  },
);
  1. 第1引数 (ツールの識別子):

    • MCPサーバー内でツールを一意に識別するための名前(ID)です。クライアント(Cursor)はこの名前を使って特定のツールを呼び出します
  2. 第2引数 (ツールの説明):

    • ツールが何をするのかを人間が読んで理解できるように記述します。クライアント(Cursor)がツールの目的を把握するのに役立ちます。
  3. 第3引数 (入力スキーマ):

    • ツールが受け取る入力パラメータの構造、型、および説明を定義します。ここでは Zod ライブラリを使用してスキーマを定義しています。これにより、型安全性が保証され、クライアントにメタデータを提供できます。
  4. 第4引数 (ハンドラ関数):

    • クライアントからツールが呼び出されたときに、実際のロジックを実行する関数です。第3引数で定義されたスキーマに従って検証された入力パラメータを受け取り、結果をMCPが期待する形式で返却します。
実際に手元で動かしてみたい方へ、以下に実装の全コードを記載しています。
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';

const GRAPHQL_ENDPOINT = `${process.env.BACKEND_URL}/graphql`;

// Create server instance
const server = new McpServer({
  name: 'graphql-mcp',
  version: '1.0.0',
  capabilities: {
    resources: {},
    tools: {},
  },
});

async function makeGraphQLRequest<T>(
  query: string,
  variables?: Record<string, any>,
): Promise<T | null> {
  const headers = {
    'Content-Type': 'application/json',
    // TODO: 認証に必要な Header を追加
  };

  try {
    const response = await fetch(GRAPHQL_ENDPOINT, {
      method: 'POST',
      headers: headers,
      body: JSON.stringify({ query, variables }),
    });

    if (!response.ok) {
      const errorText = await response.text();
      throw new Error(
        `HTTP error! status: ${response.status}, body: ${errorText}`,
      );
    }
    return (await response.json()) as T;
  } catch (error) {
    console.error('Error making GraphQL request:', error);
    return null;
  }
}

server.tool(
  'backend-graphql',
  'Execute a GraphQL query or mutation against the backend server.',
  {
    query: z.string().describe('The GraphQL query or mutation string.'),
    variables: z
      .record(z.string(), z.any())
      .optional()
      .describe(
        'An optional JSON object containing variables for the GraphQL query/mutation.',
      ),
  },
  async ({ query, variables }) => {
    const result = await makeGraphQLRequest<any>(query, variables);

    if (!result) {
      return {
        content: [
          {
            type: 'text',
            text: 'Failed to execute GraphQL request or received an empty response.',
          },
        ],
      };
    }

    const responseText = JSON.stringify(result, null, 2);

    return {
      content: [
        {
          type: 'text',
          text: `GraphQL Response:\\n\`\`\`json\\n${responseText}\\n\`\`\``,
        },
      ],
    };
  },
);

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error('GraphQL MCP Server running on stdio');
}

main().catch((error) => {
  console.error('Fatal error in main():', error);
  process.exit(1);
});

実際に使ってみた

実装した MCP をさっそく Cursor に登録し使ってみました。

Query(ユーザーの認証情報の取得)

Mutation(ユーザーの新規招待)

Query, Mutation どちらも正常に Cursor Agent から実行できていることがわかります!

ただ実用化は難しそう...

色々 Query や Mutation を試してみましたが、正直なところ... GraphQL Playground などで実行したほうが早いし確実です!!🫠

その主な理由は以下の 2 点に集約されると考えられます。

  1. Cursor が Schema を読み込むのに時間がかかる
    Cursor Agent はツールを実行する際、まず リポジトリ内の GraphQL Schema を読み込み、ツールの引数 (query, variables) を生成しようとします。今回実装したツールは、それ自体に利用可能な Query や Mutation の詳細情報を持っていません。そのため、Cursor はリポジトリの Schema 情報をまずは読み込む必要が、ありそれに時間がかかってしまいます。

  2. 抽象的なツールのため Cursor が実装コードをみにいってしまう
    ツールが特定の GraphQL 操作に特化していない(=抽象的である)ため、Cursor はユーザーのプロンプトの意図を正確に理解しようとして、ツールの引数を生成するだけでなく、関連する可能性のある実装コード等まで参照しようとする ことがあります。これは特に、プロンプトの内容が複雑だったり、どの Query/Mutation を使うべきか自明でない場合に発生しやすいです。Resolver のコードを参照するプロセスはさらに時間を要し、必ずしも意図した Query, Mutation の実行に繋がらない可能性もありました

上記の挙動はいずれも、今回の MCP サーバーのツールに一切、Schema やドメイン的な具体的な情報を持たせていない という抽象的な実装方法が主な原因だと考えられます。

  • 今回作成したツールは、クライアント (Cursor Agent) から受け取った queryvariables をそのまま GraphQL エンドポイントに転送するだけの、非常に汎用的なものでした。
  • もしツール自体を、「ユーザー招待のためのツール (inviteUser)」「ユーザーの認証情報を取得するためのツール (getUserAuth)」のように 用途を具体化 していれば、Agent はツールの定義(特に引数のスキーマ)を見るだけで必要な情報を判断しやすくなり、リポジトリの Schema や Resolver の実装を広範囲に参照する必要性が減り、よりスムーズかつ高速に動作した可能性があります。

今後の MCP 開発にどう活かすか

今回が初めての MCP サーバーの開発でしたが、想定よりも遥かに実装が簡単でした。

直近だと「組織 x MCP」の事例として Ubie さんのデザインシステムの件が話題となっていましたが、GitHub MCP, Slack MCP などではなく、組織に既にあるシステムと LLM をつなげる MCP 開発に目線を向けると組織に順応した業務の効率化に繋げられるような気がします。

https://zenn.dev/ubie_dev/articles/f927aaff02d618

宣伝

株式会社キカガクではエンジニアを募集しています!!
少しでも興味がある方がいれば、まずはカジュアル面談でお話しましょう。

https://www.wantedly.com/projects/1040067

株式会社キカガク

Discussion