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.
This commit is contained in:
1 parent
745746afa9
commit
4739a13458
18 files changed
+1782
-10
No files matched your search
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Serviço para detectar intenções do usuário através de palavras-chave
|
||||
*/
|
||||
|
||||
import type { DetectedIntent } from "@/lib/types/mcp-chat";
|
||||
|
||||
/**
|
||||
* Padrões de keywords para cada tipo de intent
|
||||
*/
|
||||
const INTENT_PATTERNS = {
|
||||
web_search: {
|
||||
keywords: [
|
||||
"buscar",
|
||||
"pesquisar",
|
||||
"procurar",
|
||||
"google",
|
||||
"encontrar",
|
||||
"search",
|
||||
"look up",
|
||||
"find",
|
||||
"o que é",
|
||||
"quem é",
|
||||
"onde fica",
|
||||
],
|
||||
regex:
|
||||
/\b(buscar|pesquisar|procurar|google|encontrar|search|look up|find)\b/i,
|
||||
},
|
||||
time: {
|
||||
keywords: [
|
||||
"que horas",
|
||||
"data",
|
||||
"horário",
|
||||
"hoje",
|
||||
"agora",
|
||||
"quando",
|
||||
"calendário",
|
||||
"time",
|
||||
"date",
|
||||
"current time",
|
||||
"what time",
|
||||
],
|
||||
regex:
|
||||
/\b(que horas|data|horário|hoje|agora|quando|time|date|current time)\b/i,
|
||||
},
|
||||
weather: {
|
||||
keywords: [
|
||||
"clima",
|
||||
"tempo",
|
||||
"temperatura",
|
||||
"chuva",
|
||||
"previsão",
|
||||
"weather",
|
||||
"forecast",
|
||||
"temperature",
|
||||
],
|
||||
regex:
|
||||
/\b(clima|tempo|temperatura|chuva|previsão|weather|forecast|temperature)\b/i,
|
||||
},
|
||||
file: {
|
||||
keywords: [
|
||||
"arquivo",
|
||||
"pasta",
|
||||
"diretório",
|
||||
"file",
|
||||
"folder",
|
||||
"directory",
|
||||
"ler arquivo",
|
||||
"read file",
|
||||
"listar",
|
||||
"list",
|
||||
],
|
||||
regex:
|
||||
/\b(arquivo|pasta|diretório|file|folder|directory|ler arquivo|read file)\b/i,
|
||||
},
|
||||
code: {
|
||||
keywords: [
|
||||
"código",
|
||||
"programar",
|
||||
"executar",
|
||||
"run",
|
||||
"execute",
|
||||
"compile",
|
||||
"debug",
|
||||
"git",
|
||||
"commit",
|
||||
],
|
||||
regex:
|
||||
/\b(código|programar|executar|run|execute|compile|debug|git|commit)\b/i,
|
||||
},
|
||||
database: {
|
||||
keywords: [
|
||||
"banco de dados",
|
||||
"database",
|
||||
"query",
|
||||
"sql",
|
||||
"select",
|
||||
"insert",
|
||||
"update",
|
||||
"delete",
|
||||
],
|
||||
regex:
|
||||
/\b(banco de dados|database|query|sql|select|insert|update|delete)\b/i,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Detecta a intenção do usuário na mensagem
|
||||
* @param message Mensagem do usuário
|
||||
* @returns Intent detectado ou null
|
||||
*/
|
||||
export function detectIntent(message: string): DetectedIntent | null {
|
||||
const messageLower = message.toLowerCase();
|
||||
|
||||
for (const [type, pattern] of Object.entries(INTENT_PATTERNS)) {
|
||||
const matches = pattern.regex.test(messageLower);
|
||||
|
||||
if (matches) {
|
||||
// Calcular confiança baseado em quantas keywords foram encontradas
|
||||
const foundKeywords = pattern.keywords.filter((keyword) =>
|
||||
messageLower.includes(keyword.toLowerCase())
|
||||
);
|
||||
|
||||
const confidence = Math.min(0.5 + foundKeywords.length * 0.2, 1.0);
|
||||
|
||||
return {
|
||||
type: type as DetectedIntent["type"],
|
||||
confidence,
|
||||
keywords: foundKeywords,
|
||||
suggestedTools: getSuggestedToolsForIntent(
|
||||
type as DetectedIntent["type"]
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna tools sugeridos para um tipo de intent
|
||||
* @param intentType Tipo de intent
|
||||
* @returns Array de nomes de tools sugeridos
|
||||
*/
|
||||
function getSuggestedToolsForIntent(
|
||||
intentType: DetectedIntent["type"]
|
||||
): string[] {
|
||||
const toolMapping: Record<DetectedIntent["type"], string[]> = {
|
||||
web_search: ["web_search", "duckduckgo_search", "google_search", "search"],
|
||||
time: ["get_current_time", "get_date", "current_time", "time"],
|
||||
weather: ["get_weather", "weather", "forecast"],
|
||||
file: ["read_file", "list_files", "filesystem", "file"],
|
||||
code: ["execute_code", "git", "run_command"],
|
||||
database: ["query", "sql", "database"],
|
||||
other: [],
|
||||
};
|
||||
|
||||
return toolMapping[intentType] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se um tool name corresponde a algum intent detectado
|
||||
* @param toolName Nome do tool
|
||||
* @param intent Intent detectado
|
||||
* @returns true se o tool é relevante para o intent
|
||||
*/
|
||||
export function isToolRelevantForIntent(
|
||||
toolName: string,
|
||||
intent: DetectedIntent
|
||||
): boolean {
|
||||
const toolNameLower = toolName.toLowerCase();
|
||||
return intent.suggestedTools.some((suggested) =>
|
||||
toolNameLower.includes(suggested.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtra tools com base em um intent detectado
|
||||
* @param tools Array de tools disponíveis
|
||||
* @param intent Intent detectado
|
||||
* @returns Tools filtrados e ordenados por relevância
|
||||
*/
|
||||
export function filterToolsByIntent(
|
||||
tools: Array<{ name: string; description: string }>,
|
||||
intent: DetectedIntent
|
||||
): Array<{ name: string; description: string; relevance: number }> {
|
||||
return tools
|
||||
.map((tool) => {
|
||||
let relevance = 0;
|
||||
|
||||
// Verificar se o tool name contém alguma keyword sugerida
|
||||
const toolNameLower = tool.name.toLowerCase();
|
||||
for (const suggested of intent.suggestedTools) {
|
||||
if (toolNameLower.includes(suggested.toLowerCase())) {
|
||||
relevance += 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar se a descrição contém keywords do intent
|
||||
const descriptionLower = tool.description.toLowerCase();
|
||||
for (const keyword of intent.keywords) {
|
||||
if (descriptionLower.includes(keyword.toLowerCase())) {
|
||||
relevance += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...tool, relevance };
|
||||
})
|
||||
.filter((tool) => tool.relevance > 0)
|
||||
.sort((a, b) => b.relevance - a.relevance);
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Serviço executor de MCPs - spawna processos e executa tools via JSON-RPC
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from "child_process";
|
||||
import { MCPRepository } from "@/database/repositories/mcp";
|
||||
import type { MCPExecutionResult, MCPTool } from "@/lib/types/mcp-chat";
|
||||
|
||||
const EXECUTION_TIMEOUT = 30000; // 30 segundos
|
||||
|
||||
export class MCPExecutor {
|
||||
/**
|
||||
* Executa um tool de MCP
|
||||
* @param mcpId ID do MCP instalado
|
||||
* @param toolName Nome do tool a executar
|
||||
* @param parameters Parâmetros do tool
|
||||
* @returns Resultado da execução
|
||||
*/
|
||||
static async executeMCPTool(
|
||||
mcpId: string,
|
||||
toolName: string,
|
||||
parameters: Record<string, any>
|
||||
): Promise<MCPExecutionResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Obter dados do MCP instalado
|
||||
const mcpData = MCPRepository.getInstalledMCP(mcpId);
|
||||
if (!mcpData) {
|
||||
throw new Error(`MCP ${mcpId} not found or not installed`);
|
||||
}
|
||||
|
||||
// Verificar se está pronto
|
||||
if (mcpData.status !== "ready") {
|
||||
throw new Error(
|
||||
`MCP ${mcpId} is not ready (status: ${mcpData.status})`
|
||||
);
|
||||
}
|
||||
|
||||
// Obter comando executável
|
||||
const config = JSON.parse(mcpData.config);
|
||||
const command = config.command || mcpData.executable_command;
|
||||
const args = config.args || [];
|
||||
|
||||
if (!command) {
|
||||
throw new Error(`No executable command found for MCP ${mcpId}`);
|
||||
}
|
||||
|
||||
console.log(`🚀 Executing MCP tool: ${mcpId}.${toolName}`);
|
||||
console.log(`Command: ${command} ${args.join(" ")}`);
|
||||
|
||||
// Spawnar processo do MCP
|
||||
const result = await this.spawnMCPProcess(
|
||||
command,
|
||||
args,
|
||||
toolName,
|
||||
parameters,
|
||||
mcpData.environment_path
|
||||
);
|
||||
|
||||
const executionTime = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
toolName,
|
||||
mcpId,
|
||||
result,
|
||||
executionTime,
|
||||
};
|
||||
} catch (error: any) {
|
||||
const executionTime = Date.now() - startTime;
|
||||
|
||||
console.error(`❌ Error executing MCP tool ${mcpId}.${toolName}:`, error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
toolName,
|
||||
mcpId,
|
||||
error: error.message || "Unknown error",
|
||||
executionTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawna processo do MCP e executa JSON-RPC
|
||||
* @param command Comando executável
|
||||
* @param args Argumentos do comando
|
||||
* @param toolName Nome do tool
|
||||
* @param parameters Parâmetros do tool
|
||||
* @param environmentPath Caminho do ambiente (opcional)
|
||||
* @returns Resultado do tool
|
||||
*/
|
||||
private static async spawnMCPProcess(
|
||||
command: string,
|
||||
args: string[],
|
||||
toolName: string,
|
||||
parameters: Record<string, any>,
|
||||
environmentPath?: string
|
||||
): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const env = {
|
||||
...process.env,
|
||||
...(environmentPath && {
|
||||
PATH: `${environmentPath}/node_modules/.bin:${process.env.PATH}`,
|
||||
}),
|
||||
};
|
||||
|
||||
const process = spawn(command, args, {
|
||||
cwd: environmentPath || process.cwd(),
|
||||
env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let responseReceived = false;
|
||||
|
||||
// Timeout
|
||||
const timeout = setTimeout(() => {
|
||||
if (!responseReceived) {
|
||||
process.kill();
|
||||
reject(new Error(`Execution timeout after ${EXECUTION_TIMEOUT}ms`));
|
||||
}
|
||||
}, EXECUTION_TIMEOUT);
|
||||
|
||||
// Capturar stdout
|
||||
process.stdout?.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
|
||||
// Tentar parsear respostas JSON-RPC
|
||||
const lines = stdout.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (parsed.id === 2 && parsed.result) {
|
||||
// Resposta do tools/call
|
||||
clearTimeout(timeout);
|
||||
responseReceived = true;
|
||||
process.kill();
|
||||
resolve(parsed.result);
|
||||
}
|
||||
} catch (e) {
|
||||
// Linha não é JSON válido, continuar
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Capturar stderr
|
||||
process.stderr?.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
// Erro de processo
|
||||
process.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`Process error: ${error.message}`));
|
||||
});
|
||||
|
||||
// Processo encerrou
|
||||
process.on("close", (code) => {
|
||||
clearTimeout(timeout);
|
||||
if (!responseReceived) {
|
||||
if (code !== 0) {
|
||||
reject(
|
||||
new Error(`Process exited with code ${code}. stderr: ${stderr}`)
|
||||
);
|
||||
} else {
|
||||
reject(new Error("No valid response received from MCP"));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Enviar requisições JSON-RPC
|
||||
try {
|
||||
// 1. Initialize
|
||||
const initRequest = {
|
||||
jsonrpc: "2.0",
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "ollahub", version: "1.0.0" },
|
||||
},
|
||||
id: 1,
|
||||
};
|
||||
process.stdin?.write(JSON.stringify(initRequest) + "\n");
|
||||
|
||||
// Aguardar um pouco antes de enviar tool call
|
||||
setTimeout(() => {
|
||||
// 2. Call tool
|
||||
const toolCallRequest = {
|
||||
jsonrpc: "2.0",
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: toolName,
|
||||
arguments: parameters,
|
||||
},
|
||||
id: 2,
|
||||
};
|
||||
process.stdin?.write(JSON.stringify(toolCallRequest) + "\n");
|
||||
process.stdin?.end();
|
||||
}, 500);
|
||||
} catch (error: any) {
|
||||
clearTimeout(timeout);
|
||||
process.kill();
|
||||
reject(new Error(`Failed to send JSON-RPC request: ${error.message}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter tools disponíveis de um MCP
|
||||
* @param mcpId ID do MCP
|
||||
* @returns Array de tools
|
||||
*/
|
||||
static getAvailableTools(mcpId: string): MCPTool[] {
|
||||
try {
|
||||
const mcpData = MCPRepository.getInstalledMCP(mcpId);
|
||||
if (!mcpData || !mcpData.tools) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Se já é um array (já foi parseado), retornar diretamente
|
||||
if (Array.isArray(mcpData.tools)) {
|
||||
return mcpData.tools;
|
||||
}
|
||||
|
||||
// Se é string, parsear
|
||||
if (typeof mcpData.tools === "string") {
|
||||
const tools = JSON.parse(mcpData.tools);
|
||||
return Array.isArray(tools) ? tools : [];
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error(`Error getting available tools for ${mcpId}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validar parâmetros contra schema do tool
|
||||
* @param parameters Parâmetros fornecidos
|
||||
* @param tool Definição do tool
|
||||
* @returns true se válido, false caso contrário
|
||||
*/
|
||||
static validateParameters(
|
||||
parameters: Record<string, any>,
|
||||
tool: MCPTool
|
||||
): { valid: boolean; error?: string } {
|
||||
try {
|
||||
const schema = tool.inputSchema;
|
||||
|
||||
// Verificar campos obrigatórios
|
||||
if (schema.required) {
|
||||
for (const required of schema.required) {
|
||||
if (!(required in parameters)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Missing required parameter: ${required}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validação básica de tipos
|
||||
for (const [key, value] of Object.entries(parameters)) {
|
||||
if (schema.properties && schema.properties[key]) {
|
||||
const propSchema = schema.properties[key];
|
||||
const expectedType = propSchema.type;
|
||||
|
||||
if (expectedType) {
|
||||
const actualType = typeof value;
|
||||
const valid =
|
||||
(expectedType === "string" && actualType === "string") ||
|
||||
(expectedType === "number" && actualType === "number") ||
|
||||
(expectedType === "boolean" && actualType === "boolean") ||
|
||||
(expectedType === "object" && actualType === "object") ||
|
||||
(expectedType === "array" && Array.isArray(value));
|
||||
|
||||
if (!valid) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Parameter ${key} should be ${expectedType}, got ${actualType}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
} catch (error: any) {
|
||||
return { valid: false, error: error.message };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Serviço para detectar capabilities de function calling dos modelos
|
||||
*/
|
||||
|
||||
import type { ModelCapabilities } from "@/lib/types/mcp-chat";
|
||||
|
||||
/**
|
||||
* Lista de modelos com suporte nativo a function calling
|
||||
* Baseado na documentação do Ollama (atualizado em nov 2024)
|
||||
*/
|
||||
const MODELS_WITH_NATIVE_TOOLS = [
|
||||
// Llama 3.x
|
||||
/^llama3\.1/i,
|
||||
/^llama3\.2/i,
|
||||
/^llama3-/i,
|
||||
|
||||
// Mistral
|
||||
/^mistral/i,
|
||||
/^mistral-nemo/i,
|
||||
/^mixtral/i,
|
||||
|
||||
// Gemma
|
||||
/^gemma2/i,
|
||||
|
||||
// Qwen
|
||||
/^qwen2\.5/i,
|
||||
|
||||
// Command-R
|
||||
/^command-r/i,
|
||||
|
||||
// Firefunction
|
||||
/^firefunction/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Detecta capabilities de function calling de um modelo
|
||||
* @param modelName Nome do modelo (ex: "llama3.1:8b", "qwen2.5:7b")
|
||||
* @returns Capabilities do modelo
|
||||
*/
|
||||
export function detectModelCapabilities(modelName: string): ModelCapabilities {
|
||||
const supportsNativeTools = MODELS_WITH_NATIVE_TOOLS.some((pattern) =>
|
||||
pattern.test(modelName)
|
||||
);
|
||||
|
||||
return {
|
||||
modelName,
|
||||
supportsNativeTools,
|
||||
requiresPromptEngineering: !supportsNativeTools,
|
||||
toolCallFormat: supportsNativeTools ? "openai" : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se um modelo específico suporta function calling nativo
|
||||
* @param modelName Nome do modelo
|
||||
* @returns true se suporta, false caso contrário
|
||||
*/
|
||||
export function supportsNativeTools(modelName: string): boolean {
|
||||
return detectModelCapabilities(modelName).supportsNativeTools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista todos os modelos conhecidos com suporte a function calling
|
||||
* @returns Array de padrões regex em string
|
||||
*/
|
||||
export function getSupportedModelsPatterns(): string[] {
|
||||
return [
|
||||
"llama3.1:*",
|
||||
"llama3.2:*",
|
||||
"mistral:*",
|
||||
"mixtral:*",
|
||||
"gemma2:*",
|
||||
"qwen2.5:*",
|
||||
"command-r:*",
|
||||
"firefunction:*",
|
||||
];
|
||||
}
|
||||
Reference in new issue
Block a user