spb/tendril Public
Tendril — web ingestion platform (scrape/crawl/map/search) on macOS Apple Silicon: WebKit fidelity, authenticated pages, deterministic testable extraction. A self-hosted Firecrawl alternative.
JavaScript 82.6%
TypeScript 11.8%
HTML 5.3%
1// author: simon-pierre boucher <contact@spboucher.ai>2import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";3import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";4import { z } from "zod";56const BASE_URL = (process.env.TENDRIL_BASE_URL ?? "https://www.ten-dril.com").replace(/\/+$/, "");7const API_KEY = process.env.TENDRIL_API_KEY;89interface ApiEnvelope {10 success: boolean;11 data?: unknown;12 error?: { code: string; message: string; details?: unknown };13 requestId?: string;14}1516async function call(path: string, body: unknown): Promise<ApiEnvelope> {17 const headers: Record<string, string> = { "content-type": "application/json" };18 if (API_KEY) headers["authorization"] = `Bearer ${API_KEY}`;19 const res = await fetch(`${BASE_URL}${path}`, {20 method: body === undefined ? "GET" : "POST",21 headers,22 ...(body === undefined ? {} : { body: JSON.stringify(body) }),23 });24 const text = await res.text();25 try {26 return JSON.parse(text) as ApiEnvelope;27 } catch {28 return { success: false, error: { code: "ERR_NON_JSON", message: text.slice(0, 500) } };29 }30}3132function textResult(text: string, isError = false) {33 return { content: [{ type: "text" as const, text }], ...(isError ? { isError: true } : {}) };34}3536function errText(env: ApiEnvelope): string {37 return `Tendril error ${env.error?.code ?? "UNKNOWN"}: ${env.error?.message ?? "request failed"}`;38}3940const server = new McpServer({ name: "tendril", version: "0.1.0" });4142server.tool(43 "tendril_scrape",44 "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.",45 {46 url: z.string().url().describe("The absolute URL to scrape"),47 formats: z48 .array(z.enum(["markdown", "html", "links", "structured"]))49 .optional()50 .describe("Output formats to include (default: markdown)"),51 onlyMainContent: z.boolean().optional().describe("Strip nav/footer/ads and keep the main article (default: true)"),52 tier: z.enum(["auto", "http"]).optional().describe("Fetch tier. 'http' forces Tier 0 output even if escalation is suggested (default: auto)"),53 },54 async ({ url, formats, onlyMainContent, tier }) => {55 const env = await call("/v1/scrape", {56 url,57 formats: formats ?? ["markdown"],58 ...(onlyMainContent !== undefined ? { onlyMainContent } : {}),59 ...(tier !== undefined ? { tier } : {}),60 });61 if (!env.success) return textResult(errText(env), true);62 const d = env.data as Record<string, unknown>;63 const parts: string[] = [];64 if (typeof d.markdown === "string") parts.push(d.markdown);65 if (d.structured) parts.push("\n\n---\nStructured data:\n" + JSON.stringify(d.structured, null, 2));66 if (Array.isArray(d.links)) parts.push(`\n\n---\n${d.links.length} links extracted.`);67 if (d.html && !d.markdown) parts.push(String(d.html).slice(0, 20000));68 return textResult(parts.join("") || JSON.stringify(d, null, 2));69 },70);7172server.tool(73 "tendril_map",74 "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.",75 {76 url: z.string().url().describe("The site root to map, e.g. https://example.com"),77 search: z.string().optional().describe("Only return URLs containing this substring"),78 limit: z.number().int().min(1).max(50000).optional().describe("Max URLs to return (default: 100)"),79 includeSubdomains: z.boolean().optional().describe("Include subdomains of the apex (default: false)"),80 },81 async ({ url, search, limit, includeSubdomains }) => {82 const env = await call("/v1/map", {83 url,84 limit: limit ?? 100,85 ...(search !== undefined ? { search } : {}),86 ...(includeSubdomains !== undefined ? { includeSubdomains } : {}),87 });88 if (!env.success) return textResult(errText(env), true);89 const d = env.data as { links: Array<{ url: string; source: string }>; count: number };90 const lines = d.links.map((l) => `- ${l.url} (${l.source})`).join("\n");91 return textResult(`${d.count} URLs discovered on ${url}:\n\n${lines}`);92 },93);9495server.tool(96 "tendril_status",97 "Check Tendril's health and which fetch tiers are currently available. Call this if scrape/map requests are failing.",98 {},99 async () => {100 const env = await call("/v1/status", undefined);101 return textResult(JSON.stringify(env.data ?? env, null, 2));102 },103);104105async function main(): Promise<void> {106 const transport = new StdioServerTransport();107 await server.connect(transport);108 process.stderr.write(`tendril-mcp connected (base=${BASE_URL})\n`);109}110111main().catch((err: unknown) => {112 process.stderr.write(`tendril-mcp fatal: ${String(err)}\n`);113 process.exit(1);114});115