Skip to main content
Glama

Omotenashi QR MCP Server

server.mjs16.1 kB
import express from 'express'; import cors from 'cors'; import { randomUUID } from 'node:crypto'; import { z } from 'zod'; import dotenv from 'dotenv'; import fetch from 'node-fetch'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import { InMemoryEventStore } from '@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js'; // 環境変数の読み込み dotenv.config(); // 環境変数のバリデーション 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; const MCP_API_KEY = process.env.MCP_API_KEY; const OMOTENASHI_SESSION_TOKEN = process.env.OMOTENASHI_SESSION_TOKEN; const BASE_API_URL = process.env.BASE_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サーバーのインスタンスを作成 */ const createMcpServer = () => { const server = new McpServer( { name: 'omotenashi-mcp-server', version: '1.0.0', }, { capabilities: { tools: {}, logging: {}, }, } ); /** * 音声生成ツール * おもてなしQRの既存API(/api/v2/video/generate-audio)を呼び出す */ server.registerTool( 'generate_audio', { title: '音声生成ツール', description: 'テキストから音声を生成します(おもてなしQR音声生成API)', inputSchema: z.object({ content: z.string().describe('音声化するテキスト内容'), language: z.enum(['ja', 'en', 'zh', 'ko']).default('ja').describe('言語 (ja, en, zh, ko)'), voice_speaker: z.string().default('Orus').describe('音声スピーカー名(例: Orus)'), voice_speed: z.number().min(0.5).max(2.0).default(1.0).describe('音声速度 (0.5-2.0)'), }), }, async ({ content, language, voice_speaker, voice_speed }, extra) => { try { await server.sendLoggingMessage( { level: 'info', data: `Starting audio generation: "${content.substring(0, 50)}${content.length > 50 ? '...' : ''}"`, }, extra.sessionId ); // 既存APIのエンドポイント const apiUrl = `${BASE_API_URL}/video/generate-audio`; // APIリクエストボディ const requestBody = { session_token: OMOTENASHI_SESSION_TOKEN, content: content, language: language, settings: { voice_speaker: voice_speaker, voice_speed: voice_speed, }, original_prompt: 'MCP Server経由', }; await server.sendLoggingMessage( { level: 'debug', data: `Calling API: ${apiUrl}`, }, extra.sessionId ); // リクエスト詳細をログ出力 console.log('[DEBUG] API Request:'); console.log(' URL:', apiUrl); console.log(' Body:', JSON.stringify(requestBody, null, 2)); // 既存APIを呼び出し const response = await fetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(requestBody), }); if (!response.ok) { const errorText = await response.text(); console.log('[ERROR] API Response:'); console.log(' Status:', response.status); console.log(' StatusText:', response.statusText); console.log(' Body:', errorText); throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`); } const data = await response.json(); // APIレスポンスの検証 if (!data.success) { throw new Error(`API returned error: ${JSON.stringify(data)}`); } // プロジェクトIDを取得 const projectId = data.data?.project_id; if (!projectId) { throw new Error('No project_id in response'); } await server.sendLoggingMessage( { level: 'info', data: `Audio generation started. Project ID: ${projectId}`, }, extra.sessionId ); // 🆕 音声ファイル完成を待つ(ステータスAPIポーリング) const maxAttempts = 60; // 最大60秒 const pollInterval = 1000; // 1秒ごと let audioFileUrl = null; let finalStatus = null; for (let attempt = 1; attempt <= maxAttempts; attempt++) { // ステータスAPIを呼び出し const statusUrl = `${BASE_API_URL}/video/project-status/${projectId}`; const statusResponse = await fetch(statusUrl, { method: 'GET', headers: { 'Content-Type': 'application/json', }, }); if (statusResponse.ok) { const statusData = await statusResponse.json(); if (statusData.success && statusData.data) { const status = statusData.data.status; finalStatus = status; // 音声完成を確認 if (status === 'audio_completed' && statusData.data.files?.audio) { const audioPath = statusData.data.files.audio; audioFileUrl = `https://omotenashiqr.com/${audioPath}`; await server.sendLoggingMessage( { level: 'info', data: `Audio completed after ${attempt} seconds: ${audioFileUrl}`, }, extra.sessionId ); break; } } } // まだ完成していない場合は待つ if (attempt < maxAttempts) { await new Promise(resolve => setTimeout(resolve, pollInterval)); } } if (!audioFileUrl) { throw new Error(`Audio file not ready after ${maxAttempts} seconds (status: ${finalStatus})`); } await server.sendLoggingMessage( { level: 'info', data: `Audio generation completed. Project ID: ${projectId}`, }, extra.sessionId ); // MCPクライアントに返すレスポンス return { content: [ { type: 'text', text: JSON.stringify( { success: true, project_id: projectId, status: 'audio_completed', audio_url: audioFileUrl, message: '音声生成が完了しました', }, null, 2 ), }, ], }; } catch (error) { await server.sendLoggingMessage( { level: 'error', data: `Error in generate_audio: ${error.message}`, }, extra.sessionId ); return { content: [ { type: 'text', text: JSON.stringify( { success: false, error: error.message, message: '音声生成中にエラーが発生しました', }, null, 2 ), }, ], isError: true, }; } } ); return server; }; /** * Expressアプリケーションのセットアップ */ const app = express(); // ミドルウェア app.use(express.json()); app.use( cors({ origin: '*', exposedHeaders: ['Mcp-Session-Id'], }) ); // トランスポートマップ(セッションIDごとに管理) const transports = {}; /** * API KEY認証ミドルウェア */ const authenticateApiKey = (req, res, next) => { // initializeリクエストはAPI KEY不要(セッション確立のため) if (req.body && isInitializeRequest(req.body)) { return next(); } 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エンドポイント */ 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; if (sessionId && transports[sessionId]) { // 既存のトランスポートを再利用 transport = transports[sessionId]; console.log(`[MCP] Reusing existing transport for session: ${sessionId}`); } else if (!sessionId && isInitializeRequest(req.body)) { // 新規初期化リクエスト console.log('[MCP] Creating new transport for initialization'); const eventStore = new InMemoryEventStore(); transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), 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サーバーに接続 const server = createMcpServer(); await server.connect(transport); await transport.handleRequest(req, res, req.body); return; } else if (sessionId && req.body && req.body.method === 'tools/call') { // セッションIDはあるが、サーバーが知らない場合(サーバー再起動後など) // 新しいトランスポートを自動作成してセッションを再確立 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, // 既存のセッションIDを再利用 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]; } }; // MCPサーバーに接続 const server = createMcpServer(); await server.connect(transport); await transport.handleRequest(req, res, req.body); return; } 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ストリーム用) */ 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'); } 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エンドポイント(セッション終了用) */ 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'); } } }; // ルート設定(認証なし - ChatGPT Desktop App互換) app.post('/mcp', mcpPostHandler); app.get('/mcp', mcpGetHandler); 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'); }); // シャットダウン処理 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); });

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