// author: simon-pierre boucher import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; const BASE_URL = (process.env.TENDRIL_BASE_URL ?? "https://www.ten-dril.com").replace(/\/+$/, ""); const API_KEY = process.env.TENDRIL_API_KEY; interface ApiEnvelope { success: boolean; data?: unknown; error?: { code: string; message: string; details?: unknown }; requestId?: string; } async function call(path: string, body: unknown): Promise { const headers: Record = { "content-type": "application/json" }; if (API_KEY) headers["authorization"] = `Bearer ${API_KEY}`; const res = await fetch(`${BASE_URL}${path}`, { method: body === undefined ? "GET" : "POST", headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); const text = await res.text(); try { return JSON.parse(text) as ApiEnvelope; } catch { return { success: false, error: { code: "ERR_NON_JSON", message: text.slice(0, 500) } }; } } function textResult(text: string, isError = false) { return { content: [{ type: "text" as const, text }], ...(isError ? { isError: true } : {}) }; } function errText(env: ApiEnvelope): string { return `Tendril error ${env.error?.code ?? "UNKNOWN"}: ${env.error?.message ?? "request failed"}`; } const server = new McpServer({ name: "tendril", version: "0.1.0" }); server.tool( "tendril_scrape", "Fetch a single web page through Tendril and return clean Markdown (plus optional links, structured data, and metadata). Use this to read the content of a specific URL. Tendril runs real WebKit behind a residential IP and extracts deterministically.", { url: z.string().url().describe("The absolute URL to scrape"), formats: z .array(z.enum(["markdown", "html", "links", "structured"])) .optional() .describe("Output formats to include (default: markdown)"), onlyMainContent: z.boolean().optional().describe("Strip nav/footer/ads and keep the main article (default: true)"), tier: z.enum(["auto", "http"]).optional().describe("Fetch tier. 'http' forces Tier 0 output even if escalation is suggested (default: auto)"), }, async ({ url, formats, onlyMainContent, tier }) => { const env = await call("/v1/scrape", { url, formats: formats ?? ["markdown"], ...(onlyMainContent !== undefined ? { onlyMainContent } : {}), ...(tier !== undefined ? { tier } : {}), }); if (!env.success) return textResult(errText(env), true); const d = env.data as Record; const parts: string[] = []; if (typeof d.markdown === "string") parts.push(d.markdown); if (d.structured) parts.push("\n\n---\nStructured data:\n" + JSON.stringify(d.structured, null, 2)); if (Array.isArray(d.links)) parts.push(`\n\n---\n${d.links.length} links extracted.`); if (d.html && !d.markdown) parts.push(String(d.html).slice(0, 20000)); return textResult(parts.join("") || JSON.stringify(d, null, 2)); }, ); server.tool( "tendril_map", "Discover the URLs of a website without rendering — merges sitemaps, robots.txt, homepage links and /llms.txt. Use this to find pages on a site before scraping them, or to get a site's structure quickly.", { url: z.string().url().describe("The site root to map, e.g. https://example.com"), search: z.string().optional().describe("Only return URLs containing this substring"), limit: z.number().int().min(1).max(50000).optional().describe("Max URLs to return (default: 100)"), includeSubdomains: z.boolean().optional().describe("Include subdomains of the apex (default: false)"), }, async ({ url, search, limit, includeSubdomains }) => { const env = await call("/v1/map", { url, limit: limit ?? 100, ...(search !== undefined ? { search } : {}), ...(includeSubdomains !== undefined ? { includeSubdomains } : {}), }); if (!env.success) return textResult(errText(env), true); const d = env.data as { links: Array<{ url: string; source: string }>; count: number }; const lines = d.links.map((l) => `- ${l.url} (${l.source})`).join("\n"); return textResult(`${d.count} URLs discovered on ${url}:\n\n${lines}`); }, ); server.tool( "tendril_status", "Check Tendril's health and which fetch tiers are currently available. Call this if scrape/map requests are failing.", {}, async () => { const env = await call("/v1/status", undefined); return textResult(JSON.stringify(env.data ?? env, null, 2)); }, ); async function main(): Promise { const transport = new StdioServerTransport(); await server.connect(transport); process.stderr.write(`tendril-mcp connected (base=${BASE_URL})\n`); } main().catch((err: unknown) => { process.stderr.write(`tendril-mcp fatal: ${String(err)}\n`); process.exit(1); });