@metalift/mcp
Advanced tools
| export type ScrapeResponseDetail = "compact" | "standard" | "full"; | ||
| /** Matches API COMPACT_BODY_MAX_CHARS — keep in sync with scrape_response.py */ | ||
| export declare const COMPACT_BODY_MAX_CHARS = 16000; | ||
| export declare function resolveScrapeResponseDetail(result: Record<string, unknown>, fallback?: ScrapeResponseDetail): ScrapeResponseDetail; | ||
| export declare function formatScrapeResponse(result: Record<string, unknown>, detail?: ScrapeResponseDetail): string; | ||
| export declare function formatBatchScrapeResponse(result: Record<string, unknown>, detail?: ScrapeResponseDetail): string; |
| /** Matches API COMPACT_BODY_MAX_CHARS — keep in sync with scrape_response.py */ | ||
| export const COMPACT_BODY_MAX_CHARS = 16_000; | ||
| export function resolveScrapeResponseDetail(result, fallback = "compact") { | ||
| const detail = result.response_detail; | ||
| if (detail === "compact" || detail === "standard" || detail === "full") { | ||
| return detail; | ||
| } | ||
| return fallback; | ||
| } | ||
| function formatMetadataBlock(metadata, detail) { | ||
| const lines = []; | ||
| const title = typeof metadata.title === "string" ? metadata.title : "Untitled"; | ||
| lines.push(`# ${title}`); | ||
| const sourceUrl = typeof metadata.source_url === "string" ? metadata.source_url : ""; | ||
| if (sourceUrl) | ||
| lines.push(`URL: ${sourceUrl}`); | ||
| if (typeof metadata.status_code === "number") { | ||
| lines.push(`HTTP: ${metadata.status_code}`); | ||
| } | ||
| if (detail !== "compact") { | ||
| if (typeof metadata.description === "string" && metadata.description) { | ||
| lines.push(`Description: ${metadata.description.slice(0, 400)}`); | ||
| } | ||
| if (typeof metadata.strategy === "string") { | ||
| lines.push(`Strategy: ${metadata.strategy}`); | ||
| } | ||
| if (typeof metadata.links_total === "number") { | ||
| lines.push(`Links: ${metadata.links_total} total`); | ||
| } | ||
| else if (Array.isArray(metadata.links) && metadata.links.length > 0) { | ||
| lines.push(`Links: ${metadata.links.length} included`); | ||
| } | ||
| } | ||
| return lines; | ||
| } | ||
| export function formatScrapeResponse(result, detail = resolveScrapeResponseDetail(result)) { | ||
| if (detail === "full") { | ||
| return JSON.stringify(result, null, 2); | ||
| } | ||
| const success = result.success === true; | ||
| const credits = typeof result.credits_charged === "number" ? result.credits_charged : undefined; | ||
| const data = result.data; | ||
| const metadata = (data?.metadata ?? {}); | ||
| const markdown = typeof data?.markdown === "string" ? data.markdown : ""; | ||
| const text = typeof data?.text === "string" ? data.text : ""; | ||
| const body = markdown || text; | ||
| const lines = [ | ||
| ...formatMetadataBlock(metadata, detail), | ||
| credits !== undefined ? `Credits: ${credits}` : "", | ||
| `Detail: ${detail}`, | ||
| "", | ||
| ].filter(Boolean); | ||
| if (!success) { | ||
| const err = typeof result.error === "string" ? result.error : "Scrape failed"; | ||
| lines.push(`Error: ${err}`); | ||
| return lines.join("\n"); | ||
| } | ||
| if (body) { | ||
| lines.push(body); | ||
| } | ||
| else { | ||
| lines.push("(No markdown/text content in response.)"); | ||
| } | ||
| if (detail === "compact") { | ||
| lines.push(""); | ||
| lines.push("Summarize in plain language. Use response_detail=standard for full page text or full for raw JSON + all links."); | ||
| } | ||
| else { | ||
| lines.push(""); | ||
| lines.push("Summarize in plain language. Use response_detail=full for complete JSON including all links."); | ||
| } | ||
| return lines.join("\n"); | ||
| } | ||
| export function formatBatchScrapeResponse(result, detail = resolveScrapeResponseDetail(result)) { | ||
| if (detail === "full") { | ||
| return JSON.stringify(result, null, 2); | ||
| } | ||
| const pages = Array.isArray(result.data) ? result.data : []; | ||
| const credits = typeof result.credits_charged === "number" ? result.credits_charged : undefined; | ||
| const lines = [ | ||
| `Batch scrape: ${pages.length} page(s)`, | ||
| credits !== undefined ? `Credits: ${credits}` : "", | ||
| `Detail: ${detail}`, | ||
| "", | ||
| ].filter(Boolean); | ||
| for (let i = 0; i < pages.length; i++) { | ||
| lines.push(`--- Page ${i + 1} ---`); | ||
| lines.push(formatScrapeResponse({ success: true, response_detail: detail, data: pages[i] }, detail)); | ||
| lines.push(""); | ||
| } | ||
| return lines.join("\n").trim(); | ||
| } |
| export {}; |
| import assert from "node:assert/strict"; | ||
| import test from "node:test"; | ||
| import { formatScrapeResponse, resolveScrapeResponseDetail, } from "./scrape-format.js"; | ||
| test("resolveScrapeResponseDetail reads API field", () => { | ||
| assert.equal(resolveScrapeResponseDetail({ response_detail: "full" }), "full"); | ||
| assert.equal(resolveScrapeResponseDetail({}), "compact"); | ||
| }); | ||
| test("formatScrapeResponse compact omits links from output", () => { | ||
| const out = formatScrapeResponse({ | ||
| success: true, | ||
| response_detail: "compact", | ||
| credits_charged: 1, | ||
| data: { | ||
| metadata: { | ||
| title: "Docker Docs", | ||
| source_url: "https://docs.docker.com/", | ||
| status_code: 200, | ||
| links: ["https://example.com/a"], | ||
| }, | ||
| markdown: "# Hello", | ||
| }, | ||
| }); | ||
| assert.match(out, /Detail: compact/); | ||
| assert.match(out, /# Hello/); | ||
| assert.doesNotMatch(out, /example\.com\/a/); | ||
| }); | ||
| test("formatScrapeResponse full returns JSON", () => { | ||
| const payload = { | ||
| success: true, | ||
| response_detail: "full", | ||
| data: { markdown: "# Hi", metadata: { links: ["https://x.com"] } }, | ||
| }; | ||
| const out = formatScrapeResponse(payload, "full"); | ||
| assert.match(out, /"links"/); | ||
| assert.match(out, /https:\/\/x\.com/); | ||
| }); | ||
| test("formatScrapeResponse standard includes extra metadata lines", () => { | ||
| const out = formatScrapeResponse({ | ||
| success: true, | ||
| response_detail: "standard", | ||
| data: { | ||
| metadata: { | ||
| title: "Page", | ||
| source_url: "https://example.com", | ||
| description: "A description", | ||
| strategy: "article", | ||
| links_total: 100, | ||
| }, | ||
| markdown: "body", | ||
| }, | ||
| }); | ||
| assert.match(out, /Description:/); | ||
| assert.match(out, /Strategy: article/); | ||
| assert.match(out, /Links: 100 total/); | ||
| }); |
| export declare const SEARCH_UNAVAILABLE_DOCS = "https://metalift.ai/docs/release-notes"; | ||
| export declare function searchUnavailableMessage(apiUrl: string, statusCode?: number): string; | ||
| export declare function formatSearchApiError(apiUrl: string, body: unknown, status: number): string | null; |
| export const SEARCH_UNAVAILABLE_DOCS = "https://metalift.ai/docs/release-notes"; | ||
| export function searchUnavailableMessage(apiUrl, statusCode) { | ||
| const status = statusCode ? ` (HTTP ${statusCode})` : ""; | ||
| return (`Web search is not available on ${apiUrl}${status}. ` + | ||
| "POST /v1/search was not found or search is disabled on this API host. " + | ||
| "Metalift Cloud (https://api.metalift.ai) may not have search deployed yet — " + | ||
| "use a self-hosted scrape API with SEARCH_ENABLED=true, or check " + | ||
| `${SEARCH_UNAVAILABLE_DOCS} for rollout status.`); | ||
| } | ||
| export function formatSearchApiError(apiUrl, body, status) { | ||
| if (status === 404) { | ||
| return searchUnavailableMessage(apiUrl, status); | ||
| } | ||
| if (status === 503) { | ||
| const detail = typeof body === "object" && body !== null && "detail" in body | ||
| ? String(body.detail ?? "") | ||
| : ""; | ||
| if (/search|searxng/i.test(detail)) { | ||
| return searchUnavailableMessage(apiUrl, status); | ||
| } | ||
| } | ||
| return null; | ||
| } |
+3
-1
@@ -0,1 +1,2 @@ | ||
| import { formatSearchApiError } from "./search-errors.js"; | ||
| const B2B_ATTESTATION_HEADER = "X-B2B-Attestation"; | ||
@@ -83,3 +84,4 @@ const B2B_ATTESTATION_VALUE = "I-confirm-B2B-use"; | ||
| if (!response.ok) { | ||
| throw new Error(formatApiError(body, response.status)); | ||
| const searchError = path === "/v1/search" ? formatSearchApiError(this.apiUrl, body, response.status) : null; | ||
| throw new Error(searchError || formatApiError(body, response.status)); | ||
| } | ||
@@ -86,0 +88,0 @@ return withBilling(body, billingFromResponse(response)); |
+40
-11
@@ -6,4 +6,5 @@ #!/usr/bin/env node | ||
| import { MetaliftClient } from "./client.js"; | ||
| import { buildWebSearchRequest, WEB_SEARCH_RESULT_LIMIT } from "./web-search.js"; | ||
| import { formatBatchScrapeResponse, formatScrapeResponse } from "./scrape-format.js"; | ||
| import { normalizeScrapeArgs } from "./scrape-args.js"; | ||
| import { buildWebSearchRequest, formatWebSearchResponse, WEB_SEARCH_RESULT_LIMIT } from "./web-search.js"; | ||
| const COMPLIANCE_NOTICE = "You are solely responsible for complying with website terms, robots.txt, copyright, and data protection laws when using scraped content."; | ||
@@ -20,9 +21,15 @@ const SERVER_INSTRUCTIONS = `Metalift provides web search and web scraping as separate, independently billed tools. | ||
| - Fetches page content (markdown, html, text). Billed per URL (static=1, JS=5, premium=10+ credits). | ||
| - Use after search when the user or agent needs full page content from specific URLs. | ||
| - response_detail controls how much is returned (default compact): | ||
| • compact — truncated markdown (~16k chars), minimal metadata, no links (best for LLM context) | ||
| • standard — full markdown/text, metadata with up to 25 links | ||
| • full — complete JSON payload including all links and html when requested | ||
| - Use compact unless you need full page text or link extraction. | ||
| Recommended agent workflow: metalift_web_search → pick 0–N relevant URLs from snippets → metalift_scrape chosen URLs only.`; | ||
| Recommended agent workflow: metalift_web_search → answer from snippets when possible → metalift_scrape (compact) only when snippets are insufficient. | ||
| For simple factual questions (versions, definitions, current events), prefer answering directly from search snippets. Do not scrape unless snippets are insufficient. Never paste raw scrape JSON to the user — summarize the answer in plain language.`; | ||
| const client = new MetaliftClient(); | ||
| const server = new McpServer({ | ||
| name: "metalift", | ||
| version: "1.0.7", | ||
| version: "1.0.10", | ||
| }, { | ||
@@ -33,5 +40,9 @@ instructions: SERVER_INSTRUCTIONS, | ||
| title: "Scrape URL", | ||
| description: `Scrape a single URL into markdown, HTML, or text for LLM context. Separate from web search — use after metalift_web_search when full page content is needed. Default: fast direct static article extraction (strategy=article, render=static, proxy=direct, 10s timeout). For full page HTML on static sites use strategy=download with formats=["html"] (1 credit, all tiers). strategy=raw and full-page HTML without download require Enterprise tier. For WAF, SPA, retail, or JS-heavy pages pass strategy=auto or a specific strategy (spa, cloudflare, retail). Response includes credits_charged based on actual usage (static=1, JS=5, premium=10+). ${COMPLIANCE_NOTICE}`, | ||
| description: `Scrape a single URL into markdown, HTML, or text for LLM context. Separate from web search — use after metalift_web_search when full page content is needed. Default response_detail=compact (truncated markdown, no links). Use standard for full page text or full for complete JSON + all links. Default scrape path: fast direct static article extraction (strategy=article, render=static, proxy=direct, 10s timeout). For full page HTML on static sites use strategy=download with formats=["html"] (1 credit, all tiers). strategy=raw and full-page HTML without download require Enterprise tier. For WAF, SPA, retail, or JS-heavy pages pass strategy=auto or a specific strategy (spa, cloudflare, retail). Response includes credits_charged based on actual usage (static=1, JS=5, premium=10+). ${COMPLIANCE_NOTICE}`, | ||
| inputSchema: { | ||
| url: z.string().url(), | ||
| response_detail: z | ||
| .enum(["compact", "standard", "full"]) | ||
| .optional() | ||
| .describe("Response depth: compact (default, ~16k chars, no links), standard (full markdown + capped links), full (complete JSON)."), | ||
| formats: z.array(z.enum(["markdown", "html", "text", "json"])).optional(), | ||
@@ -57,5 +68,12 @@ render: z.enum(["static", "dynamic", "auto"]).optional(), | ||
| }, async (args) => { | ||
| const result = await client.scrape(normalizeScrapeArgs(args)); | ||
| const normalized = normalizeScrapeArgs(args); | ||
| const detail = normalized.response_detail ?? "compact"; | ||
| const result = await client.scrape({ ...normalized, response_detail: detail }); | ||
| return { | ||
| content: [{ type: "text", text: JSON.stringify(result, null, 2) }], | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: formatScrapeResponse(result, detail), | ||
| }, | ||
| ], | ||
| }; | ||
@@ -74,2 +92,3 @@ }); | ||
| only_main_content: z.boolean().optional(), | ||
| response_detail: z.enum(["compact", "standard", "full"]).optional(), | ||
| }) | ||
@@ -80,5 +99,15 @@ .optional(), | ||
| }, async (args) => { | ||
| const result = await client.batch(args); | ||
| const scrapeOptions = args.scrape_options ?? {}; | ||
| const detail = scrapeOptions.response_detail ?? "compact"; | ||
| const result = await client.batch({ | ||
| ...args, | ||
| scrape_options: { ...scrapeOptions, response_detail: detail }, | ||
| }); | ||
| return { | ||
| content: [{ type: "text", text: JSON.stringify(result, null, 2) }], | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: formatBatchScrapeResponse(result, detail), | ||
| }, | ||
| ], | ||
| }; | ||
@@ -120,3 +149,3 @@ }); | ||
| title: "Web Search", | ||
| description: `Search the web and return up to ${WEB_SEARCH_RESULT_LIMIT} SERP results (title, url, snippet, engine, score). Costs 2 credits per search. Returns search snippets only — not page content. Do not auto-scrape results; pick relevant URLs and call metalift_scrape separately for full content.`, | ||
| description: `Search the web and return up to ${WEB_SEARCH_RESULT_LIMIT} SERP results (title, url, snippet, engine, score). Costs 2 credits per search. Returns search snippets only — not page content. Answer simple questions from snippets; do not auto-scrape. Call metalift_scrape separately only when full page content is required.`, | ||
| inputSchema: { | ||
@@ -131,3 +160,3 @@ query: z.string().min(1).max(512), | ||
| return { | ||
| content: [{ type: "text", text: JSON.stringify(result, null, 2) }], | ||
| content: [{ type: "text", text: formatWebSearchResponse(result) }], | ||
| }; | ||
@@ -134,0 +163,0 @@ }); |
| export type ScrapeFormat = "markdown" | "html" | "text" | "json"; | ||
| export type ScrapeRender = "static" | "dynamic" | "auto"; | ||
| export type ScrapeProxy = "auto" | "direct" | "residential" | "datacenter"; | ||
| export type ScrapeResponseDetail = "compact" | "standard" | "full"; | ||
| export interface ScrapeArgs { | ||
@@ -16,2 +17,3 @@ url: string; | ||
| cookie_header?: string; | ||
| response_detail?: ScrapeResponseDetail; | ||
| } | ||
@@ -18,0 +20,0 @@ /** Default timeout for the fast direct static markdown path. */ |
@@ -52,4 +52,5 @@ /** Default timeout for the fast direct static markdown path. */ | ||
| render: "auto", | ||
| response_detail: args.response_detail ?? "compact", | ||
| timeout_ms: args.timeout_ms ?? FAST_SCRAPE_TIMEOUT_MS, | ||
| }; | ||
| } |
@@ -11,2 +11,3 @@ import assert from "node:assert/strict"; | ||
| render: "auto", | ||
| response_detail: "compact", | ||
| timeout_ms: FAST_SCRAPE_TIMEOUT_MS, | ||
@@ -13,0 +14,0 @@ }); |
@@ -8,6 +8,7 @@ export declare const WEB_SEARCH_RESULT_LIMIT = 10; | ||
| }): { | ||
| language: string; | ||
| limit: number; | ||
| query: string; | ||
| categories?: string[]; | ||
| language?: string; | ||
| }; | ||
| export declare function formatWebSearchResponse(result: Record<string, unknown>): string; |
+29
-1
| export const WEB_SEARCH_RESULT_LIMIT = 10; | ||
| export const WEB_SEARCH_CREDITS = 2; | ||
| export function buildWebSearchRequest(args) { | ||
| return { ...args, limit: WEB_SEARCH_RESULT_LIMIT }; | ||
| return { | ||
| ...args, | ||
| language: args.language ?? "en", | ||
| limit: WEB_SEARCH_RESULT_LIMIT, | ||
| }; | ||
| } | ||
| export function formatWebSearchResponse(result) { | ||
| const results = Array.isArray(result.results) ? result.results : []; | ||
| const query = typeof result.query === "string" ? result.query : ""; | ||
| const credits = typeof result.credits_charged === "number" ? result.credits_charged : WEB_SEARCH_CREDITS; | ||
| const lines = [`Query: ${query}`, `Results: ${results.length} (${credits} credits)`, ""]; | ||
| if (results.length === 0) { | ||
| lines.push("No relevant results. Try rephrasing the query."); | ||
| return lines.join("\n"); | ||
| } | ||
| for (let i = 0; i < results.length; i++) { | ||
| const row = results[i]; | ||
| const title = typeof row.title === "string" ? row.title : "Untitled"; | ||
| const url = typeof row.url === "string" ? row.url : ""; | ||
| const snippet = typeof row.snippet === "string" ? row.snippet : ""; | ||
| lines.push(`${i + 1}. ${title}`); | ||
| lines.push(` URL: ${url}`); | ||
| if (snippet) { | ||
| lines.push(` ${snippet.slice(0, 320)}${snippet.length > 320 ? "…" : ""}`); | ||
| } | ||
| lines.push(""); | ||
| } | ||
| lines.push("Answer simple questions from these snippets when possible. Only call metalift_scrape when full page content is required."); | ||
| return lines.join("\n"); | ||
| } |
| import assert from "node:assert/strict"; | ||
| import test from "node:test"; | ||
| import { buildWebSearchRequest, WEB_SEARCH_RESULT_LIMIT } from "./web-search.js"; | ||
| test("buildWebSearchRequest always uses top-10 limit", () => { | ||
| import { buildWebSearchRequest, formatWebSearchResponse, WEB_SEARCH_RESULT_LIMIT } from "./web-search.js"; | ||
| test("buildWebSearchRequest always uses top-10 limit and defaults language to en", () => { | ||
| assert.equal(WEB_SEARCH_RESULT_LIMIT, 10); | ||
| assert.deepEqual(buildWebSearchRequest({ query: "docker docs" }), { | ||
| query: "docker docs", | ||
| language: "en", | ||
| limit: 10, | ||
@@ -12,1 +13,17 @@ }); | ||
| }); | ||
| test("formatWebSearchResponse renders readable snippets", () => { | ||
| const text = formatWebSearchResponse({ | ||
| query: "docker docs", | ||
| credits_charged: 2, | ||
| results: [ | ||
| { | ||
| title: "Docker Docs", | ||
| url: "https://docs.docker.com/", | ||
| snippet: "Official Docker documentation.", | ||
| }, | ||
| ], | ||
| }); | ||
| assert.match(text, /Query: docker docs/); | ||
| assert.match(text, /Docker Docs/); | ||
| assert.match(text, /Answer simple questions from these snippets/); | ||
| }); |
+2
-2
| { | ||
| "name": "@metalift/mcp", | ||
| "mcpName": "io.github.MetaLift-AI/metalift", | ||
| "version": "1.0.7", | ||
| "version": "1.0.10", | ||
| "description": "Metalift MCP server for AI agents", | ||
@@ -16,3 +16,3 @@ "license": "SEE LICENSE IN LICENSE", | ||
| "dev": "tsx src/index.ts", | ||
| "test": "npm run build && node --test dist/client.test.js dist/scrape-args.test.js dist/web-search.test.js", | ||
| "test": "npm run build && node --test dist/client.test.js dist/scrape-args.test.js dist/scrape-format.test.js dist/web-search.test.js", | ||
| "prepublishOnly": "npm run build && node ../../scripts/npm-prepare-publish.mjs strip", | ||
@@ -19,0 +19,0 @@ "postpublish": "node ../../scripts/npm-prepare-publish.mjs restore" |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
47286
32.96%23
35.29%966
36.83%6
100%