TL;DR
シリーズ記事
№ | 記事 | 内容 |
---|---|---|
1 | MCPサーバーの最小構成を作ってみる(Node.js版) | AIサービスのアカウントを持たなくてもMCPツールの開発ができます |
2 | 複数MCPツールを同時に利用可能なChatbotを最小構成で作ってみる(Google Gemini版) | Node.jsを使ってGeminiを利用したAI Agentの製作 |
3 | MCPサーバーの最小構成を作ってみる(Python版) | Coming Soon |
4 | 簡単なMCP利用可能なChatbotを作ってみる(Azure OpenAI版) | Coming Soon |
5 | RAGをMCPサーバーとして利用し、簡単な映画推薦Chatbotを作ってみる(Azure OpenAI版) | Coming Soon |
6 | MCPを活用してAI Agentで定期タスクを実行できるように | 本編 |
はじめに
以前書いた「MCPサーバーの最小構成を作ってみる(Node.js版) 」、および 複数MCPツールを同時に利用可能なChatbotを最小構成で作ってみる(Google Gemini版) では、Model Context Protocol(MCP)プロトコルを使って、対話型のAIエージェントの開発の仕方を紹介しました。
しかし、MCPサーバーは、GitHub Copilotや自作のGeminiエージェントなどLLMのClientから呼び出しがない限り、自らAI Agentに情報送信したり、所定の時刻で何かタスクを実行したりすることはできません。
もしMCPサーバーで sleep()
関数のようなもので回答を遅延させることで定期タスク機能を実装すれば、だいたい30秒~1分くらい(実装による)でクライアントがタイムアウトします。
しかし所定の時刻でタスクを実行したり、SNSなどを監視して投稿に反応したりすることこそ、AI Agentの醍醐味と思うので、以下のような実装を考案しました。
実装方法
Schedule MCP Server
AI Agent自分自身がMCPサーバーに変身して、LLMモデルが自分へ命令を下せるように作っています。
また、内部で通信する構造となっているので、あえてPortを指定しない(port=0)にして、その都度URLを発行します。
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import express from "express";
export class ScheduleMcpServer {
constructor({ llmCallback, port = 0 } = {}) {
this.llmCallback = llmCallback;
this.tasks = {};
this.transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // シングルトンなので、Sessionは不要
});
this.mcpServer = new McpServer({
name: "Scheduled Task MCP Server",
version: "1.0.0",
});
this.app = express();
this.app.use(express.json());
this.port = port; // ポート番号を初期化
this.url = "";
this.app.post("/mcp", async (req, res) => {
await this.transport.handleRequest(req, res, req.body);
});
this.expressServer = null;
}
async createTools() {
const server = this.mcpServer;
server.tool(
"register_task",
"実行プランを登録し、所定の時間(秒)後、Promptでコールバックを実行する。",
{
prompt: z.string(),
waitSecond: z.number().optional().default(60),
intervalFlag: z.boolean().optional().default(false),
},
async ({ prompt, waitSecond, intervalFlag }) => {
const taskName = `Task_${waitSecond}_${Date.now()}`;
if (!this.llmCallback) {
return {
content: [{ type: "text", text: "LLM callback function is not set." }],
};
}
if (this.tasks[taskName]) {
clearTimeout(this.tasks[taskName]); // 同名の実行プランが登録されている場合は破棄
}
const cb = async (prompt, waitSecond, intervalFlag) => {
await this.llmCallback(prompt, taskName);
if (intervalFlag) {
this.tasks[taskName] = setTimeout(async () => await cb(prompt, waitSecond, intervalFlag), waitSecond * 1000);
} else {
delete this.tasks[taskName]; // Clear the plan after execution
}
}
this.tasks[taskName] = setTimeout(async () => await cb(prompt, waitSecond, intervalFlag), waitSecond * 1000);
let responseText = `Execution plan ${taskName} has been registered and `;
responseText += intervalFlag ? "will repeat every" : "will execute after";
responseText += ` ${waitSecond} seconds.`;
return {
content: [{ type: "text", text: responseText }],
};
}
);
server.tool(
"cancel_task",
"登録済みの実行プランをキャンセルする",
{ taskName: z.string() },
async ({ taskName }) => {
console.log(`Cancelling event: ${taskName}`);
if (this.tasks[taskName]) {
clearTimeout(this.tasks[taskName]);
delete this.tasks[taskName];
}
return { content: [{ type: "text", text: `Task ${taskName} has been cancelled.` }] };
}
);
server.tool(
"list_tasks",
"登録済みの実行プランの一覧を表示する",
{},
async () => {
const taskNames = Object.keys(this.tasks);
let text = taskNames.map(taskName => `Scheduled Tasks: ${taskName}`).join("\n");
if (!text) {
text = "No scheduled tasks found.";
}
return {
content: [{ type: "text", text }],
};
}
);
}
async start(port = this.port) {
await this.createTools();
await this.mcpServer.connect(this.transport);
return new Promise((resolve, reject) => {
this.expressServer = this.app.listen(port, () => {
// 動的ポート番号を取得
this.port = this.expressServer.address().port;
this.url = `http://localhost:${this.port}/mcp`;
console.log(`Scheduled Task MCP Server is running on ${this.url}`);
resolve({ port: this.port, url: this.url });
}).on('error', (err) => {
console.error('Error starting MCP HTTP server:', err);
reject(err);
});
});
}
}
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
// const __dirname = path.dirname(__filename);
if (process.argv[1] === __filename) {
const server = new ScheduleMcpServer({
llmCallback: async (prompt, taskName) => {
console.log(`Executing task: ${taskName} with prompt: ${prompt}`);
// ここでLLMを呼び出す処理を実装
},
port: 3000, // 開発用にポートを固定
});
await server.start();
console.log(`MCP Server started at ${server.url}`);
process.on('SIGINT', async () => {
console.log("Shutting down MCP Server...");
await server.mcpServer.close();
server.expressServer?.close();
console.log("MCP Server shut down.");
process.exit(0);
});
}
MCP Inspectorではこんな感じでツールが登録されています。
Gemini Agent
上記 ScheduleMcpServer
をインスタンス化して、llmCallback
を自分自身にチャットをする関数で登録すれば、定期タスク機能は出来上がります。
import { GoogleGenAI, mcpToTool } from '@google/genai';
import { createInterface } from "readline/promises";
import dotenv from "dotenv";
dotenv.config(); // .envファイルを読み込む
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { ScheduleMcpServer } from "./schedule.mjs";
const scheduleMcpServer = new ScheduleMcpServer();
const { url: selfMcpUrl } = await scheduleMcpServer.start();
console.log(`MCP server is running at ${selfMcpUrl}`);
// MCPクライアントを作成
const selfMcpClient = new Client({
name: "event-mcp-client",
version: "1.0.0",
});
// MCPサーバーに接続
await selfMcpClient.connect(
new StreamableHTTPClientTransport(new URL(selfMcpUrl), {
sessionId: undefined, // シングルトンなので、Sessionは不要
})
);
const timeMcpClient = new Client({
name: "time-mcp-client",
version: "1.0.0",
});
// MCPサーバーに接続
await timeMcpClient.connect(
new StdioClientTransport({
command: "npx", args: ['-y', 'time-mcp'], // Demo用に時刻を取得するMCPサーバーを起動
})
);
// クライアントを設定
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
const chat = ai.chats.create({
model: "gemini-2.0-flash",
config: {
systemInstruction: "あなたはMCPを使って、定期でタスクを実行できます。",
tools: [ mcpToTool(selfMcpClient), mcpToTool(timeMcpClient) ],
},
});
// ユーザー入力用のreadlineインターフェースを作成
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
// 定期タスクが実行されるときのコールバック関数を設定
scheduleMcpServer.llmCallback = async (prompt, taskName) => {
console.log(`Executing task: ${taskName} with prompt: ${prompt}`);
const response = await chat.sendMessage({
role: "user",
message: { type: "text", text: prompt },
});
// console.log(`Response for task ${taskName}:`, response.text);
// Geminiの応答を表示
console.log(`Gemini:`, response.text, "\n\n");
// プロンプトを再度表示
rl.prompt();
}
// readlineインターフェースのプロンプトを設定
rl.setPrompt(`Waiting for chat input... \n> `);
rl.prompt();
rl.on("line", async (line) => {
const trimmedLine = line.trim();
// 入力が空の場合は再度プロンプトを表示
if (!trimmedLine) {
rl.prompt();
return;
}
// "exit"と入力された場合は終了
if (trimmedLine.toLowerCase() === 'exit') {
console.log("Exiting...");
// readlineインターフェースを閉じる
await closeAll();
process.exit(0);
}
// ユーザーの入力をGeminiに送信
const response = await chat.sendMessage({
role: "user",
message: { type: "text", text: trimmedLine },
});
console.log(`Gemini:`, response.text, "\n");
// プロンプトを再度表示
rl.prompt();
}).on("SIGINT", async () => {
rl.close();
console.log("Exiting chat...");
await closeAll();
process.exit(0);
});
async function closeAll() {
await Promise.all([
selfMcpClient.close(),
timeMcpClient.close(),
scheduleMcpServer.mcpServer.close(),
]).catch(console.error);
console.log("All clients closed.");
}
結果
ScheduleMcpServer
がない時、Geminiのデフォルトの反応
PS: Demo用にTime MCP Serverを使っていますが、Bugで問い合わせる際、フォーマットを指定しないと怒られる問題があります。(フィールドformat
は default: 'YYYY-MM-DD HH:mm:ss'
と設定しているが、required: ['format']
の設定の方が勝ってしまった。src/tools.ts)
$ node gemini-schedule-demo.mjs
Scheduled Task MCP Server is running on http://localhost:58256/mcp
MCP server is running at http://localhost:58256/mcp
Waiting for chat input...
> 現在時刻を`YYYY-MM-DD HH:mm:ss`で出力して
Gemini: 現在の時刻は2025-06-24 11:28:02です。
Waiting for chat input...
> 10秒後に現在時刻を`YYYY-MM-DD HH:mm:ss`で出力して
Gemini: 申し訳ありません。現在、10秒後にタスクを実行する機能はありません。
Waiting for chat input...
>
ScheduleMcpServer
が適用した後、Geminiの反応
$ node gemini-schedule-demo.mjs
Scheduled Task MCP Server is running on http://localhost:58107/mcp
MCP server is running at http://localhost:58107/mcp
Waiting for chat input...
> 現在時刻を`YYYY-MM-DD HH:mm:ss`で出力して
Gemini: 現在の時刻は2025-06-24 11:42:23(UTC)です。Asia/Tokyoでは2025-06-24 20:42:23です。
Waiting for chat input...
> 10秒後に現在時刻を`YYYY-MM-DD HH:mm:ss`で出力して
Gemini: 了解しました。10秒後に現在時刻を`YYYY-MM-DD HH:mm:ss`で出力するタスクを登録しました。
Waiting for chat input...
> Executing task: Task_10_1750765353763 with prompt: 現在時刻を`YYYY-MM-DD HH:mm:ss`で出力して
Gemini: 現在の時刻は2025-06-24 11:42:44(UTC)です。Asia/Tokyoでは2025-06-24 20:42:44です。
Waiting for chat input...
>
繰り返し実行タスクも登録できます
Waiting for chat input...
> 1分間おきに、現在時刻を`YYYY-MM-DD HH:mm:ss`で出力して
Gemini: 1分間おきに現在時刻を`YYYY-MM-DD HH:mm:ss`で出力するタスクを登録しました。
Waiting for chat input...
> Executing task: Task_60_1750765473140 with prompt: 現在時刻を`YYYY-MM-DD HH:mm:ss`で出力して
Gemini: 現在の時刻は2025-06-24 11:45:34(UTC)です。Asia/Tokyoでは2025-06-24 20:45:34です。
Waiting for chat input...
> Executing task: Task_60_1750765473140 with prompt: 現在時刻を`YYYY-MM-DD HH:mm:ss`で出力して
Gemini: 現在の時刻は2025-06-24 11:46:35(UTC)です。Asia/Tokyoでは2025-06-24 20:46:35です。
Waiting for chat input...
> 現在実行中のタスクをキャンセルして
Cancelling event: Task_60_1750765473140
Gemini: 現在実行中のタスク(Task_60_1750765473140)をキャンセルしました。
Waiting for chat input...
>
余談
上記実装方法に EventEmitterを組込みめば、SNS投稿などのEventが発火した際、AI Agentがそれに対して反応できるように作れます。
また、PythonなどEvent機能の実装が複雑な言語でも、無限ループでポーリングすれば、同等な実装が期待できます。
そしてChatGPTの新機能「Scheduled Tasks」などでも、活用できるかと思います。
最後、Googleが提唱する A2A プロトコルがもっと広がれば、もしかしたらもっと簡単な実装ができると思います。