#!/usr/bin/env bun /** * Cliente MCP para validar conformidade Streamable HTTP * Testa: * - Inicialização de sessão * - Reutilização de sessionId * - Chamada de tools * - Listagem de recursos * - SSE streaming (GET) */ import { randomUUID } from 'node:crypto'; const SERVER_URL = 'http://localhost:3002/mcp'; const HEALTH_URL = 'http://localhost:3002/health'; let sessionId: string | null = null; // ==================== HELPERS ==================== /** * Fazer requisição POST para servidor MCP */ async function mcpRequest(method: string, params: Record = {}, id = 1) { const body = { jsonrpc: '2.0', id, method, params, }; const headers: Record = { 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream', }; // Adicionar sessionId se já foi inicializado if (sessionId) { headers['mcp-session-id'] = sessionId; } console.log(`\n📤 Request: ${method}`); if (Object.keys(params).length > 0) { console.log(' Params:', JSON.stringify(params).substring(0, 80) + '...'); } try { const response = await fetch(SERVER_URL, { method: 'POST', headers, body: JSON.stringify(body), }); // Extrair sessionId do header se presente const newSessionId = response.headers.get('mcp-session-id'); if (newSessionId && !sessionId) { sessionId = newSessionId; console.log(`✅ Nova sessão criada: ${sessionId}`); } // Verificar se é SSE ou JSON const contentType = response.headers.get('content-type') || ''; let data: unknown; if (contentType.includes('text/event-stream')) { // Processar SSE stream const text = await response.text(); // Parse SSE format: "event: message\ndata: {...}\n\n" const lines = text.trim().split('\n'); let jsonData = ''; for (const line of lines) { if (line.startsWith('data: ')) { jsonData = line.substring(6); break; } } if (jsonData) { data = JSON.parse(jsonData); } else { console.log(`❌ Erro: SSE stream sem data`); return null; } } else { // JSON direto data = await response.json(); } // Verificar se é erro JSON-RPC if (data && typeof data === 'object' && 'error' in data && data.error) { const error = data.error as { code: number; message: string }; console.log(`❌ Erro (code ${error.code}): ${error.message}`); return null; } // Sucesso console.log(`✅ Response recebida`); return data; } catch (error) { console.log(`❌ Erro na requisição:`, error instanceof Error ? error.message : error); return null; } } /** * Testar health check */ async function testHealth() { console.log('\n' + '='.repeat(60)); console.log('🏥 TEST 1: Health Check'); console.log('='.repeat(60)); try { const response = await fetch(HEALTH_URL); const text = await response.text(); if (response.status === 200 && text === 'OK') { console.log('✅ Server health check: OK'); return true; } else { console.log(`❌ Health check falhou: ${response.status} ${text}`); return false; } } catch (error) { console.log(`❌ Erro ao testar health:`, error instanceof Error ? error.message : error); return false; } } /** * Testar inicialização */ async function testInitialize() { console.log('\n' + '='.repeat(60)); console.log('🔧 TEST 2: Initialize (Criar Sessão)'); console.log('='.repeat(60)); const result = await mcpRequest('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test-client', version: '1.0.0', }, }); if (result && result.result) { console.log('✅ Servidor respondeu com result'); console.log(' Capabilities:', JSON.stringify(result.result.capabilities || {}).substring(0, 100)); console.log(' serverInfo:', result.result.serverInfo?.name || 'N/A'); return true; } return false; } /** * Testar listagem de tools */ async function testListTools() { console.log('\n' + '='.repeat(60)); console.log('🔨 TEST 3: List Tools'); console.log('='.repeat(60)); const result = await mcpRequest('tools/list'); if (result && result.result && Array.isArray(result.result.tools)) { console.log(`✅ Encontrados ${result.result.tools.length} tools`); result.result.tools.slice(0, 3).forEach((tool: Record) => { console.log(` - ${tool.name}`); }); if (result.result.tools.length > 3) { console.log(` ... e ${result.result.tools.length - 3} mais`); } return true; } return false; } /** * Testar chamada de tool */ async function testCallTool() { console.log('\n' + '='.repeat(60)); console.log('⚙️ TEST 4: Call Tool - listar_grupos_musculares'); console.log('='.repeat(60)); const result = await mcpRequest('tools/call', { name: 'listar_grupos_musculares', arguments: {}, }); if (result && result.result) { const content = result.result.content?.[0]; if (content && content.type === 'text') { const text = content.text as string; const lines = text.split('\n').slice(0, 5); console.log('✅ Tool executada com sucesso'); console.log(' Resposta (primeiras linhas):'); lines.forEach(line => { if (line.trim()) console.log(` ${line}`); }); return true; } } return false; } /** * Testar chamada de outra tool com argumentos */ async function testCallToolWithArgs() { console.log('\n' + '='.repeat(60)); console.log('⚙️ TEST 5: Call Tool - buscar_exercicio_por_nome'); console.log('='.repeat(60)); const result = await mcpRequest('tools/call', { name: 'buscar_exercicio_por_nome', arguments: { nome: 'supino', }, }); if (result && result.result) { const content = result.result.content?.[0]; if (content && content.type === 'text') { const text = content.text as string; console.log('✅ Tool executada com sucesso'); console.log(' Resposta (primeiras 3 linhas):'); text.split('\n').slice(0, 3).forEach(line => { if (line.trim()) console.log(` ${line}`); }); return true; } } return false; } /** * Testar listagem de recursos */ async function testListResources() { console.log('\n' + '='.repeat(60)); console.log('📚 TEST 6: List Resources'); console.log('='.repeat(60)); const result = await mcpRequest('resources/list'); if (result && result.result && Array.isArray(result.result.resources)) { console.log(`✅ Encontrados ${result.result.resources.length} recursos`); result.result.resources.slice(0, 3).forEach((resource: Record) => { console.log(` - ${resource.name} (${resource.uri})`); }); if (result.result.resources.length > 3) { console.log(` ... e ${result.result.resources.length - 3} mais`); } return true; } return false; } /** * Testar GET /mcp para SSE (requer sessionId) */ async function testSSEStream() { console.log('\n' + '='.repeat(60)); console.log('📡 TEST 7: SSE Stream (GET /mcp)'); console.log('='.repeat(60)); if (!sessionId) { console.log('⏭️ Pulando: sessionId não disponível'); return false; } const headers: Record = { 'Accept': 'text/event-stream', 'mcp-session-id': sessionId, }; try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 3000); // timeout 3s const response = await fetch('http://localhost:3002/mcp', { method: 'GET', headers, signal: controller.signal, }); clearTimeout(timeoutId); if (response.status === 200) { const contentType = response.headers.get('content-type'); console.log(`✅ SSE stream conectado`); console.log(` Content-Type: ${contentType}`); console.log(` mcp-session-id header: ${response.headers.get('mcp-session-id') || 'N/A'}`); // Stream pode fechar após timeout ou enviar dados, ambos são válidos return true; } else { console.log(`❌ Falha ao conectar SSE: ${response.status}`); return false; } } catch (error) { // Timeout ou desconexão é aceitável if (error instanceof Error && error.name === 'AbortError') { console.log(`✅ SSE stream manteve conexão até timeout (comportamento esperado)`); return true; } console.log(`⚠️ SSE stream desconectou (aceitável):`, error instanceof Error ? error.message : error); // Desconexão não significa falha - a conexão foi estabelecida return true; } } /** * Testar reutilização de sessionId */ async function testSessionReuse() { console.log('\n' + '='.repeat(60)); console.log('🔄 TEST 8: Session Reuse (reutilizar sessionId)'); console.log('='.repeat(60)); if (!sessionId) { console.log('⏭️ Pulando: sessionId não disponível'); return false; } console.log(`📤 Enviando request com sessionId existente: ${sessionId}`); const result = await mcpRequest('tools/list', {}, 99); if (result) { console.log('✅ sessionId reutilizado com sucesso'); return true; } return false; } /** * Testar erro com sessionId inválido */ async function testInvalidSession() { console.log('\n' + '='.repeat(60)); console.log('❌ TEST 9: Invalid Session (testar rejeição)'); console.log('='.repeat(60)); const fakeSessionId = randomUUID(); console.log(`📤 Enviando request com sessionId inválido: ${fakeSessionId}`); const body = { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {}, }; try { const response = await fetch(SERVER_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'mcp-session-id': fakeSessionId, }, body: JSON.stringify(body), }); const data = await response.json(); if (data.error && data.error.code === -32000) { console.log('✅ Servidor rejeitou sessionId inválido corretamente'); console.log(` Erro: ${data.error.message}`); return true; } else if (data.error) { console.log(`⚠️ Servidor retornou erro diferente: code ${data.error.code}`); return true; // Ainda é um erro, o que é esperado } } catch (error) { console.log(`❌ Erro:`, error instanceof Error ? error.message : error); return false; } return false; } // ==================== MAIN ==================== async function main() { console.log('\n' + '╔' + '═'.repeat(58) + '╗'); console.log('║' + ' '.repeat(10) + '🧪 TESTES DE CONFORMIDADE MCP' + ' '.repeat(20) + '║'); console.log('║' + ' '.repeat(8) + 'Streamable HTTP - Servidor Academia' + ' '.repeat(16) + '║'); console.log('╚' + '═'.repeat(58) + '╝'); const results: { test: string; passed: boolean }[] = []; // Executar testes em sequência results.push({ test: 'Health Check', passed: await testHealth() }); results.push({ test: 'Initialize', passed: await testInitialize() }); results.push({ test: 'List Tools', passed: await testListTools() }); results.push({ test: 'Call Tool (sem args)', passed: await testCallTool() }); results.push({ test: 'Call Tool (com args)', passed: await testCallToolWithArgs() }); results.push({ test: 'List Resources', passed: await testListResources() }); results.push({ test: 'SSE Stream', passed: await testSSEStream() }); results.push({ test: 'Session Reuse', passed: await testSessionReuse() }); results.push({ test: 'Invalid Session', passed: await testInvalidSession() }); // Resumo console.log('\n' + '='.repeat(60)); console.log('📊 RESUMO DOS TESTES'); console.log('='.repeat(60)); const passed = results.filter(r => r.passed).length; const total = results.length; results.forEach((r, i) => { const icon = r.passed ? '✅' : '❌'; console.log(`${icon} ${i + 1}. ${r.test}`); }); console.log('='.repeat(60)); console.log(`\n🎯 Resultado Final: ${passed}/${total} testes passaram`); if (passed === total) { console.log('🎉 Servidor está 100% conforme com Streamable HTTP!'); } else if (passed >= total - 1) { console.log('✅ Servidor está funcionando bem!'); } else { console.log('⚠️ Alguns testes falharam. Verifique os logs acima.'); } console.log(''); } main().catch(error => { console.error('❌ Erro fatal:', error); process.exit(1); });