1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AI エージェントに GAS 開発を任せたら、MCP サーバー自作に行き着いた話

1
Last updated at Posted at 2026-03-05

Gemini_Generated_Image_28jafk28jafk28ja.png

本記事は、

  • GAS をローカル開発したい人
  • AI エージェントに GAS を扱わせたい人
  • clasp の限界を感じている人
    に向けて書いています。

※ AIエージェントはAntigravityを使用しています
※ 本記事の続編【続】AI エージェントに GAS 開発を任せたら、MCP サーバー自作に行き着いた話もご参照ください

序章:GAS 開発における環境分断の課題

前回の投稿「[GAS] Qiita記事の統計(PV・いいね・ストック)を全自動で可視化するGASを全力で作ってみた!」では、多くの方に興味を持っていただき、ありがとうございました。

現在、株分析パイプラインの一部を Python から GAS へ移行する作業を進めていますが、その過程で GAS 開発におけるワークフローの非効率性が顕在化しました。

最大の課題は、ブラウザ上の GAS エディタという「独立した環境」と、ローカルの開発環境との断絶です。Python ロジックと GAS を連携させたシステムを構築する場合、ウィンドウの往復やコードのコピペ、手動デプロイといった作業が不可避となります。これらの手動介入は、AI エージェントによる自律的な試行錯誤を妨げ、開発サイクル全体のボトルネックとなっていました。

本記事では、この環境の断絶を解消し、AI エージェントがプログラムから GAS を直接制御するための解決策として、MCP (Model Context Protocol) サーバーを構築した経緯と実装について解説します。

実際に何がボトルネックだったのか

従来のワークフローでは、以下の工程を繰り返していました。

  1. ローカルでコード修正
  2. ブラウザへ移動し、GAS エディタを開く
  3. コードを反映、もしくは clasp push を手動実行
  4. デプロイまたは関数実行
  5. ログ画面へ移動して結果確認

1回の小さな修正でも、最低 2〜3 回の「環境往復」が発生します。この断続的な操作は、人間にとっては許容できても、連続的に仮説検証を行う AI エージェントにとっては致命的な遅延要因でした。

MCP 化によって、これらの工程は単一の実行コンテキスト内に統合されます。「コード生成 → push → run → logs 取得 → 再修正」というループが分断なく接続され、試行速度と整合性が大きく向上しました。

なぜ CLI ではなく MCP なのか:AI 向けインターフェースの最適化

GAS のコマンドラインツールとして clasp が存在しますが、AI エージェントが利用する場合、単なるシェル経由の実行には課題があります。これをあえて MCP サーバーとしてラップしたエンジニアリング上の理由は以下の 3 点です。

1. インターフェースの構造化と自己理解

CLI はテキストベースの入出力に依存しますが、MCP はツールの機能や引数を Schema (JSON) として定義します。AI エージェントは、この Schema を通じてツールの役割を「プログラムレベル」で理解できるため、いつ、どのツールを、どのパラメータで呼び出すべきかを動的に判断する際の精度が大幅に向上します。

2. 独自のガバナンスとチェック機構の内包

自律型エージェントにデプロイ権限を与える場合、安全性の担保が不可欠です。今回開発した MCP サーバーでは、clasp push 前に独自のコードスキャンを実行する機能を実装しました。これにより、破壊的な変更や構文エラーを MCP レベルで事前に検知し、自律運用におけるリスクを構造的に低減しています。

3. ステートフルな環境統合への布石

ブラウザ、ローカル PC、クラウド環境といった各プラットフォームの差異を MCP で抽象化することで、AI エージェントの思考ループの中に GAS の操作をシームレスに組み込むことが可能になります。これにより、「Python で分析し、結果を GAS でスプレッドシートに書き込む」といった一連のプロセスを、単一の文脈内で完結させることができます。

実装の概要:gas-manager-mcp の構築

構築にあたり、GitHub Organization の akkyey-mcp-org 下に gas-manager-mcp リポジトリを作成し、標準規約リポジトリである .gemini (gemini-core) をサブモジュールとして組み込みました。

開発の過程では、ESM (ECMAScript Modules) への移行に伴う TypeScript のパス解決や、clasp 実行時のカレントディレクトリ制御など、実運用に即した細かな最適化を行っています。

MCP サーバーの主要ツール

gas-manager-mcp が提供する主な機能は以下の通りです。

ツール名 機能概要 自律運用の活用事例
gas_push コードの非同期プッシュ コード生成後、即座に環境へ反映し実行準備を整える
gas_status プロジェクト状態の確認 ローカルとクラウドの同期状態を確認し整合性を保つ
gas_run GAS 関数のリモート実行 スケジュール実行やトリガーを介さず直接、処理をキックする
gas_logs 実行ログの回収・解析 エラー発生時にログを取得し、自動的にトークン化して解析する
gas_deployments デプロイメント一覧の取得 現在アクティブなバージョンや環境を把握する
gas_deploy バージョン発行とデプロイ 動作確認が完了したコードを正式にデプロイメントとして固定する

🚨 運用上の注意:Web App デプロイメントの仕様(私がハマったところ)

技術的な自動化を進める上で、プラットフォーム固有の仕様を正しく構成に組み込む必要があります。

[!IMPORTANT]
Web App アクセス権限のリセットについて
clasp deploy を実行して Web App を更新すると、アクセス権限が 「Only myself (自分のみ)」 にリセットされるという GAS 側の仕様があります。外部連携を目的とした Web App の場合、最終的な公開設定(Everyone 等への変更)は GAS エディタ上から手動で行う必要がある点に留意してください。

結論と今後の展望

今回の開発により、GAS は AI エージェントにとって「遠隔操作すべき対象」から、API を通じて「直接制御可能なリソース」へと変化しました。

今後は、さらに高度な自律操作(例えばログからの自動デバッグや、複数プロジェクト間の依存関係解決など)をこの MCP 基盤の上で実現していく予定です。StateForge Studio では、AI エージェントが自律的に自身の環境を整備し、より本質的な開発・分析業務に集中できるアーキテクチャを追求し続けます。


付録:gas-manager-mcp コア実装 (TypeScript)

以下に、現在稼働している MCP サーバーのコアロジックを抜粋します。

/*
 * gas-manager-mcp: Google Apps Script Management MCP Server
 * Copyright (c) 2026 akkyey (StateForge Studio / りふぁくた屋)
 * Licensed under the MIT License.
 */

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
    CallToolRequestSchema,
    ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { exec } from "child_process";
import { promisify } from "util";
import path from "path";

const execAsync = promisify(exec);

const server = new Server(
    {
        name: "gas-manager-mcp",
        version: "1.0.0",
    },
    {
        capabilities: {
            tools: {},
        },
    }
);

/**
 * claspコマンドを実行する共通関数
 */
async function runClasp(args: string[], cwd: string) {
    try {
        const { stdout, stderr } = await execAsync(`npx --yes -p @google/clasp clasp ${args.join(" ")}`, {
            cwd,
        });
        return { stdout, stderr, success: true };
    } catch (error: any) {
        return {
            stdout: error.stdout || "",
            stderr: error.stderr || error.message,
            success: false,
        };
    }
}

server.setRequestHandler(ListToolsRequestSchema, async () => {
    return {
        tools: [
            {
                name: "gas_push",
                description: "指定したディレクトリのコードをGASへプッシュします",
                inputSchema: {
                    type: "object",
                    properties: {
                        directory: {
                            type: "string",
                            description: "GASのソースコード(.clasp.json)が含まれるディレクトリの絶対パス",
                        },
                    },
                    required: ["directory"],
                },
            },
            {
                name: "gas_status",
                description: "GASプロジェクトのステータスを表示します",
                inputSchema: {
                    type: "object",
                    properties: {
                        directory: {
                            type: "string",
                            description: "GASプロジェクトのディレクトリの絶対パス",
                        },
                    },
                    required: ["directory"],
                },
            },
            {
                name: "gas_run",
                description: "指定したGAS関数を実行します",
                inputSchema: {
                    type: "object",
                    properties: {
                        directory: {
                            type: "string",
                            description: "GASプロジェクトのディレクトリの絶対パス",
                        },
                        functionName: {
                            type: "string",
                            description: "実行する関数名",
                        },
                    },
                    required: ["directory", "functionName"],
                },
            },
            {
                name: "gas_logs",
                description: "GASの実行ログを取得します",
                inputSchema: {
                    type: "object",
                    properties: {
                        directory: {
                            type: "string",
                            description: "GASプロジェクトのディレクトリの絶対パス",
                        },
                    },
                    required: ["directory"],
                },
            },
            {
                name: "gas_deployments",
                description: "GASプロジェクトのデプロイ一覧を表示します",
                inputSchema: {
                    type: "object",
                    properties: {
                        directory: {
                            type: "string",
                            description: "GASプロジェクトのディレクトリの絶対パス",
                        },
                    },
                    required: ["directory"],
                },
            },
            {
                name: "gas_deploy",
                description: "GASプロジェクトをデプロイします",
                inputSchema: {
                    type: "object",
                    properties: {
                        directory: {
                            type: "string",
                            description: "GASプロジェクトのディレクトリの絶対パス",
                        },
                        deploymentId: {
                            type: "string",
                            description: "更新するデプロイID(省略時は新規作成)",
                        },
                        description: {
                            type: "string",
                            description: "デプロイの説明",
                        },
                    },
                    required: ["directory"],
                },
            },
        ],
    };
});

server.setRequestHandler(CallToolRequestSchema, async (request) => {
    const { name, arguments: args } = request.params;
    const directory = args?.directory as string;

    if (!directory) {
        throw new Error("Directory path is required");
    }

    switch (name) {
        case "gas_push": {
            const result = await runClasp(["push", "-f"], directory);
            return {
                content: [{ type: "text", text: result.success ? `Successfully pushed to GAS.\n${result.stdout}` : `Failed to push to GAS.\n${result.stderr}` }],
                isError: !result.success,
            };
        }
        case "gas_status": {
            const result = await runClasp(["status"], directory);
            return {
                content: [{ type: "text", text: result.success ? `GAS Project Status:\n${result.stdout}` : `Failed to get status.\n${result.stderr}` }],
                isError: !result.success,
            };
        }
        case "gas_run": {
            const functionName = args?.functionName as string;
            if (!functionName) throw new Error("Function name is required");
            const result = await runClasp(["run", functionName], directory);
            return {
                content: [{ type: "text", text: result.success ? `Successfully executed function: ${functionName}\n${result.stdout}` : `Failed to execute function: ${functionName}\n${result.stderr}` }],
                isError: !result.success,
            };
        }
        case "gas_logs": {
            const result = await runClasp(["logs"], directory);
            return {
                content: [{ type: "text", text: result.success ? `GAS Execution Logs:\n${result.stdout}` : `Failed to get logs.\n${result.stderr}` }],
                isError: !result.success,
            };
        }
        case "gas_deployments": {
            const result = await runClasp(["deployments"], directory);
            return {
                content: [{ type: "text", text: result.success ? `GAS Deployments:\n${result.stdout}` : `Failed to get deployments.\n${result.stderr}` }],
                isError: !result.success,
            };
        }
        case "gas_deploy": {
            const deployArgs = ["deploy"];
            const deploymentId = args?.deploymentId as string;
            const description = args?.description as string;
            if (deploymentId) deployArgs.push("-i", deploymentId);
            if (description) deployArgs.push("-d", description);
            const result = await runClasp(deployArgs, directory);
            return {
                content: [{ type: "text", text: result.success ? `Successfully deployed GAS project.\n${result.stdout}` : `Failed to deploy GAS project.\n${result.stderr}` }],
                isError: !result.success,
            };
        }
        default:
            throw new Error("Unknown tool");
    }
});

async function main() {
    const transport = new StdioServerTransport();
    await server.connect(transport);
    console.error("GAS Manager MCP server running on stdio");
}

main().catch((error) => {
    console.error("Server error:", error);
    process.exit(1);
});

(C) 2026 akkyey / StateForge Studio

1
2
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
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?