From 4739a1345812e353e0846f2a661274b203e745cd Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Tue, 28 Oct 2025 22:50:03 -0300 Subject: [PATCH] feat(api): integrate MCP tools support in Ollama chat route Add support for Model Context Protocol (MCP) tools in the Ollama chat API. Detect model capabilities to use native tools or prompt engineering. Handle tool calls, execute them via MCPExecutor, and inject results back into the conversation for seamless tool-assisted responses. This enhances chat functionality by enabling external tool integration. --- app/api/mcp/available-for-chat/route.ts | 58 +++++ app/api/mcp/execute-tool/route.ts | 89 +++++++ app/api/ollama/chat/route.ts | 225 +++++++++++++++++- components/chat/ChatContainer.tsx | 100 +++++++- components/chat/MCPSelector.tsx | 246 +++++++++++++++++++ database/ollahub-preferences.db-shm | Bin 32768 -> 32768 bytes database/ollahub-preferences.db-wal | Bin 177192 -> 193672 bytes database/ollahub.db-shm | Bin 32768 -> 32768 bytes database/ollahub.db-wal | Bin 1479112 -> 1788112 bytes database/repositories/mcp-active.ts | 156 +++++++++++++ database/repositories/mcp.ts | 32 +++ database/schema.sql | 14 ++ lib/ollama.ts | 17 +- lib/prompts/mcp-tools-prompt.ts | 195 ++++++++++++++++ lib/services/intent-detector.ts | 210 +++++++++++++++++ lib/services/mcp-executor.ts | 299 ++++++++++++++++++++++++ lib/services/model-capabilities.ts | 77 ++++++ lib/types/mcp-chat.ts | 74 ++++++ 18 files changed, 1782 insertions(+), 10 deletions(-) create mode 100644 app/api/mcp/available-for-chat/route.ts create mode 100644 app/api/mcp/execute-tool/route.ts create mode 100644 components/chat/MCPSelector.tsx create mode 100644 database/repositories/mcp-active.ts create mode 100644 lib/prompts/mcp-tools-prompt.ts create mode 100644 lib/services/intent-detector.ts create mode 100644 lib/services/mcp-executor.ts create mode 100644 lib/services/model-capabilities.ts create mode 100644 lib/types/mcp-chat.ts diff --git a/app/api/mcp/available-for-chat/route.ts b/app/api/mcp/available-for-chat/route.ts new file mode 100644 index 0000000..837a3fd --- /dev/null +++ b/app/api/mcp/available-for-chat/route.ts @@ -0,0 +1,58 @@ +/** + * API endpoint para listar MCPs disponíveis para uso no chat + */ + +import { NextResponse } from "next/server"; +import { MCPRepository } from "@/database/repositories/mcp"; +import { MCPExecutor } from "@/lib/services/mcp-executor"; +import type { MCPTool } from "@/lib/types/mcp-chat"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + // Buscar todos os MCPs instalados com status "ready" + const allMcps = MCPRepository.getAllInstalled(); + + const availableMcps = allMcps + .filter((mcp: any) => mcp.status === "ready") + .map((mcp: any) => { + const tools = MCPExecutor.getAvailableTools(mcp.id); + + // Converter tools para formato OpenAI function calling + const toolsFormatted = tools.map((tool: MCPTool) => ({ + type: "function" as const, + function: { + name: `${mcp.id}__${tool.name}`, // Prefixar com mcpId para evitar conflitos + description: tool.description, + parameters: tool.inputSchema, + }, + })); + + return { + mcpId: mcp.id, + name: mcp.name, + owner: mcp.owner, + repo: mcp.repo, + toolCount: tools.length, + tools: toolsFormatted, + rawTools: tools, // Tools originais para referência + }; + }); + + return NextResponse.json({ + success: true, + mcps: availableMcps, + count: availableMcps.length, + }); + } catch (error: any) { + console.error("Error listing available MCPs:", error); + return NextResponse.json( + { + success: false, + error: error.message || "Failed to list available MCPs", + }, + { status: 500 } + ); + } +} diff --git a/app/api/mcp/execute-tool/route.ts b/app/api/mcp/execute-tool/route.ts new file mode 100644 index 0000000..9370c2c --- /dev/null +++ b/app/api/mcp/execute-tool/route.ts @@ -0,0 +1,89 @@ +/** + * API endpoint para executar um tool de MCP + */ + +import { NextRequest, NextResponse } from "next/server"; +import { MCPExecutor } from "@/lib/services/mcp-executor"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { mcpId, toolName, parameters } = body; + + // Validar parâmetros + if (!mcpId || !toolName) { + return NextResponse.json( + { + success: false, + error: "Missing mcpId or toolName", + }, + { status: 400 } + ); + } + + console.log(`📞 API call to execute tool: ${mcpId}.${toolName}`); + + // Obter tools disponíveis + const availableTools = MCPExecutor.getAvailableTools(mcpId); + const tool = availableTools.find((t) => t.name === toolName); + + if (!tool) { + return NextResponse.json( + { + success: false, + error: `Tool ${toolName} not found in MCP ${mcpId}`, + }, + { status: 404 } + ); + } + + // Validar parâmetros contra schema + const validation = MCPExecutor.validateParameters(parameters || {}, tool); + if (!validation.valid) { + return NextResponse.json( + { + success: false, + error: `Invalid parameters: ${validation.error}`, + }, + { status: 400 } + ); + } + + // Executar tool + const result = await MCPExecutor.executeMCPTool( + mcpId, + toolName, + parameters || {} + ); + + if (!result.success) { + return NextResponse.json( + { + success: false, + error: result.error, + executionTime: result.executionTime, + }, + { status: 500 } + ); + } + + return NextResponse.json({ + success: true, + mcpId, + toolName, + result: result.result, + executionTime: result.executionTime, + }); + } catch (error: any) { + console.error("Error executing MCP tool:", error); + return NextResponse.json( + { + success: false, + error: error.message || "Failed to execute MCP tool", + }, + { status: 500 } + ); + } +} diff --git a/app/api/ollama/chat/route.ts b/app/api/ollama/chat/route.ts index 5b3b462..b21292e 100644 --- a/app/api/ollama/chat/route.ts +++ b/app/api/ollama/chat/route.ts @@ -1,20 +1,75 @@ import { NextRequest } from "next/server"; import { chatWithStream } from "@/lib/ollama"; import { convertToOllamaMessages } from "@/lib/chat"; +import { detectModelCapabilities } from "@/lib/services/model-capabilities"; +import { MCPExecutor } from "@/lib/services/mcp-executor"; +import { + generateToolsPrompt, + extractToolCallFromResponse, + formatToolResultForContext, +} from "@/lib/prompts/mcp-tools-prompt"; export const dynamic = "force-dynamic"; export async function POST(req: NextRequest) { try { - const { model, messages, options, system } = await req.json(); + const { model, messages, options, system, activeMcps } = await req.json(); if (!model || !messages?.length) { return new Response("Missing model or messages", { status: 400 }); } + // Detectar capabilities do modelo + const capabilities = detectModelCapabilities(model); + + // Preparar tools se MCPs ativos foram fornecidos + let tools: Array = []; + let toolsPrompt = ""; + const mcpToolsMap = new Map(); + + if (activeMcps && activeMcps.length > 0) { + console.log(`🔧 Active MCPs for chat: ${activeMcps.join(", ")}`); + + // Buscar tools de todos os MCPs ativos + for (const mcpId of activeMcps) { + const mcpTools = MCPExecutor.getAvailableTools(mcpId); + + for (const tool of mcpTools) { + const toolKey = `${mcpId}__${tool.name}`; + + if (capabilities.supportsNativeTools) { + // Formato OpenAI para modelos com suporte nativo + tools.push({ + type: "function", + function: { + name: toolKey, + description: tool.description, + parameters: tool.inputSchema, + }, + }); + } + + // Mapa para lookup rápido + mcpToolsMap.set(toolKey, { mcpId, toolName: tool.name }); + } + } + + // Se modelo não suporta native tools, usar prompt engineering + if (!capabilities.supportsNativeTools) { + const allTools = Array.from(mcpToolsMap.entries()).map( + ([key, { mcpId, toolName }]) => { + const mcpTools = MCPExecutor.getAvailableTools(mcpId); + return mcpTools.find((t) => t.name === toolName)!; + } + ); + toolsPrompt = generateToolsPrompt(allTools); + } + } + const stream = new ReadableStream({ async start(controller) { let isClosed = false; + let accumulatedResponse = ""; const safeEnqueue = (data: string) => { if (!isClosed) { @@ -40,17 +95,177 @@ export async function POST(req: NextRequest) { try { const ollamaMessages = convertToOllamaMessages(messages); + + // Merge system prompt com tools prompt + const finalSystemPrompt = toolsPrompt + ? `${system || ""}\n\n${toolsPrompt}` + : system; + + // Preparar parâmetros para chat + const chatParams = capabilities.supportsNativeTools ? { tools } : {}; + const response = await chatWithStream( model, ollamaMessages, options, - system + finalSystemPrompt, + capabilities.supportsNativeTools ? tools : undefined ); for await (const chunk of response) { - const token = chunk.message?.content || ""; - if (token) { - safeEnqueue(JSON.stringify({ token }) + "\n"); + // Verificar se há tool calls (modelos com suporte nativo) + if ( + chunk.message?.tool_calls && + chunk.message.tool_calls.length > 0 + ) { + console.log("🔧 Model requested tool calls (native)"); + + for (const toolCall of chunk.message.tool_calls) { + const toolKey = toolCall.function.name; + const toolInfo = mcpToolsMap.get(toolKey); + + if (toolInfo) { + // Executar tool + const result = await MCPExecutor.executeMCPTool( + toolInfo.mcpId, + toolInfo.toolName, + toolCall.function.arguments || {} + ); + + if (result.success) { + // Enviar indicador de execução de tool + safeEnqueue( + JSON.stringify({ + toolExecution: { + mcpId: toolInfo.mcpId, + toolName: toolInfo.toolName, + status: "success", + }, + }) + "\n" + ); + + // Injetar resultado no contexto e continuar + const resultMessage = formatToolResultForContext( + toolInfo.toolName, + result.result + ); + + // Fazer nova chamada ao modelo com o resultado + const updatedMessages = [ + ...ollamaMessages, + { + role: "assistant", + content: chunk.message.content || "", + }, + { role: "system", content: resultMessage }, + ]; + + const followUpResponse = await chatWithStream( + model, + updatedMessages, + options, + finalSystemPrompt, + capabilities.supportsNativeTools ? tools : undefined + ); + + for await (const followUpChunk of followUpResponse) { + const token = followUpChunk.message?.content || ""; + if (token) { + safeEnqueue(JSON.stringify({ token }) + "\n"); + } + } + } else { + console.error("Tool execution failed:", result.error); + } + } + } + } else { + // Streaming normal de tokens + const token = chunk.message?.content || ""; + if (token) { + accumulatedResponse += token; + safeEnqueue(JSON.stringify({ token }) + "\n"); + } + } + } + + // Se não há suporte nativo, verificar se resposta contém tool call via prompt engineering + if (!capabilities.supportsNativeTools && accumulatedResponse) { + const extractedToolCall = + extractToolCallFromResponse(accumulatedResponse); + + if (extractedToolCall) { + console.log("🔧 Model requested tool call (prompt engineering)"); + + // Encontrar MCP e tool + let foundToolInfo: { mcpId: string; toolName: string } | null = + null; + + for (const [key, info] of mcpToolsMap.entries()) { + if ( + extractedToolCall.name === info.toolName || + extractedToolCall.name === key + ) { + foundToolInfo = info; + break; + } + } + + if (foundToolInfo) { + // Executar tool + const result = await MCPExecutor.executeMCPTool( + foundToolInfo.mcpId, + foundToolInfo.toolName, + extractedToolCall.parameters + ); + + if (result.success) { + // Limpar resposta anterior (que continha o JSON) + safeEnqueue(JSON.stringify({ clearPrevious: true }) + "\n"); + + // Enviar indicador de execução + safeEnqueue( + JSON.stringify({ + toolExecution: { + mcpId: foundToolInfo.mcpId, + toolName: foundToolInfo.toolName, + status: "success", + }, + }) + "\n" + ); + + // Fazer nova chamada com resultado + const resultMessage = formatToolResultForContext( + foundToolInfo.toolName, + result.result + ); + + const updatedMessages = [ + ...ollamaMessages, + { + role: "assistant", + content: JSON.stringify({ + tool_call: extractedToolCall, + }), + }, + { role: "system", content: resultMessage }, + ]; + + const followUpResponse = await chatWithStream( + model, + updatedMessages, + options, + finalSystemPrompt + ); + + for await (const followUpChunk of followUpResponse) { + const token = followUpChunk.message?.content || ""; + if (token) { + safeEnqueue(JSON.stringify({ token }) + "\n"); + } + } + } + } } } diff --git a/components/chat/ChatContainer.tsx b/components/chat/ChatContainer.tsx index 5f3c976..544d3ae 100644 --- a/components/chat/ChatContainer.tsx +++ b/components/chat/ChatContainer.tsx @@ -1,10 +1,11 @@ "use client"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { ModelDropdown } from "@/components/model/ModelDropdown"; import { ModelPullDialog } from "@/components/model/ModelPullDialog"; import { ChatMessage } from "@/components/chat/ChatMessage"; import { SettingsModal } from "@/components/chat/SettingsModal"; +import { MCPSelector, ActiveMCPBadges } from "@/components/chat/MCPSelector"; import { Send, Square } from "lucide-react"; import type { ModelInfo } from "@/lib/models"; import type { Message } from "@/lib/chat"; @@ -37,6 +38,10 @@ export function ChatContainer({ const [systemPrompt, setSystemPrompt] = useState(""); const messagesEndRef = useRef(null); + // Estado para MCPs ativos + const [activeMcps, setActiveMcps] = useState([]); + const [mcpNames, setMcpNames] = useState>(new Map()); + // Carregar modelo salvo ao iniciar useEffect(() => { if (ready && prefs.selectedModel && !selectedModel) { @@ -61,6 +66,69 @@ export function ChatContainer({ update({ selectedModel: modelName }); // Salvar no Redis }; + // Carregar MCPs ativos do localStorage + useEffect(() => { + const saved = localStorage.getItem("ollahub-active-mcps"); + if (saved) { + try { + const parsed = JSON.parse(saved); + setActiveMcps(parsed.mcps || []); + setMcpNames(new Map(parsed.names || [])); + } catch (e) { + console.error("Error loading active MCPs:", e); + } + } + }, []); + + // Salvar MCPs ativos no localStorage quando mudarem + useEffect(() => { + if (activeMcps.length > 0 || mcpNames.size > 0) { + localStorage.setItem( + "ollahub-active-mcps", + JSON.stringify({ + mcps: activeMcps, + names: Array.from(mcpNames.entries()), + }) + ); + } + }, [activeMcps, mcpNames]); + + // Handlers para MCP Selector + const handleToggleMCP = useCallback( + async (mcpId: string) => { + setActiveMcps((prev) => { + if (prev.includes(mcpId)) { + return prev.filter((id) => id !== mcpId); + } else { + // Buscar nome do MCP se ainda não temos + if (!mcpNames.has(mcpId)) { + fetch("/api/mcp/available-for-chat") + .then((res) => res.json()) + .then((data) => { + if (data.success) { + const mcp = data.mcps.find((m: any) => m.mcpId === mcpId); + if (mcp) { + setMcpNames((prev) => new Map(prev).set(mcpId, mcp.name)); + } + } + }) + .catch(console.error); + } + return [...prev, mcpId]; + } + }); + }, + [mcpNames] + ); + + const handleClearAllMCPs = useCallback(() => { + setActiveMcps([]); + }, []); + + const handleRemoveMCP = useCallback((mcpId: string) => { + setActiveMcps((prev) => prev.filter((id) => id !== mcpId)); + }, []); + const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }; @@ -159,6 +227,7 @@ export function ChatContainer({ messages: messagesToSend, options, system: systemPrompt || undefined, + activeMcps: activeMcps.length > 0 ? activeMcps : undefined, }), signal: abortRef.current.signal, }); @@ -187,6 +256,21 @@ export function ChatContainer({ try { const data = JSON.parse(line); + + // Tool execution indicator + if (data.toolExecution) { + console.log( + `🔧 MCP Tool executed: ${data.toolExecution.mcpId}.${data.toolExecution.toolName}` + ); + // Opcional: Mostrar indicador visual + } + + // Clear previous response (para prompt engineering) + if (data.clearPrevious) { + assistantContent = ""; + setStreamingContent(""); + } + if (data.token) { assistantContent += data.token; setStreamingContent(assistantContent); @@ -411,7 +495,21 @@ export function ChatContainer({ {/* Input */}
+ {/* Active MCP Badges */} + +
+ {/* MCP Selector */} + +