Skip to main content
Glama
server.mjs25.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); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/rakuai-support/mcp-omotenashi'

If you have feedback or need assistance with the MCP directory API, please join our Discord server