import type { Tool } from "@magiccode/core"; import { optNum, optStr, str } from "./helpers"; const DEFAULT_TIMEOUT = 30_000; // 30 secondes const OUTPUT_MAX = 100_000; // 100 KB interface FetchOptions { method?: string; headers?: Record; body?: string; timeout?: number; } export const webfetchTool: Tool = { spec: { name: "WebFetch", description: "Récupère le contenu d'une URL (HTTP/HTTPS). Retourne le statut, les en-têtes et le corps de la réponse (texte ou JSON).", inputSchema: { type: "object", properties: { url: { type: "string", description: "URL à récupérer (http:// ou https://)" }, method: { type: "string", description: 'Méthode HTTP (GET par défaut, ou POST, PUT, DELETE, etc.)' }, headers: { type: "object", description: "En-têtes HTTP personnalisés", additionalProperties: { type: "string" }, }, body: { type: "string", description: "Corps de la requête (pour POST, PUT, etc.)" }, timeout: { type: "number", description: "Timeout en millisecondes (30000 par défaut)" }, }, required: ["url"], }, }, permission: "ask", describe: (i) => `WebFetch ${String(i.method ?? "GET")} ${String(i.url ?? "")}`, async execute(input) { const url = str(input, "url"); const method = optStr(input, "method")?.toUpperCase() ?? "GET"; const headers = (input.headers as Record) ?? {}; const body = optStr(input, "body"); const timeout = optNum(input, "timeout") ?? DEFAULT_TIMEOUT; // Validation basique de l'URL let parsedUrl: URL; try { parsedUrl = new URL(url); if (!["http:", "https:"].includes(parsedUrl.protocol)) { throw new Error(`Protocole non supporté : ${parsedUrl.protocol}`); } } catch (error) { throw new Error(`URL invalide : ${url} (${(error as Error).message})`); } // Préparer les options de fetch const fetchOptions: FetchOptions = { method, timeout, }; if (Object.keys(headers).length > 0) { fetchOptions.headers = headers; } if (body) { fetchOptions.body = body; // Ajouter Content-Type par défaut si non défini if (!fetchOptions.headers) { fetchOptions.headers = {}; } if (!fetchOptions.headers["Content-Type"]) { fetchOptions.headers["Content-Type"] = "application/json"; } } try { const response = await fetch(url, fetchOptions); // Construire l'en-tête de réponse const responseHeaders: Record = {}; response.headers.forEach((value, key) => { responseHeaders[key] = value; }); // Déterminer le type de contenu const contentType = responseHeaders["content-type"] || ""; let bodyText: string; if (contentType.includes("application/json")) { // Pour JSON, retourner le texte formaté const text = await response.text(); try { const parsed = JSON.parse(text); bodyText = JSON.stringify(parsed, null, 2); } catch { bodyText = text; } } else if (contentType.includes("text/") || contentType.includes("application/xml")) { bodyText = await response.text(); } else { // Pour les autres types (images, binaires), retourner un résumé const buffer = await response.arrayBuffer(); bodyText = `[Contenu binaire : ${buffer.byteLength} bytes (type: ${contentType || "inconnu"})]`; } // Tronquer si trop volumineux if (bodyText.length > OUTPUT_MAX) { bodyText = `${bodyText.slice(0, OUTPUT_MAX)}\n… (tronqué, ${bodyText.length} bytes au total)`; } // Assembler la réponse const result = [ `Status: ${response.status} ${response.statusText}`, `\n[Headers]`, Object.entries(responseHeaders) .map(([k, v]) => `${k}: ${v}`) .join("\n"), `\n[Body]`, bodyText, ].join("\n"); return result; } catch (error) { const message = error instanceof Error ? error.message : String(error); if (message.includes("timeout") || message.includes("Timeout")) { throw new Error(`Timeout après ${timeout}ms pour ${url}`); } throw new Error(`Erreur lors de la récupération de ${url}: ${message}`); } }, };