index.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
  1. // In the Streamable HTTP transport, the server operates as an independent process that can handle multiple client connections. This transport uses HTTP POST and GET requests. Server can optionally make use of Server-Sent Events (SSE) to stream multiple server messages. This permits basic MCP servers, as well as more feature-rich servers supporting streaming and server-to-client notifications and requests.
  2. // The server MUST provide a single HTTP endpoint path (hereafter referred to as the MCP endpoint) that supports both POST and GET methods. For example, this could be a URL like https://example.com/mcp.
  3. import { randomUUID } from 'node:crypto';
  4. import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
  5. import {
  6. CallToolRequestSchema,
  7. ListToolsRequestSchema,
  8. ListResourcesRequestSchema,
  9. isInitializeRequest,
  10. } from "@modelcontextprotocol/sdk/types.js";
  11. import { Database } from "bun:sqlite";
  12. import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
  13. interface Exercicio {
  14. id: number;
  15. nome: string;
  16. grupo_muscular: string;
  17. series: number;
  18. repeticoes: number;
  19. intervalo_segundos: number;
  20. observacoes: string;
  21. }
  22. const filename_database = process.env.DB_PATH || "./academia.sqlite3";
  23. // Conexão com o Banco de Dados usando o SQLite nativo do Bun
  24. const db = new Database(filename_database);
  25. // Criar servidor MCP
  26. const mcpServer = new McpServer({
  27. name: "academia-mcp-server",
  28. version: "1.0.0",
  29. }, {
  30. capabilities: {
  31. tools: {},
  32. resources: {},
  33. }
  34. });
  35. // ==================== SESSION MANAGEMENT ====================
  36. // Storage de transports por sessionId para suportar múltiplas conexões
  37. const transports: { [sessionId: string]: WebStandardStreamableHTTPServerTransport } = {};
  38. // Função auxiliar para criar resposta de erro JSON-RPC
  39. function createJsonRpcError(code: number, message: string, id: string | number | null = null) {
  40. return {
  41. jsonrpc: "2.0",
  42. error: {
  43. code,
  44. message,
  45. },
  46. id,
  47. };
  48. }
  49. // Handler para listar os recursos (exercícios)
  50. mcpServer.server.setRequestHandler(ListResourcesRequestSchema, async () => {
  51. const query = db.query<Exercicio, []>(
  52. `SELECT id, nome, grupo_muscular, series, repeticoes, intervalo_segundos, observacoes
  53. FROM exercios_vw`
  54. );
  55. const exercicios = query.all();
  56. return {
  57. resources: exercicios.map((ex) => ({
  58. uri: `academia://exercicio/${ex.id}`,
  59. name: ex.nome,
  60. mimeType: "application/json",
  61. text: JSON.stringify(ex, null, 2),
  62. })),
  63. };
  64. });
  65. // Handler para listar todas as ferramentas disponíveis
  66. mcpServer.server.setRequestHandler(ListToolsRequestSchema, async () => {
  67. return {
  68. tools: [
  69. {
  70. name: "buscar_exercicios_por_grupo",
  71. description:
  72. "Busca exercícios filtrando por grupo muscular. Grupos disponíveis: 'Costas (dorsais, lombar)', 'Ombros (deltoides)', 'Pernas', 'Peito (peitoral)', 'Braços (Bíceps, Tríceps, Antebraço)'",
  73. inputSchema: {
  74. type: "object",
  75. properties: {
  76. grupo_muscular: {
  77. type: "string",
  78. description: "Nome do grupo muscular (ex: 'Pernas', 'Peito (peitoral)')",
  79. },
  80. },
  81. required: ["grupo_muscular"],
  82. },
  83. },
  84. {
  85. name: "listar_grupos_musculares",
  86. description: "Lista todos os grupos musculares disponíveis no banco de dados",
  87. inputSchema: {
  88. type: "object",
  89. properties: {},
  90. },
  91. },
  92. {
  93. name: "buscar_exercicio_por_nome",
  94. description: "Busca exercícios específicos por nome (busca parcial, case-insensitive)",
  95. inputSchema: {
  96. type: "object",
  97. properties: {
  98. nome: {
  99. type: "string",
  100. description: "Nome ou parte do nome do exercício (ex: 'agachamento', 'supino')",
  101. },
  102. },
  103. required: ["nome"],
  104. },
  105. },
  106. {
  107. name: "listar_todos_exercicios",
  108. description: "Lista todos os exercícios cadastrados no banco de dados",
  109. inputSchema: {
  110. type: "object",
  111. properties: {},
  112. },
  113. },
  114. {
  115. name: "obter_detalhes_exercicio",
  116. description: "Obtém detalhes completos de um exercício específico pelo ID",
  117. inputSchema: {
  118. type: "object",
  119. properties: {
  120. id: {
  121. type: "number",
  122. description: "ID do exercício",
  123. },
  124. },
  125. required: ["id"],
  126. },
  127. },
  128. ],
  129. };
  130. });
  131. // Handler para executar as ferramentas
  132. mcpServer.server.setRequestHandler(CallToolRequestSchema, async (request: unknown) => {
  133. const typedRequest = request as { params: { name: string; arguments: Record<string, unknown> } };
  134. const { name, arguments: args } = typedRequest.params;
  135. try {
  136. switch (name) {
  137. case "buscar_exercicios_por_grupo": {
  138. const { grupo_muscular } = args as { grupo_muscular: string };
  139. const query = db.query<Exercicio, [string]>(
  140. `SELECT id, nome, grupo_muscular, series, repeticoes, intervalo_segundos, observacoes
  141. FROM exercios_vw
  142. WHERE grupo_muscular LIKE ?`
  143. );
  144. const exercicios = query.all(`%${grupo_muscular}%`);
  145. if (exercicios.length === 0) {
  146. return {
  147. content: [
  148. {
  149. type: "text",
  150. text: `Nenhum exercício encontrado para o grupo muscular: ${grupo_muscular}`,
  151. },
  152. ],
  153. };
  154. }
  155. const resultado = exercicios.map(ex =>
  156. `**${ex.nome}**\n` +
  157. `- Séries: ${ex.series}\n` +
  158. `- Repetições: ${ex.repeticoes}\n` +
  159. `- Intervalo: ${ex.intervalo_segundos}s\n` +
  160. `- Observações: ${ex.observacoes}\n`
  161. ).join('\n');
  162. return {
  163. content: [
  164. {
  165. type: "text",
  166. text: `Encontrados ${exercicios.length} exercícios para ${grupo_muscular}:\n\n${resultado}`,
  167. },
  168. ],
  169. };
  170. }
  171. case "listar_grupos_musculares": {
  172. const query = db.query<{ grupo_muscular: string }, []>(
  173. `SELECT DISTINCT grupo_muscular FROM exercios_vw ORDER BY grupo_muscular`
  174. );
  175. const grupos = query.all();
  176. const lista = grupos.map(g => `- ${g.grupo_muscular}`).join('\n');
  177. return {
  178. content: [
  179. {
  180. type: "text",
  181. text: `Grupos musculares disponíveis:\n\n${lista}`,
  182. },
  183. ],
  184. };
  185. }
  186. case "buscar_exercicio_por_nome": {
  187. console.log('Executando ferramenta buscar_exercicio_por_nome');
  188. const { nome } = args as { nome: string };
  189. const query = db.query<Exercicio, [string]>(
  190. `SELECT id, nome, grupo_muscular, series, repeticoes, intervalo_segundos, observacoes
  191. FROM exercios_vw
  192. WHERE nome LIKE ?`
  193. );
  194. const exercicios = query.all(`%${nome}%`);
  195. if (exercicios.length === 0) {
  196. return {
  197. content: [
  198. {
  199. type: "text",
  200. text: `Nenhum exercício encontrado com o nome: ${nome}`,
  201. },
  202. ],
  203. };
  204. }
  205. const resultado = exercicios.map(ex =>
  206. `**ID ${ex.id}: ${ex.nome}**\n` +
  207. `- Grupo: ${ex.grupo_muscular}\n` +
  208. `- Séries: ${ex.series} x ${ex.repeticoes} repetições\n` +
  209. `- Intervalo: ${ex.intervalo_segundos}s\n` +
  210. `- Observações: ${ex.observacoes}\n`
  211. ).join('\n');
  212. return {
  213. content: [
  214. {
  215. type: "text",
  216. text: `Encontrados ${exercicios.length} exercício(s):\n\n${resultado}`,
  217. },
  218. ],
  219. };
  220. }
  221. case "listar_todos_exercicios": {
  222. const query = db.query<Exercicio, []>(
  223. `SELECT id, nome, grupo_muscular, series, repeticoes, intervalo_segundos, observacoes
  224. FROM exercios_vw
  225. ORDER BY grupo_muscular, nome`
  226. );
  227. const exercicios = query.all();
  228. // Agrupa por grupo muscular
  229. const porGrupo: Record<string, Exercicio[]> = {};
  230. exercicios.forEach(ex => {
  231. if (!porGrupo[ex.grupo_muscular]) {
  232. porGrupo[ex.grupo_muscular] = [];
  233. }
  234. const grupo = porGrupo[ex.grupo_muscular];
  235. if (grupo) {
  236. grupo.push(ex);
  237. }
  238. });
  239. const resultado = Object.entries(porGrupo).map(([grupo, exs]) =>
  240. `### ${grupo}\n` +
  241. exs.map(ex => `- ${ex.nome} (${ex.series}x${ex.repeticoes})`).join('\n')
  242. ).join('\n\n');
  243. return {
  244. content: [
  245. {
  246. type: "text",
  247. text: `Total de ${exercicios.length} exercícios cadastrados:\n\n${resultado}`,
  248. },
  249. ],
  250. };
  251. }
  252. case "obter_detalhes_exercicio": {
  253. const { id } = args as { id: number };
  254. const query = db.query<Exercicio, [number]>(
  255. `SELECT id, nome, grupo_muscular, series, repeticoes, intervalo_segundos, observacoes
  256. FROM exercios_vw
  257. WHERE id = ?`
  258. );
  259. const exercicio = query.get(id);
  260. if (!exercicio) {
  261. return {
  262. content: [
  263. {
  264. type: "text",
  265. text: `Exercício com ID ${id} não encontrado.`,
  266. },
  267. ],
  268. };
  269. }
  270. const detalhes =
  271. `# ${exercicio.nome}\n\n` +
  272. `**Grupo Muscular:** ${exercicio.grupo_muscular}\n` +
  273. `**Séries:** ${exercicio.series}\n` +
  274. `**Repetições:** ${exercicio.repeticoes}\n` +
  275. `**Intervalo:** ${exercicio.intervalo_segundos} segundos\n` +
  276. `**Observações:** ${exercicio.observacoes}`;
  277. return {
  278. content: [
  279. {
  280. type: "text",
  281. text: detalhes,
  282. },
  283. ],
  284. };
  285. }
  286. default:
  287. throw new Error(`Ferramenta desconhecida: ${name}`);
  288. }
  289. } catch (error) {
  290. return {
  291. content: [
  292. {
  293. type: "text",
  294. text: `Erro ao executar ${name}: ${error instanceof Error ? error.message : String(error)}`,
  295. },
  296. ],
  297. isError: true,
  298. };
  299. }
  300. });
  301. // ==================== HTTP HANDLERS ====================
  302. /**
  303. * Handler para POST /mcp - Processa requisições MCP (Initialize, Calls, etc)
  304. * Gerencia sessões via mcp-session-id header
  305. */
  306. async function handleMcpPost(req: Request): Promise<Response> {
  307. try {
  308. const sessionId = req.headers.get("mcp-session-id");
  309. let transport: WebStandardStreamableHTTPServerTransport;
  310. // Fazer clone do request para validação sem consumir o body
  311. const clonedReq = req.clone();
  312. let body: unknown;
  313. // Tentar fazer parse do body apenas para validação
  314. try {
  315. body = await clonedReq.json();
  316. } catch (e) {
  317. console.error("Failed to parse JSON request body:", e);
  318. return new Response(
  319. JSON.stringify(createJsonRpcError(-32700, "Parse error")),
  320. { status: 400, headers: { "Content-Type": "application/json" } }
  321. );
  322. }
  323. // Validar estrutura JSON-RPC
  324. if (typeof body !== "object" || body === null) {
  325. return new Response(
  326. JSON.stringify(createJsonRpcError(-32700, "Invalid Request: body must be JSON object")),
  327. { status: 400, headers: { "Content-Type": "application/json" } }
  328. );
  329. }
  330. const requestBody = body as Record<string, unknown>;
  331. const requestId = (requestBody.id as string | number | null) || null;
  332. // Se há sessionId existente, reutilizar transport
  333. if (sessionId && transports[sessionId]) {
  334. console.log(`[${sessionId}] Reusing existing transport`);
  335. transport = transports[sessionId];
  336. }
  337. // Se é initialize request, criar nova sessão
  338. else if (!sessionId && isInitializeRequest(requestBody)) {
  339. console.log("[NEW] Initialize request received, creating new session");
  340. transport = new WebStandardStreamableHTTPServerTransport({
  341. sessionIdGenerator: () => randomUUID(),
  342. onsessioninitialized: (newSessionId: string) => {
  343. console.log(`[${newSessionId}] Session initialized`);
  344. transports[newSessionId] = transport;
  345. },
  346. });
  347. // Conectar transport ao servidor MCP
  348. await mcpServer.connect(transport);
  349. }
  350. // Erro: nem sessionId válido nem initialize request
  351. else {
  352. console.error("Invalid request: no valid session ID or not an initialize request");
  353. return new Response(
  354. JSON.stringify(
  355. createJsonRpcError(
  356. -32000,
  357. "Invalid request: provide mcp-session-id header for existing sessions or send an initialize request",
  358. requestId
  359. )
  360. ),
  361. { status: 400, headers: { "Content-Type": "application/json" } }
  362. );
  363. }
  364. // Delegar ao transport para processar a requisição
  365. // Usar o request original (não clonado) para que o transport possa ler o body
  366. return transport.handleRequest(req);
  367. } catch (error) {
  368. console.error("Error handling MCP POST request:", error);
  369. return new Response(
  370. JSON.stringify(createJsonRpcError(-32603, "Internal server error")),
  371. { status: 500, headers: { "Content-Type": "application/json" } }
  372. );
  373. }
  374. }
  375. /**
  376. * Handler para GET /mcp - Estabelece stream SSE para notificações
  377. * Requer mcp-session-id header válido
  378. */
  379. async function handleMcpGet(req: Request): Promise<Response> {
  380. try {
  381. const sessionId = req.headers.get("mcp-session-id");
  382. // Validar session ID
  383. if (!sessionId) {
  384. console.error("GET request without mcp-session-id header");
  385. return new Response("Invalid or missing mcp-session-id header", {
  386. status: 400,
  387. headers: { "Content-Type": "text/plain" },
  388. });
  389. }
  390. if (!transports[sessionId]) {
  391. console.error(`[${sessionId}] Session not found`);
  392. return new Response(`Session ${sessionId} not found`, {
  393. status: 404,
  394. headers: { "Content-Type": "text/plain" },
  395. });
  396. }
  397. console.log(`[${sessionId}] Establishing SSE stream`);
  398. const transport = transports[sessionId];
  399. // Delegar ao transport para estabelecer SSE stream
  400. return transport.handleRequest(req);
  401. } catch (error) {
  402. console.error("Error handling MCP GET request:", error);
  403. return new Response("Internal server error", {
  404. status: 500,
  405. headers: { "Content-Type": "text/plain" },
  406. });
  407. }
  408. }
  409. // ==================== SERVER STARTUP ====================
  410. /**
  411. * Inicia o servidor MCP com suporte a Streamable HTTP
  412. * Implementa:
  413. * - POST /mcp para requisições JSON-RPC e gerenciamento de sessão
  414. * - GET /mcp para streams SSE
  415. * - GET /health para health check
  416. * - Graceful shutdown ao receber SIGINT/SIGTERM
  417. */
  418. async function main() {
  419. const port = process.env.PORT ? Number(process.env.PORT) : 3000;
  420. const server = Bun.serve({
  421. port,
  422. async fetch(req: Request) {
  423. const url = new URL(req.url);
  424. const pathname = url.pathname;
  425. const method = req.method;
  426. // Health check endpoint
  427. if (method === "GET" && pathname === "/health") {
  428. return new Response("OK", { status: 200 });
  429. }
  430. // MCP endpoint - conforme especificação Streamable HTTP
  431. if (pathname === "/mcp") {
  432. if (method === "POST") {
  433. return await handleMcpPost(req);
  434. } else if (method === "GET") {
  435. return await handleMcpGet(req);
  436. } else {
  437. return new Response("Method not allowed", { status: 405 });
  438. }
  439. }
  440. // 404 para rotas não encontradas
  441. return new Response(
  442. JSON.stringify({ error: "Not Found", path: pathname }),
  443. { status: 404, headers: { "Content-Type": "application/json" } }
  444. );
  445. },
  446. });
  447. console.log(`[SERVER] Servidor MCP de Academia iniciado`);
  448. console.log(`[SERVER] Endpoint HTTP/SSE disponível em http://localhost:${port}/mcp`);
  449. console.log(`[SERVER] Health check em http://localhost:${port}/health`);
  450. // ==================== GRACEFUL SHUTDOWN ====================
  451. const shutdown = async (signal: string) => {
  452. console.log(`\n[SHUTDOWN] Recebido sinal ${signal}, iniciando shutdown gracioso...`);
  453. // Fechar todas as sessões ativas
  454. const sessionIds = Object.keys(transports);
  455. console.log(`[SHUTDOWN] Fechando ${sessionIds.length} sessão(ões) ativa(s)...`);
  456. for (const sessionId of sessionIds) {
  457. try {
  458. const transport = transports[sessionId];
  459. if (transport) {
  460. console.log(`[SHUTDOWN] Fechando sessão ${sessionId}`);
  461. await transport.close?.();
  462. delete transports[sessionId];
  463. }
  464. } catch (error) {
  465. console.error(`[SHUTDOWN] Erro ao fechar sessão ${sessionId}:`, error);
  466. }
  467. }
  468. // Fechar servidor MCP
  469. try {
  470. console.log("[SHUTDOWN] Fechando servidor MCP...");
  471. await mcpServer.close?.();
  472. } catch (error) {
  473. console.error("[SHUTDOWN] Erro ao fechar servidor MCP:", error);
  474. }
  475. // Fechar servidor HTTP
  476. server.stop();
  477. console.log("[SHUTDOWN] Shutdown concluído. Servidor parado.");
  478. process.exit(0);
  479. };
  480. // Registrar handlers para SIGINT e SIGTERM
  481. process.on("SIGINT", () => shutdown("SIGINT"));
  482. process.on("SIGTERM", () => shutdown("SIGTERM"));
  483. return server;
  484. }
  485. // Iniciar servidor
  486. main().catch((error) => {
  487. console.error("[FATAL] Erro ao iniciar servidor:", error);
  488. process.exit(1);
  489. });