server.mjs•25.8 kB
// ========================================
// 必要なモジュールのインポート
// ========================================
import express from 'express'; // Expressフレームワーク(Webサーバー構築用)
import cors from 'cors'; // CORS設定(クロスオリジンリクエスト許可)
import { randomUUID } from 'node:crypto'; // セッションID生成用
import { z } from 'zod'; // スキーマバリデーション
import dotenv from 'dotenv'; // 環境変数読み込み
import fetch from 'node-fetch'; // HTTP リクエスト送信
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; // MCPサーバー本体
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; // HTTPトランスポート
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; // 初期化リクエスト判定
import { InMemoryEventStore } from '@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js'; // イベントストア
import fs from 'fs'; // ファイルシステム操作
import path from 'path'; // パス操作
// ========================================
// ウィジェットHTMLファイルの読み込み
// ========================================
// 動画再生 + QRコード表示ウィジェット(本番用)
// React製動画生成ウィジェット
const videoQrReactHtml = fs.readFileSync('./web/src/video-qr-react.html', 'utf-8');
// ========================================
// 環境変数の読み込みとバリデーション
// ========================================
dotenv.config(); // .env ファイルから環境変数を読み込み
// 必須の環境変数チェック
const requiredEnvVars = ['MCP_API_KEY', 'OMOTENASHI_SESSION_TOKEN', 'BASE_API_URL'];
for (const envVar of requiredEnvVars) {
if (!process.env[envVar]) {
console.error(`Error: ${envVar} is not set in .env file`);
process.exit(1); // 必須変数がない場合は起動を中止
}
}
// 環境変数を定数に格納
const MCP_PORT = process.env.MCP_PORT ? parseInt(process.env.MCP_PORT, 10) : 8001; // MCPサーバーのポート番号(デフォルト: 8001)
const MCP_API_KEY = process.env.MCP_API_KEY; // ChatGPTからのアクセス認証用APIキー
const OMOTENASHI_SESSION_TOKEN = process.env.OMOTENASHI_SESSION_TOKEN; // おもてなしQR APIアクセス用トークン
const BASE_API_URL = process.env.BASE_API_URL; // おもてなしQR APIのベースURL
// サーバー起動時の設定を表示
console.log('=== MCP Server Configuration ===');
console.log(`Port: ${MCP_PORT}`);
console.log(`Base API URL: ${BASE_API_URL}`);
console.log(`API Key: ${MCP_API_KEY.substring(0, 10)}...`); // セキュリティのため一部のみ表示
console.log('================================\n');
// ========================================
// MCPサーバーインスタンスの作成
// ========================================
/**
* MCPサーバーを作成し、ツールとリソースを登録する
* @returns {McpServer} 設定済みのMCPサーバーインスタンス
*/
const createMcpServer = () => {
// MCPサーバーの初期化
const server = new McpServer(
{
name: 'omotenashi-mcp-server', // サーバー名
version: '1.0.0', // バージョン
},
{
capabilities: {
tools: {}, // ツール機能を有効化
logging: {}, // ロギング機能を有効化
resources: {}, // リソース機能を有効化
},
}
);
// ========================================
// ツール1: 動画完全生成ツール(本番用)
// ========================================
/**
* テキストから動画を生成し、QRコード付きで返すツール
* - 音声生成(15言語対応)
* - 動画生成(画像 + 音声)
* - ストレージアップロード
* - QRコード生成
* BGM、字幕、縦動画などの設定が可能
* 15言語対応の多言語動画生成
*/
server.registerTool(
'generate_video_react', // ツール名
{
// Widget表示の設定
_meta: {
"openai/outputTemplate": "ui://widget/video-qr-react.html", // React製Widget
"openai/toolInvocation/invoking": "動画を生成しています...",
"openai/toolInvocation/invoked": "動画生成完了",
"openai/widgetAccessible": true,
"openai/resultCanProduceWidget": true
},
// ツールの基本情報
title: '動画生成ツール(React UI版)',
description: `テキストから多言語動画を自動生成します。
【主な機能】
・15言語対応(日本語、英語、中国語、韓国語など)
・BGMの追加が可能
・字幕の表示・非表示を選択可能
・縦動画(スマホ向け1080x1920)に対応
・QRコード自動生成
・短縮URL生成
・React製の美しいUIで結果を表示
【使い方】
「〇〇という動画を作って」と指示するだけでOK。
BGMや字幕の有無、言語などを指定することもできます。`,
// 入力パラメータ
inputSchema: z.object({
text: z.string()
.describe('動画にするテキスト内容'),
language: z.enum([
'ja', 'en', 'zh', 'zh-TW', 'ko', 'th', 'es', 'it', 'fr', 'de', 'ru', 'ms', 'id', 'vi', 'fil'
])
.default('ja')
.describe('言語コード(デフォルト: 日本語)'),
use_bgm: z.boolean().default(false)
.describe('BGM(背景音楽)を使用するか。trueでBGM付き動画になります'),
use_subtitles: z.boolean().default(true)
.describe('字幕を表示するか。falseで字幕なし動画になります'),
use_vertical_video: z.boolean().default(false)
.describe('縦動画(1080x1920、スマホ向け)にするか。falseで横動画(1920x1080)になります'),
title: z.string().optional()
.describe('動画タイトル(省略時は自動生成)'),
description: z.string().optional()
.describe('動画説明文(省略時はデフォルト値)'),
tags: z.array(z.string()).optional()
.describe('動画タグ(配列)'),
is_public: z.boolean().default(true)
.describe('動画を公開するか'),
image_urls: z.array(z.string()).optional()
.describe('背景画像URL(複数対応・将来拡張用)'),
mcp_user_id: z.union([z.string(), z.number()]).default('38')
.describe('MCPユーザーID'),
}),
// 出力データ
outputSchema: z.object({
qrCode: z.string().describe('QRコードのBase64データURL'),
shortUrl: z.string().describe('短縮URL'),
videoUrl: z.string().describe('動画URL'),
title: z.string().describe('動画タイトル'),
description: z.string().describe('動画説明'),
language: z.string().describe('言語コード'),
durationSeconds: z.number().describe('音声の長さ(秒)'),
projectId: z.string().describe('プロジェクトID'),
useBgm: z.boolean().optional().describe('BGM使用フラグ'),
useSubtitles: z.boolean().optional().describe('字幕使用フラグ'),
useVerticalVideo: z.boolean().optional().describe('縦動画フラグ'),
}),
},
// ツールの実行ロジック(generate_complete_video と同じ)
async (args, extra) => {
try {
// 入力パラメータを展開
const { text, language, title, description, tags, is_public, use_bgm, use_subtitles, use_vertical_video, image_urls, mcp_user_id } = args;
// 処理開始ログをChatGPTに送信
await server.sendLoggingMessage(
{ level: 'info', data: 'Starting video generation (React UI): "' + text.substring(0, 50) + (text.length > 50 ? '...' : '') + '" (' + language + ')' },
extra.sessionId
);
// おもてなしQR APIへのリクエストを構築
const apiUrl = BASE_API_URL + '/mcp/generate-complete-video';
const requestBody = {
mcp_api_key: MCP_API_KEY,
text: text,
language: language,
title: title,
description: description,
tags: tags,
is_public: is_public,
use_bgm: use_bgm,
use_subtitles: use_subtitles,
use_vertical_video: use_vertical_video,
image_urls: image_urls,
mcp_user_id: String(mcp_user_id)
};
console.log('[REACT_VIDEO] API Request:', JSON.stringify(requestBody, null, 2));
// タイムアウト設定(120秒)
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 120000);
// おもてなしQR APIを呼び出し
const response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
signal: controller.signal,
}).finally(() => clearTimeout(timeout));
// APIエラーチェック
if (!response.ok) {
const errorText = await response.text();
console.log('[ERROR] React Video API:', response.status, errorText);
throw new Error('API request failed: ' + response.status + ' - ' + errorText);
}
// レスポンスをJSONとして解析
const data = await response.json();
if (!data.success) {
throw new Error('動画生成に失敗: ' + JSON.stringify(data));
}
const r = data.data;
console.log("[REACT_VIDEO] API Response:", JSON.stringify(r, null, 2));
await server.sendLoggingMessage(
{ level: 'info', data: 'Video generation succeeded! Project: ' + r.project_id + ', Short URL: ' + r.short_url },
extra.sessionId
);
// ChatGPTのチャット画面に表示するテキストメッセージを作成
const resultText = '🎬 動画生成完了!(React UI版)\n\n' +
'【動画情報】\n' +
'プロジェクトID: ' + r.project_id + '\n' +
'タイトル: ' + r.title + '\n' +
'言語: ' + r.language + '\n' +
'音声時間: ' + r.duration_seconds + '秒\n\n' +
'【設定】\n' +
'BGM: ' + (use_bgm ? 'あり 🎵' : 'なし') + '\n' +
'字幕: ' + (use_subtitles ? 'あり 📝' : 'なし') + '\n' +
'動画形式: ' + (use_vertical_video ? '縦動画 📱' : '横動画 🖥️') + '\n\n' +
'【アクセス】\n' +
'短縮URL: ' + r.short_url + '\n' +
'動画URL: ' + r.video_url + '\n\n' +
'React製の美しいUIで表示されます。QRコードをスキャンするとスマホで再生できます!';
// ChatGPTに返すデータ
return {
// チャット画面に表示されるテキスト
content: [
{ type: 'text', text: resultText }
],
// Widget に渡される構造化データ
structuredContent: {
qrCode: r.qr_code, // QRコードのBase64データ
shortUrl: r.short_url, // 短縮URL
videoUrl: r.video_url, // 動画URL
title: r.title, // タイトル
description: r.description, // 説明
language: r.language, // 言語
durationSeconds: r.duration_seconds, // 音声の長さ
projectId: r.project_id, // プロジェクトID
useBgm: use_bgm, // BGM使用フラグ(React UIで表示用)
useSubtitles: use_subtitles, // 字幕使用フラグ(React UIで表示用)
useVerticalVideo: use_vertical_video // 縦動画フラグ(React UIで表示用)
},
// Widget表示の設定
_meta: {
"openai/outputTemplate": "ui://widget/video-qr-react.html",
"openai/widgetAccessible": true,
"openai/resultCanProduceWidget": true
}
};
} catch (error) {
// エラーハンドリング
console.error('[ERROR] generate_video_react failed:', error);
await server.sendLoggingMessage(
{ level: 'error', data: 'Video generation failed: ' + error.message },
extra.sessionId
);
// タイムアウトエラーの場合
if (error.name === 'AbortError') {
return {
content: [{ type: 'text', text: '動画生成がタイムアウトしました(120秒超過)。処理は継続している可能性があります。\n\nエラー: ' + error.message }],
isError: true,
};
}
// その他のエラー
return {
content: [{ type: 'text', text: '動画生成エラー: ' + error.message + '\n\nStack: ' + error.stack }],
isError: true,
};
}
}
);
// ========================================
// Widget リソース登録
// ========================================
/**
* 動画再生 + QRコード表示 Widget のリソース登録
* generate_complete_video ツールで使用される
*/
/**
* React製動画生成 Widget のリソース登録
* generate_video_react ツールで使用される
*/
server.registerResource(
"video-qr-react",
"ui://widget/video-qr-react.html",
{
// CSP設定(外部リソースのアクセス許可)
"openai/widgetCSP": {
"resource_domains": [
"https://unpkg.com", // React & Babel の CDN
"https://cdn.omotenashiqr.com", // CDN
"https://omotenashiqr.com", // メインドメイン
"https://s3.isk01.sakurastorage.jp" // ストレージ
]
},
"openai/widgetPrefersBorder": true // Widget に枠線を表示
},
async () => ({
contents: [{
uri: "ui://widget/video-qr-react.html",
mimeType: "text/html+skybridge", // Widget 専用MIMEタイプ
text: videoQrReactHtml, // Widget HTML の内容
_meta: {
"openai/widgetDescription": "React製の美しいUI で動画とQRコードを表示するウィジェット",
"openai/widgetDomain": "https://chatgpt.com"
}
}]
})
);
// 設定済みのMCPサーバーを返す
return server;
};
// ========================================
// グローバルMCPサーバーインスタンス
// ========================================
// すべてのセッションで共有される単一のMCPサーバー
const globalMcpServer = createMcpServer();
// ========================================
// Expressアプリケーションのセットアップ
// ========================================
const app = express();
// ミドルウェア設定
app.use(express.json()); // JSONリクエストボディのパース
app.use(
cors({
origin: '*', // すべてのオリジンからのアクセスを許可
exposedHeaders: ['Mcp-Session-Id'], // セッションIDヘッダーを公開
})
);
// ========================================
// セッション管理
// ========================================
// セッションIDごとにトランスポートを管理
// { [sessionId: string]: StreamableHTTPServerTransport }
const transports = {};
// ========================================
// API KEY認証ミドルウェア
// ========================================
/**
* ChatGPTからのリクエストを認証する
* 初期化リクエスト以外は X-API-Key ヘッダーが必須
*/
const authenticateApiKey = (req, res, next) => {
// 初期化リクエストはAPI KEY不要(セッション確立のため)
if (req.body && isInitializeRequest(req.body)) {
return next();
}
// X-API-Key または Authorization ヘッダーからAPIキーを取得
const apiKey = req.headers['x-api-key'] || req.headers['authorization']?.replace('Bearer ', '');
if (!apiKey) {
return res.status(401).json({
jsonrpc: '2.0',
error: {
code: -32001,
message: 'Unauthorized: API key is required (X-API-Key header)',
},
id: null,
});
}
if (apiKey !== MCP_API_KEY) {
return res.status(403).json({
jsonrpc: '2.0',
error: {
code: -32002,
message: 'Forbidden: Invalid API key',
},
id: null,
});
}
next();
};
// ========================================
// MCP POSTエンドポイント(メインエンドポイント)
// ========================================
/**
* ChatGPTからのツール呼び出しやメッセージ送信を処理
* セッション管理とトランスポート処理を行う
*/
const mcpPostHandler = async (req, res) => {
// デバッグログ
console.log('[DEBUG] POST /mcp - Headers:', JSON.stringify(req.headers));
console.log('[DEBUG] POST /mcp - Body:', JSON.stringify(req.body));
const sessionId = req.headers['mcp-session-id'];
if (sessionId) {
console.log(`[MCP] Received request for session: ${sessionId}`);
} else if (isInitializeRequest(req.body)) {
console.log('[MCP] Received new initialization request');
} else {
console.log('[MCP] Received request without session ID');
}
try {
let transport;
// ケース1: 既存セッションのリクエスト
if (sessionId && transports[sessionId]) {
transport = transports[sessionId];
console.log(`[MCP] Reusing existing transport for session: ${sessionId}`);
}
// ケース2: 新規初期化リクエスト
else if (!sessionId && isInitializeRequest(req.body)) {
console.log('[MCP] Creating new transport for initialization');
// イベントストアとトランスポートを作成
const eventStore = new InMemoryEventStore();
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(), // ランダムなセッションIDを生成
eventStore,
onsessioninitialized: (sessionId) => {
console.log(`[MCP] Session initialized with ID: ${sessionId}`);
transports[sessionId] = transport; // セッションを保存
},
});
// トランスポートクローズ時のクリーンアップ
transport.onclose = () => {
const sid = transport.sessionId;
if (sid && transports[sid]) {
console.log(`[MCP] Transport closed for session ${sid}`);
delete transports[sid];
}
};
// グローバルMCPサーバーにトランスポートを接続
await globalMcpServer.connect(transport);
await transport.handleRequest(req, res, req.body);
return;
}
// ケース3: セッション消失エラー
else if (sessionId && req.body && req.body.method === 'tools/call') {
console.log('[ERROR] Session lost. Client needs to reinitialize.');
return res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32001,
message: 'Session expired. Please reinitialize the connection.',
},
id: req.body.id || null,
});
}
// ケース4: セッション再確立(現在は無効化)
else if (false) {
// サーバー再起動後などにセッションを自動再確立する処理
// 現在は false で無効化されている
console.log('[MCP] Recreating lost transport for session: ' + sessionId);
console.log('[MCP] This typically happens after server restart');
const eventStore = new InMemoryEventStore();
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => sessionId,
eventStore,
onsessioninitialized: (sid) => {
console.log('[MCP] Session re-initialized with ID: ' + sid);
transports[sid] = transport;
},
});
transport.onclose = () => {
if (transports[sessionId]) {
console.log('[MCP] Transport closed for session ' + sessionId);
delete transports[sessionId];
}
};
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
return;
}
// ケース5: 無効なリクエスト
else {
console.log('[ERROR] Invalid request - No session ID and not an initialize request');
console.log('[ERROR] Request body:', JSON.stringify(req.body));
return res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: No valid session ID provided or not an initialization request',
},
id: null,
});
}
// 既存トランスポートでリクエストを処理
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error('[MCP] Error handling request:', error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Internal server error',
data: error.message,
},
id: null,
});
}
}
};
// ========================================
// MCP GETエンドポイント(SSEストリーム用)
// ========================================
/**
* Server-Sent Events (SSE) でリアルタイムログやイベントを配信
* ChatGPTがサーバーからの通知を受け取るために使用
*/
const mcpGetHandler = async (req, res) => {
const sessionId = req.headers['mcp-session-id'];
if (!sessionId || !transports[sessionId]) {
return res.status(400).send('Invalid or missing session ID');
}
// 再接続の場合、Last-Event-ID を確認
const lastEventId = req.headers['last-event-id'];
if (lastEventId) {
console.log(`[MCP] Client reconnecting with Last-Event-ID: ${lastEventId}`);
} else {
console.log(`[MCP] Establishing new SSE stream for session ${sessionId}`);
}
const transport = transports[sessionId];
await transport.handleRequest(req, res);
};
// ========================================
// MCP DELETEエンドポイント(セッション終了用)
// ========================================
/**
* セッションを明示的に終了する
* ChatGPT側でセッションを閉じる際に呼ばれる
*/
const mcpDeleteHandler = async (req, res) => {
const sessionId = req.headers['mcp-session-id'];
if (!sessionId || !transports[sessionId]) {
return res.status(400).send('Invalid or missing session ID');
}
console.log(`[MCP] Received session termination request for session ${sessionId}`);
try {
const transport = transports[sessionId];
await transport.handleRequest(req, res);
} catch (error) {
console.error('[MCP] Error handling session termination:', error);
if (!res.headersSent) {
res.status(500).send('Error processing session termination');
}
}
};
// ========================================
// ルート設定
// ========================================
app.post('/mcp', mcpPostHandler); // ツール呼び出し、メッセージ送信
app.get('/mcp', mcpGetHandler); // SSEストリーム(リアルタイム通知)
app.delete('/mcp', mcpDeleteHandler); // セッション終了
// ========================================
// ヘルスチェックエンドポイント
// ========================================
/**
* サーバーの稼働状況を確認するエンドポイント
* 監視ツールやロードバランサーから呼ばれる
*/
app.get('/health', (req, res) => {
res.json({
status: 'ok',
server: 'omotenashi-mcp-server',
version: '1.0.0',
uptime: process.uptime(), // サーバー稼働時間(秒)
sessions: Object.keys(transports).length, // アクティブなセッション数
});
});
// ========================================
// サーバー起動
// ========================================
app.listen(MCP_PORT, (error) => {
if (error) {
console.error('[MCP] Failed to start server:', error);
process.exit(1);
}
console.log(`\n✓ MCP Server is running on port ${MCP_PORT}`);
console.log(` - POST endpoint: http://localhost:${MCP_PORT}/mcp`);
console.log(` - GET endpoint: http://localhost:${MCP_PORT}/mcp (SSE stream)`);
console.log(` - DELETE endpoint: http://localhost:${MCP_PORT}/mcp (session termination)`);
console.log(` - Health check: http://localhost:${MCP_PORT}/health`);
console.log('\nServer is ready to accept MCP connections.\n');
});
// ========================================
// シャットダウン処理
// ========================================
/**
* Ctrl+C でサーバーを停止した際のクリーンアップ
* すべてのアクティブなセッションを適切に終了する
*/
process.on('SIGINT', async () => {
console.log('\n[MCP] Shutting down server...');
// すべてのトランスポートをクローズ
for (const sessionId in transports) {
try {
console.log(`[MCP] Closing transport for session ${sessionId}`);
await transports[sessionId].close();
delete transports[sessionId];
} catch (error) {
console.error(`[MCP] Error closing transport for session ${sessionId}:`, error);
}
}
console.log('[MCP] Server shutdown complete');
process.exit(0);
});