SPB Git

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%
4.8 KB · 115 lines typescript
Raw Blame History
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