@metalift/mcp
Advanced tools
| export {}; |
| import assert from "node:assert/strict"; | ||
| import test from "node:test"; | ||
| import { billingFromResponse, withBilling } from "./client.js"; | ||
| test("billingFromResponse parses credit headers", () => { | ||
| const response = new Response("{}", { | ||
| headers: { | ||
| "X-Metalift-Credits-Charged": "5", | ||
| "X-Metalift-Credits-Estimated": "10", | ||
| }, | ||
| }); | ||
| assert.deepEqual(billingFromResponse(response), { | ||
| credits_charged: 5, | ||
| credits_estimated: 10, | ||
| }); | ||
| }); | ||
| test("billingFromResponse returns null when headers are missing", () => { | ||
| const response = new Response("{}"); | ||
| assert.deepEqual(billingFromResponse(response), { | ||
| credits_charged: null, | ||
| credits_estimated: null, | ||
| }); | ||
| }); | ||
| test("withBilling merges billing fields into API payload", () => { | ||
| const merged = withBilling({ success: true }, { credits_charged: 1, credits_estimated: null }); | ||
| assert.equal(merged.success, true); | ||
| assert.equal(merged.credits_charged, 1); | ||
| assert.equal(merged.credits_estimated, null); | ||
| }); |
| export type ScrapeFormat = "markdown" | "html" | "text" | "json"; | ||
| export type ScrapeRender = "static" | "dynamic" | "auto"; | ||
| export type ScrapeProxy = "auto" | "direct" | "residential" | "datacenter"; | ||
| export interface ScrapeArgs { | ||
| url: string; | ||
| formats?: ScrapeFormat[]; | ||
| render?: ScrapeRender; | ||
| only_main_content?: boolean; | ||
| timeout_ms?: number; | ||
| wait_for?: string; | ||
| screenshot?: boolean; | ||
| proxy?: ScrapeProxy; | ||
| strategy?: string; | ||
| cookies?: Record<string, string>; | ||
| cookie_header?: string; | ||
| } | ||
| /** Default timeout for the fast direct static markdown path. */ | ||
| export declare const FAST_SCRAPE_TIMEOUT_MS = 10000; | ||
| /** | ||
| * Apply fast defaults for plain URL-to-markdown scrapes. | ||
| * Preserves full auto/browser/proxy behavior when the caller opts in explicitly. | ||
| */ | ||
| export declare function normalizeScrapeArgs(args: ScrapeArgs): ScrapeArgs; |
| /** Default timeout for the fast direct static markdown path. */ | ||
| export const FAST_SCRAPE_TIMEOUT_MS = 10_000; | ||
| function isMarkdownOnlyFormats(formats) { | ||
| return formats === undefined || (formats.length === 1 && formats[0] === "markdown"); | ||
| } | ||
| /** | ||
| * Apply fast defaults for plain URL-to-markdown scrapes. | ||
| * Preserves full auto/browser/proxy behavior when the caller opts in explicitly. | ||
| */ | ||
| export function normalizeScrapeArgs(args) { | ||
| const hasAdvancedOpts = args.strategy !== undefined || | ||
| args.render !== undefined || | ||
| args.proxy !== undefined || | ||
| args.screenshot !== undefined || | ||
| args.wait_for !== undefined || | ||
| args.cookies !== undefined || | ||
| args.cookie_header !== undefined || | ||
| !isMarkdownOnlyFormats(args.formats); | ||
| if (hasAdvancedOpts) { | ||
| return { ...args }; | ||
| } | ||
| return { | ||
| ...args, | ||
| formats: args.formats ?? ["markdown"], | ||
| strategy: "article", | ||
| render: "static", | ||
| proxy: "direct", | ||
| timeout_ms: args.timeout_ms ?? FAST_SCRAPE_TIMEOUT_MS, | ||
| }; | ||
| } |
| export {}; |
| import assert from "node:assert/strict"; | ||
| import test from "node:test"; | ||
| import { FAST_SCRAPE_TIMEOUT_MS, normalizeScrapeArgs } from "./scrape-args.js"; | ||
| const BASE_URL = "https://example.com/docs"; | ||
| test("normalizeScrapeArgs applies fast defaults for plain markdown scrape", () => { | ||
| assert.deepEqual(normalizeScrapeArgs({ url: BASE_URL }), { | ||
| url: BASE_URL, | ||
| formats: ["markdown"], | ||
| strategy: "article", | ||
| render: "static", | ||
| proxy: "direct", | ||
| timeout_ms: FAST_SCRAPE_TIMEOUT_MS, | ||
| }); | ||
| }); | ||
| test("normalizeScrapeArgs preserves explicit timeout_ms", () => { | ||
| assert.equal(normalizeScrapeArgs({ url: BASE_URL, timeout_ms: 30_000 }).timeout_ms, 30_000); | ||
| }); | ||
| test("normalizeScrapeArgs preserves explicit strategy auto", () => { | ||
| const args = normalizeScrapeArgs({ url: BASE_URL, strategy: "auto" }); | ||
| assert.equal(args.strategy, "auto"); | ||
| assert.equal(args.render, undefined); | ||
| assert.equal(args.proxy, undefined); | ||
| }); | ||
| test("normalizeScrapeArgs preserves explicit specialized strategy", () => { | ||
| const args = normalizeScrapeArgs({ url: BASE_URL, strategy: "cloudflare" }); | ||
| assert.equal(args.strategy, "cloudflare"); | ||
| assert.equal(args.render, undefined); | ||
| }); | ||
| test("normalizeScrapeArgs preserves explicit render auto", () => { | ||
| const args = normalizeScrapeArgs({ url: BASE_URL, render: "auto" }); | ||
| assert.equal(args.render, "auto"); | ||
| assert.equal(args.strategy, undefined); | ||
| }); | ||
| test("normalizeScrapeArgs preserves explicit render dynamic", () => { | ||
| const args = normalizeScrapeArgs({ url: BASE_URL, render: "dynamic" }); | ||
| assert.equal(args.render, "dynamic"); | ||
| }); | ||
| test("normalizeScrapeArgs preserves explicit proxy auto", () => { | ||
| const args = normalizeScrapeArgs({ url: BASE_URL, proxy: "auto" }); | ||
| assert.equal(args.proxy, "auto"); | ||
| }); | ||
| test("normalizeScrapeArgs preserves explicit premium proxy", () => { | ||
| const args = normalizeScrapeArgs({ url: BASE_URL, proxy: "residential" }); | ||
| assert.equal(args.proxy, "residential"); | ||
| }); | ||
| test("normalizeScrapeArgs preserves screenshot requests", () => { | ||
| const args = normalizeScrapeArgs({ url: BASE_URL, screenshot: true }); | ||
| assert.equal(args.screenshot, true); | ||
| assert.equal(args.strategy, undefined); | ||
| }); | ||
| test("normalizeScrapeArgs preserves wait_for selector", () => { | ||
| const args = normalizeScrapeArgs({ url: BASE_URL, wait_for: "main" }); | ||
| assert.equal(args.wait_for, "main"); | ||
| }); | ||
| test("normalizeScrapeArgs preserves cookies", () => { | ||
| const args = normalizeScrapeArgs({ url: BASE_URL, cookies: { sid: "abc" } }); | ||
| assert.deepEqual(args.cookies, { sid: "abc" }); | ||
| }); | ||
| test("normalizeScrapeArgs preserves cookie_header", () => { | ||
| const args = normalizeScrapeArgs({ url: BASE_URL, cookie_header: "sid=abc" }); | ||
| assert.equal(args.cookie_header, "sid=abc"); | ||
| }); | ||
| test("normalizeScrapeArgs preserves non-markdown formats", () => { | ||
| const args = normalizeScrapeArgs({ url: BASE_URL, formats: ["html"] }); | ||
| assert.deepEqual(args.formats, ["html"]); | ||
| assert.equal(args.strategy, undefined); | ||
| }); | ||
| test("normalizeScrapeArgs preserves multi-format requests", () => { | ||
| const args = normalizeScrapeArgs({ url: BASE_URL, formats: ["markdown", "html"] }); | ||
| assert.deepEqual(args.formats, ["markdown", "html"]); | ||
| }); | ||
| test("normalizeScrapeArgs treats explicit markdown-only formats as fast path", () => { | ||
| const args = normalizeScrapeArgs({ url: BASE_URL, formats: ["markdown"] }); | ||
| assert.equal(args.strategy, "article"); | ||
| assert.equal(args.render, "static"); | ||
| assert.equal(args.proxy, "direct"); | ||
| }); |
+18
-11
@@ -0,1 +1,2 @@ | ||
| import type { ScrapeArgs } from "./scrape-args.js"; | ||
| export interface MetaliftClientOptions { | ||
@@ -5,2 +6,8 @@ apiUrl?: string; | ||
| } | ||
| export interface BillingMeta { | ||
| credits_charged: number | null; | ||
| credits_estimated: number | null; | ||
| } | ||
| export declare function billingFromResponse(response: Response): BillingMeta; | ||
| export declare function withBilling<T extends Record<string, unknown>>(body: T, billing: BillingMeta): T & BillingMeta; | ||
| export declare class MetaliftClient { | ||
@@ -11,21 +18,21 @@ private apiUrl; | ||
| private headers; | ||
| request<T>(path: string, init?: RequestInit): Promise<T>; | ||
| scrape(params: Record<string, unknown>): Promise<unknown>; | ||
| batch(params: Record<string, unknown>): Promise<unknown>; | ||
| crawl(params: Record<string, unknown>): Promise<unknown>; | ||
| map(params: Record<string, unknown>): Promise<unknown>; | ||
| jobStatus(jobId: string): Promise<unknown>; | ||
| request<T extends Record<string, unknown>>(path: string, init?: RequestInit): Promise<T & BillingMeta>; | ||
| scrape(params: ScrapeArgs | Record<string, unknown>): Promise<Record<string, unknown> & BillingMeta>; | ||
| batch(params: Record<string, unknown>): Promise<Record<string, unknown> & BillingMeta>; | ||
| crawl(params: Record<string, unknown>): Promise<Record<string, unknown> & BillingMeta>; | ||
| map(params: Record<string, unknown>): Promise<Record<string, unknown> & BillingMeta>; | ||
| jobStatus(jobId: string): Promise<Record<string, unknown> & BillingMeta>; | ||
| listStrategies(): Promise<{ | ||
| success: boolean; | ||
| strategies: unknown[]; | ||
| }>; | ||
| } & BillingMeta>; | ||
| listProtectionTypes(): Promise<{ | ||
| success: boolean; | ||
| protection_types: unknown[]; | ||
| }>; | ||
| } & BillingMeta>; | ||
| listSessions(): Promise<{ | ||
| success: boolean; | ||
| sessions: unknown[]; | ||
| }>; | ||
| warmSession(params: Record<string, unknown>): Promise<unknown>; | ||
| } & BillingMeta>; | ||
| warmSession(params: Record<string, unknown>): Promise<Record<string, unknown> & BillingMeta>; | ||
| health(): Promise<{ | ||
@@ -35,3 +42,3 @@ status: string; | ||
| browser_ready?: boolean; | ||
| }>; | ||
| } & BillingMeta>; | ||
| } |
+38
-4
@@ -0,1 +1,22 @@ | ||
| const B2B_ATTESTATION_HEADER = "X-B2B-Attestation"; | ||
| const B2B_ATTESTATION_VALUE = "I-confirm-B2B-use"; | ||
| const CLIENT_ID_HEADER = "X-Metalift-Client"; | ||
| const MCP_CLIENT_VERSION = "1.0.3"; | ||
| const CREDITS_CHARGED_HEADER = "X-Metalift-Credits-Charged"; | ||
| const CREDITS_ESTIMATED_HEADER = "X-Metalift-Credits-Estimated"; | ||
| function parseHeaderInt(value) { | ||
| if (value == null || value === "") | ||
| return null; | ||
| const parsed = Number(value); | ||
| return Number.isFinite(parsed) ? parsed : null; | ||
| } | ||
| export function billingFromResponse(response) { | ||
| return { | ||
| credits_charged: parseHeaderInt(response.headers.get(CREDITS_CHARGED_HEADER)), | ||
| credits_estimated: parseHeaderInt(response.headers.get(CREDITS_ESTIMATED_HEADER)), | ||
| }; | ||
| } | ||
| export function withBilling(body, billing) { | ||
| return { ...body, ...billing }; | ||
| } | ||
| export class MetaliftClient { | ||
@@ -9,3 +30,7 @@ apiUrl; | ||
| headers() { | ||
| const headers = { "Content-Type": "application/json" }; | ||
| const headers = { | ||
| "Content-Type": "application/json", | ||
| [B2B_ATTESTATION_HEADER]: B2B_ATTESTATION_VALUE, | ||
| [CLIENT_ID_HEADER]: `mcp/${MCP_CLIENT_VERSION}`, | ||
| }; | ||
| if (this.apiKey) { | ||
@@ -21,7 +46,16 @@ headers.Authorization = `Bearer ${this.apiKey}`; | ||
| }); | ||
| const body = await response.json(); | ||
| const body = (await response.json()); | ||
| if (!response.ok) { | ||
| throw new Error(body.detail || body.error || `Request failed: ${response.status}`); | ||
| const detail = typeof body === "object" && body !== null && "detail" in body | ||
| ? body.detail | ||
| : undefined; | ||
| const error = typeof body === "object" && body !== null && "error" in body | ||
| ? body.error | ||
| : undefined; | ||
| const message = (typeof detail === "string" && detail) || | ||
| (typeof error === "string" && error) || | ||
| `Request failed: ${response.status}`; | ||
| throw new Error(message); | ||
| } | ||
| return body; | ||
| return withBilling(body, billingFromResponse(response)); | ||
| } | ||
@@ -28,0 +62,0 @@ scrape(params) { |
+5
-4
@@ -6,2 +6,3 @@ #!/usr/bin/env node | ||
| import { MetaliftClient } from "./client.js"; | ||
| import { normalizeScrapeArgs } from "./scrape-args.js"; | ||
| const COMPLIANCE_NOTICE = "You are solely responsible for complying with website terms, robots.txt, copyright, and data protection laws when using scraped content."; | ||
@@ -15,3 +16,3 @@ const client = new MetaliftClient(); | ||
| title: "Scrape URL", | ||
| description: `Scrape a single URL into markdown, HTML, or text for LLM context. ${COMPLIANCE_NOTICE}`, | ||
| description: `Scrape a single URL into markdown, HTML, or text for LLM context. Default: fast direct static article extraction (strategy=article, render=static, proxy=direct, 10s timeout). 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: { | ||
@@ -39,3 +40,3 @@ url: z.string().url(), | ||
| }, async (args) => { | ||
| const result = await client.scrape(args); | ||
| const result = await client.scrape(normalizeScrapeArgs(args)); | ||
| return { | ||
@@ -47,3 +48,3 @@ content: [{ type: "text", text: JSON.stringify(result, null, 2) }], | ||
| title: "Batch Scrape URLs", | ||
| description: `Scrape multiple URLs in parallel. ${COMPLIANCE_NOTICE}`, | ||
| description: `Scrape multiple URLs in parallel. Response includes credits_charged (per-page usage billing). ${COMPLIANCE_NOTICE}`, | ||
| inputSchema: { | ||
@@ -69,3 +70,3 @@ urls: z.array(z.string().url()).min(1).max(100), | ||
| title: "Crawl Website", | ||
| description: "Crawl a website starting from a URL and return markdown for discovered pages.", | ||
| description: "Crawl a website starting from a URL and return markdown for discovered pages. Job creation shows credits_estimated; poll metalift_job_status for credits_charged as pages complete.", | ||
| inputSchema: { | ||
@@ -72,0 +73,0 @@ url: z.string().url(), |
+2
-1
| { | ||
| "name": "@metalift/mcp", | ||
| "mcpName": "io.github.MetaLift-AI/metalift", | ||
| "version": "1.0.2", | ||
| "version": "1.0.5", | ||
| "description": "Metalift MCP server for AI agents", | ||
@@ -16,2 +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", | ||
| "prepublishOnly": "npm run build && node ../../scripts/npm-prepare-publish.mjs strip", | ||
@@ -18,0 +19,0 @@ "postpublish": "node ../../scripts/npm-prepare-publish.mjs restore" |
+15
-3
@@ -16,5 +16,5 @@ # @metalift/mcp | ||
| ## Install (after npm publish) | ||
| ## Install | ||
| Add to your MCP client config: | ||
| Published on npm as `@metalift/mcp`. Default config uses `npx`: | ||
@@ -36,2 +36,14 @@ ```json | ||
| **Corporate Windows / SSL inspection:** if `npx` fails with `UNABLE_TO_VERIFY_LEAF_SIGNATURE`, install locally and use `node` — see [examples/cursor-mcp-local.json](../../examples/cursor-mcp-local.json) and [MCP setup troubleshooting](../../packages/platform-web/docs/mcp-setup.md#troubleshooting). | ||
| ## Troubleshooting | ||
| | Error | Cause | Fix | | ||
| |-------|-------|-----| | ||
| | `Failed to acquire MessagePort` | Cursor IDE bug on Windows | Reload window, restart Cursor — [details](../../packages/platform-web/docs/mcp-setup.md#failed-to-acquire-messageport-cursor--vs-code) | | ||
| | `UNABLE_TO_VERIFY_LEAF_SIGNATURE` | Corporate SSL inspection blocks npm | Local install + `node` path — [details](../../packages/platform-web/docs/mcp-setup.md#unable_to_verify_leaf_signature-npm--npx) | | ||
| | 401 / 402 at runtime | Auth or billing | Check API key and subscription | | ||
| Full guide: [packages/platform-web/docs/mcp-setup.md](../../packages/platform-web/docs/mcp-setup.md). | ||
| ## Environment variables | ||
@@ -48,3 +60,3 @@ | ||
| |------|-------------| | ||
| | `metalift_scrape` | Scrape a single URL | | ||
| | `metalift_scrape` | Scrape a single URL (default: fast direct static markdown; use `strategy: "auto"` for WAF/SPA/retail) | | ||
| | `metalift_batch_scrape` | Scrape multiple URLs | | ||
@@ -51,0 +63,0 @@ | `metalift_crawl` | Crawl a website | |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
No tests
QualityPackage does not have any tests. This is a strong signal of a poorly maintained or low quality package.
28131
58.13%13
85.71%545
59.36%1
-50%67
21.82%4
33.33%