litescrape-mcp-server 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/http.d.ts +15 -4
- package/dist/http.js +32 -9
- package/dist/index.js +10 -2
- package/dist/messages.js +3 -2
- package/dist/surfaces.js +78 -4
- package/package.json +1 -1
- package/server.json +3 -3
package/README.md
CHANGED
|
@@ -67,7 +67,7 @@ One call runs at a time per network, and failed calls never count. When a limit
|
|
|
67
67
|
|
|
68
68
|
## Add an API key
|
|
69
69
|
|
|
70
|
-
A key unlocks `google_ai_mode`, `google_ai_overview`, `google_shopping` and `
|
|
70
|
+
A key unlocks `google_ai_mode`, `google_ai_overview`, `google_shopping`, `google_reviews` and `web_fetch`, removes the daily limits and allows concurrent calls. Get one at [litescrape.com](https://litescrape.com), then set `LITESCRAPE_API_KEY` in the server's environment:
|
|
71
71
|
|
|
72
72
|
```json
|
|
73
73
|
{
|
|
@@ -100,6 +100,7 @@ With the hosted endpoint: `claude mcp add --transport http litescrape https://mc
|
|
|
100
100
|
| `google_ai_mode` | Yes | Google AI Mode's generated answer with its cited sources; `continuable` returns a follow-up token, `image_url` adds a picture to the question |
|
|
101
101
|
| `google_shopping` | Yes | The Google Shopping product grid, category blocks, sponsored listings and refinement chips; price, sale, shipping and small-business refinements, `sort_by`, pagination |
|
|
102
102
|
| `google_reviews` | Yes | The Google Maps reviews of one place by `place_id` or `data_id` (from a `google_maps` result) with `place_info`; `sort_by`, `topic_id` or `query` filters, `num`, and `next_page_token` for the next page |
|
|
103
|
+
| `web_fetch` | Yes | One public web page rendered in a fresh browser, as Markdown (default), HTML, text or a base64 PNG screenshot, with the site's `status_code`; `target_selector`, `remove_selector`, `wait_until`, `wait_for_selector`, `page_timeout`, link and image styles. Alpha |
|
|
103
104
|
|
|
104
105
|
Parameter names and accepted values follow the [Litescrape API reference](https://litescrape.com/docs).
|
|
105
106
|
|
|
@@ -141,7 +142,7 @@ Requests time out after 120 seconds by default (the API's own deadline is 90 sec
|
|
|
141
142
|
|
|
142
143
|
`litescrape-mcp-server --http` (or `LITESCRAPE_MCP_TRANSPORT=http`) serves MCP over HTTP instead of stdio: stateless `POST /mcp`, `GET /healthz`, listening on `PORT` (default 8080) and `HOST` (default `0.0.0.0`), or `--port` and `--host`. Each request's API key comes from its `Authorization: Bearer` header or `?api_key=` query parameter, so one process serves keyed and keyless callers; `LITESCRAPE_API_KEY` is ignored in this mode. This is what runs at `https://mcp.litescrape.com/mcp`.
|
|
143
144
|
|
|
144
|
-
Behind a shared address, keyless callers would all count against one allowance. `LITESCRAPE_KEYLESS_PROXY_SECRET` is the secret the Litescrape API shares with its own hosted endpoint for forwarding each caller's address
|
|
145
|
+
Behind a shared address, keyless callers would all count against one allowance. `LITESCRAPE_KEYLESS_PROXY_SECRET` is the secret the Litescrape API shares with its own hosted endpoint for forwarding each caller's address, and `LITESCRAPE_EDGE_SECRET` is the value Cloudflare adds in front of that endpoint so the caller can be read from `CF-Connecting-IP`. Neither is needed for a private deployment, where the server's own address is metered.
|
|
145
146
|
|
|
146
147
|
```
|
|
147
148
|
docker build -t litescrape-mcp-server . && docker run -p 8080:8080 litescrape-mcp-server --http
|
package/dist/http.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ export declare const MCP_PATH = "/mcp";
|
|
|
4
4
|
export declare const HEALTH_PATH = "/healthz";
|
|
5
5
|
export declare const FORWARDED_IP_HEADER = "X-Litescrape-Keyless-Ip";
|
|
6
6
|
export declare const PROXY_SECRET_HEADER = "X-Litescrape-Keyless-Secret";
|
|
7
|
+
export declare const EDGE_SECRET_HEADER = "x-litescrape-edge-secret";
|
|
8
|
+
export declare const EDGE_CLIENT_HEADER = "cf-connecting-ip";
|
|
7
9
|
export interface HttpOptions {
|
|
8
10
|
/**
|
|
9
11
|
* Secret shared with the Litescrape API. When set, every upstream request
|
|
@@ -11,6 +13,12 @@ export interface HttpOptions {
|
|
|
11
13
|
* caller rather than to this server's own address.
|
|
12
14
|
*/
|
|
13
15
|
proxySecret?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Secret Cloudflare adds to every request it relays. When set, the caller is
|
|
18
|
+
* read from CF-Connecting-IP on requests that carry it, and nothing is
|
|
19
|
+
* forwarded for requests that bypassed Cloudflare.
|
|
20
|
+
*/
|
|
21
|
+
edgeSecret?: string;
|
|
14
22
|
apiUrl?: string;
|
|
15
23
|
timeoutMs?: number;
|
|
16
24
|
fetch?: ClientOptions['fetch'];
|
|
@@ -19,11 +27,14 @@ export interface HttpOptions {
|
|
|
19
27
|
}
|
|
20
28
|
export declare function bearerToken(req: IncomingMessage): string | undefined;
|
|
21
29
|
/**
|
|
22
|
-
* The caller as the ingress reports it.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
30
|
+
* The caller as the ingress reports it. With an edge secret configured, only a
|
|
31
|
+
* request carrying it is known to have come through Cloudflare, and its caller
|
|
32
|
+
* is CF-Connecting-IP; a request that bypassed Cloudflare gets no caller at all.
|
|
33
|
+
* Without one, X-Forwarded-For is passed through unchanged: the API meters it
|
|
34
|
+
* only when it holds exactly one address, so a crafted multi-hop value is
|
|
35
|
+
* refused there instead of being trusted here.
|
|
25
36
|
*/
|
|
26
|
-
export declare function callerAddress(req: IncomingMessage): string | undefined;
|
|
37
|
+
export declare function callerAddress(req: IncomingMessage, options?: Pick<HttpOptions, 'edgeSecret'>): string | undefined;
|
|
27
38
|
export declare function clientForRequest(req: IncomingMessage, options: HttpOptions): LitescrapeClient;
|
|
28
39
|
export declare function handleRequest(req: IncomingMessage, res: ServerResponse, options: HttpOptions): Promise<void>;
|
|
29
40
|
export declare function createHttpServer(options?: HttpOptions): Server;
|
package/dist/http.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
1
2
|
import { createServer as createNodeServer, } from 'node:http';
|
|
2
3
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
3
4
|
import { LitescrapeClient } from './client.js';
|
|
@@ -9,6 +10,12 @@ export const HEALTH_PATH = '/healthz';
|
|
|
9
10
|
// shared secret, and only when it holds exactly one address.
|
|
10
11
|
export const FORWARDED_IP_HEADER = 'X-Litescrape-Keyless-Ip';
|
|
11
12
|
export const PROXY_SECRET_HEADER = 'X-Litescrape-Keyless-Secret';
|
|
13
|
+
// Behind Cloudflare the socket peer is one of its egress addresses and
|
|
14
|
+
// X-Forwarded-For is a chain, so the caller is CF-Connecting-IP; a transform
|
|
15
|
+
// rule on the zone adds the edge secret, which is how a request proves it came
|
|
16
|
+
// through Cloudflare rather than straight to the origin with forged headers.
|
|
17
|
+
export const EDGE_SECRET_HEADER = 'x-litescrape-edge-secret';
|
|
18
|
+
export const EDGE_CLIENT_HEADER = 'cf-connecting-ip';
|
|
12
19
|
function requestUrl(req) {
|
|
13
20
|
return new URL(req.url ?? '/', 'http://localhost');
|
|
14
21
|
}
|
|
@@ -20,21 +27,37 @@ export function bearerToken(req) {
|
|
|
20
27
|
const fromQuery = requestUrl(req).searchParams.get('api_key')?.trim();
|
|
21
28
|
return fromQuery || undefined;
|
|
22
29
|
}
|
|
30
|
+
function headerValue(req, name) {
|
|
31
|
+
const raw = req.headers[name];
|
|
32
|
+
return (Array.isArray(raw) ? raw.join(', ') : raw)?.trim() ?? '';
|
|
33
|
+
}
|
|
34
|
+
function sameSecret(presented, expected) {
|
|
35
|
+
const digest = (value) => createHash('sha256').update(value).digest();
|
|
36
|
+
return timingSafeEqual(digest(presented), digest(expected));
|
|
37
|
+
}
|
|
23
38
|
/**
|
|
24
|
-
* The caller as the ingress reports it.
|
|
25
|
-
*
|
|
26
|
-
*
|
|
39
|
+
* The caller as the ingress reports it. With an edge secret configured, only a
|
|
40
|
+
* request carrying it is known to have come through Cloudflare, and its caller
|
|
41
|
+
* is CF-Connecting-IP; a request that bypassed Cloudflare gets no caller at all.
|
|
42
|
+
* Without one, X-Forwarded-For is passed through unchanged: the API meters it
|
|
43
|
+
* only when it holds exactly one address, so a crafted multi-hop value is
|
|
44
|
+
* refused there instead of being trusted here.
|
|
27
45
|
*/
|
|
28
|
-
export function callerAddress(req) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
46
|
+
export function callerAddress(req, options = {}) {
|
|
47
|
+
if (options.edgeSecret) {
|
|
48
|
+
const presented = headerValue(req, EDGE_SECRET_HEADER);
|
|
49
|
+
if (!presented || !sameSecret(presented, options.edgeSecret))
|
|
50
|
+
return undefined;
|
|
51
|
+
return headerValue(req, EDGE_CLIENT_HEADER) || undefined;
|
|
52
|
+
}
|
|
53
|
+
const forwarded = headerValue(req, 'x-forwarded-for');
|
|
54
|
+
if (forwarded)
|
|
55
|
+
return forwarded;
|
|
33
56
|
return req.socket?.remoteAddress || undefined;
|
|
34
57
|
}
|
|
35
58
|
export function clientForRequest(req, options) {
|
|
36
59
|
const headers = {};
|
|
37
|
-
const caller = options.proxySecret ? callerAddress(req) : undefined;
|
|
60
|
+
const caller = options.proxySecret ? callerAddress(req, options) : undefined;
|
|
38
61
|
if (options.proxySecret && caller) {
|
|
39
62
|
headers[FORWARDED_IP_HEADER] = caller;
|
|
40
63
|
headers[PROXY_SECRET_HEADER] = options.proxySecret;
|
package/dist/index.js
CHANGED
|
@@ -19,12 +19,20 @@ function http(args) {
|
|
|
19
19
|
const port = Number(flag(args, '--port') ?? process.env.PORT ?? 8080);
|
|
20
20
|
const host = flag(args, '--host') ?? process.env.HOST ?? '0.0.0.0';
|
|
21
21
|
const proxySecret = process.env.LITESCRAPE_KEYLESS_PROXY_SECRET?.trim() || undefined;
|
|
22
|
+
const edgeSecret = process.env.LITESCRAPE_EDGE_SECRET?.trim() || undefined;
|
|
22
23
|
const apiUrl = process.env.LITESCRAPE_API_URL?.trim() || undefined;
|
|
23
24
|
const timeoutMs = Number(process.env.LITESCRAPE_TIMEOUT_MS) || undefined;
|
|
24
|
-
const server = createHttpServer({
|
|
25
|
+
const server = createHttpServer({
|
|
26
|
+
proxySecret,
|
|
27
|
+
edgeSecret,
|
|
28
|
+
apiUrl,
|
|
29
|
+
timeoutMs,
|
|
30
|
+
log: console.error,
|
|
31
|
+
});
|
|
25
32
|
server.listen(port, host, () => {
|
|
33
|
+
const forwarding = proxySecret ? (edgeSecret ? 'on, Cloudflare edge' : 'on') : 'off';
|
|
26
34
|
console.error(`${PACKAGE_NAME} ${VERSION} listening on http://${host}:${port}${MCP_PATH} ` +
|
|
27
|
-
`(API key per request, caller forwarding ${
|
|
35
|
+
`(API key per request, caller forwarding ${forwarding})`);
|
|
28
36
|
});
|
|
29
37
|
const stop = () => server.close(() => process.exit(0));
|
|
30
38
|
process.on('SIGTERM', stop);
|
package/dist/messages.js
CHANGED
|
@@ -19,14 +19,15 @@ export function instructions(keyed) {
|
|
|
19
19
|
'each successful call is billed to that key. search is google_search in fast mode ' +
|
|
20
20
|
'(organic results only). google_ai_mode generates an answer per request and is the ' +
|
|
21
21
|
'slowest tool. google_reviews takes the place_id or data_id of a google_maps result ' +
|
|
22
|
-
'and pages with next_page_token. ' +
|
|
22
|
+
'and pages with next_page_token. web_fetch returns one web page as Markdown, HTML, ' +
|
|
23
|
+
'text or a screenshot. ' +
|
|
23
24
|
shared);
|
|
24
25
|
}
|
|
25
26
|
return ('This session has no Litescrape API key. google_search (25 calls), bing_search (50), ' +
|
|
26
27
|
'google_maps (50) and duckduckgo_search (50) are free per network per UTC day, one call ' +
|
|
27
28
|
'at a time; failed calls do not count. search is google_search in fast mode (organic ' +
|
|
28
29
|
'results only) and shares its allowance. google_ai_mode, google_ai_overview, ' +
|
|
29
|
-
'google_shopping and
|
|
30
|
+
'google_shopping, google_reviews and web_fetch need an API key, which also lifts every limit: get one at ' +
|
|
30
31
|
`${KEY_URL} and set LITESCRAPE_API_KEY in this server's environment. ` +
|
|
31
32
|
shared);
|
|
32
33
|
}
|
package/dist/surfaces.js
CHANGED
|
@@ -86,9 +86,9 @@ const googleSearchBase = {
|
|
|
86
86
|
.number()
|
|
87
87
|
.int()
|
|
88
88
|
.min(1)
|
|
89
|
-
.max(
|
|
89
|
+
.max(10)
|
|
90
90
|
.optional()
|
|
91
|
-
.describe('Requested result count, 1-
|
|
91
|
+
.describe('Requested result count, 1-10; Google may return fewer'),
|
|
92
92
|
device,
|
|
93
93
|
};
|
|
94
94
|
function count(payload, key) {
|
|
@@ -127,9 +127,9 @@ export const SURFACES = [
|
|
|
127
127
|
.number()
|
|
128
128
|
.int()
|
|
129
129
|
.min(1)
|
|
130
|
-
.max(
|
|
130
|
+
.max(10)
|
|
131
131
|
.optional()
|
|
132
|
-
.describe('Requested result count, 1-
|
|
132
|
+
.describe('Requested result count, 1-10; Google may return fewer'),
|
|
133
133
|
start: z.number().int().min(0).optional().describe('Result offset for pagination'),
|
|
134
134
|
result_groups: resultGroups,
|
|
135
135
|
},
|
|
@@ -438,6 +438,80 @@ export const SURFACES = [
|
|
|
438
438
|
return `Google Reviews${name}: ${reviews} reviews${more}.`;
|
|
439
439
|
},
|
|
440
440
|
},
|
|
441
|
+
{
|
|
442
|
+
name: 'web_fetch',
|
|
443
|
+
title: 'Fetch a web page',
|
|
444
|
+
description: 'Render one public web page in a fresh browser and return it as Markdown (default), ' +
|
|
445
|
+
'HTML, plain text or a full-page PNG screenshot encoded as base64, with url, title, ' +
|
|
446
|
+
'content and the status_code the site returned. Narrow the content with ' +
|
|
447
|
+
'target_selector or remove_selector. Alpha. Requires an API key.',
|
|
448
|
+
path: '/api/web/fetch',
|
|
449
|
+
keyless: false,
|
|
450
|
+
inputSchema: {
|
|
451
|
+
url: z
|
|
452
|
+
.string()
|
|
453
|
+
.min(1)
|
|
454
|
+
.max(8192)
|
|
455
|
+
.describe('Public http:// or https:// URL on the standard port, without credentials'),
|
|
456
|
+
respond_with: z
|
|
457
|
+
.enum(['markdown', 'html', 'text', 'screenshot'])
|
|
458
|
+
.optional()
|
|
459
|
+
.describe('Output format; default markdown. screenshot returns a base64 PNG'),
|
|
460
|
+
target_selector: z
|
|
461
|
+
.string()
|
|
462
|
+
.min(1)
|
|
463
|
+
.max(2048)
|
|
464
|
+
.optional()
|
|
465
|
+
.describe('CSS selector to extract; falls back to the full page when nothing matches'),
|
|
466
|
+
remove_selector: z
|
|
467
|
+
.string()
|
|
468
|
+
.min(1)
|
|
469
|
+
.max(2048)
|
|
470
|
+
.optional()
|
|
471
|
+
.describe('CSS selector removed before extraction, e.g. "nav, footer"'),
|
|
472
|
+
wait_for_selector: z
|
|
473
|
+
.string()
|
|
474
|
+
.min(1)
|
|
475
|
+
.max(2048)
|
|
476
|
+
.optional()
|
|
477
|
+
.describe('CSS selector to wait for after navigation'),
|
|
478
|
+
wait_until: z
|
|
479
|
+
.enum(['commit', 'domcontentloaded', 'load', 'networkidle'])
|
|
480
|
+
.optional()
|
|
481
|
+
.describe('Navigation event to wait for; default domcontentloaded'),
|
|
482
|
+
page_timeout: z
|
|
483
|
+
.number()
|
|
484
|
+
.int()
|
|
485
|
+
.min(1)
|
|
486
|
+
.max(180)
|
|
487
|
+
.optional()
|
|
488
|
+
.describe('Seconds for browser operations; default 30'),
|
|
489
|
+
locale: z.string().min(2).max(64).optional().describe('Browser locale; default en-US'),
|
|
490
|
+
user_agent: z.string().min(1).max(1024).optional().describe('User-Agent to send'),
|
|
491
|
+
with_links: z
|
|
492
|
+
.enum(['inlined', 'referenced', 'collapsed', 'shortcut', 'discarded'])
|
|
493
|
+
.optional()
|
|
494
|
+
.describe('How Markdown writes links; default inlined'),
|
|
495
|
+
with_images: z
|
|
496
|
+
.enum(['all', 'alt', 'none'])
|
|
497
|
+
.optional()
|
|
498
|
+
.describe('How Markdown writes images; default all'),
|
|
499
|
+
with_iframe: z
|
|
500
|
+
.enum(['true', 'false', 'quoted'])
|
|
501
|
+
.optional()
|
|
502
|
+
.describe('Include direct child frames before extraction; default false'),
|
|
503
|
+
with_shadow_dom: z
|
|
504
|
+
.boolean()
|
|
505
|
+
.optional()
|
|
506
|
+
.describe('Expand open shadow roots before extraction; default false'),
|
|
507
|
+
},
|
|
508
|
+
summarize: (payload) => {
|
|
509
|
+
const title = typeof payload.title === 'string' && payload.title ? ` "${payload.title}"` : '';
|
|
510
|
+
const status = typeof payload.status_code === 'number' ? `, site status ${payload.status_code}` : '';
|
|
511
|
+
const size = typeof payload.content === 'string' ? `, ${payload.content.length} characters` : '';
|
|
512
|
+
return `Fetched${title}${status}${size}.`;
|
|
513
|
+
},
|
|
514
|
+
},
|
|
441
515
|
];
|
|
442
516
|
export function findSurface(name) {
|
|
443
517
|
return SURFACES.find((surface) => surface.name === name);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "litescrape-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "MCP server for the Litescrape API: Google Search, Bing, DuckDuckGo and Google Maps free without an API key, plus Google AI Mode, AI Overview, Shopping and Reviews with one. Stdio or Streamable HTTP.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://litescrape.com",
|
package/server.json
CHANGED
|
@@ -8,19 +8,19 @@
|
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
10
|
"websiteUrl": "https://litescrape.com",
|
|
11
|
-
"version": "0.
|
|
11
|
+
"version": "0.3.0",
|
|
12
12
|
"packages": [
|
|
13
13
|
{
|
|
14
14
|
"registryType": "npm",
|
|
15
15
|
"identifier": "litescrape-mcp-server",
|
|
16
|
-
"version": "0.
|
|
16
|
+
"version": "0.3.0",
|
|
17
17
|
"transport": {
|
|
18
18
|
"type": "stdio"
|
|
19
19
|
},
|
|
20
20
|
"environmentVariables": [
|
|
21
21
|
{
|
|
22
22
|
"name": "LITESCRAPE_API_KEY",
|
|
23
|
-
"description": "Optional Litescrape API key. Without it, search, google_search, bing_search, google_maps and duckduckgo_search work from a free daily allowance; with it google_ai_mode, google_ai_overview, google_shopping and
|
|
23
|
+
"description": "Optional Litescrape API key. Without it, search, google_search, bing_search, google_maps and duckduckgo_search work from a free daily allowance; with it google_ai_mode, google_ai_overview, google_shopping, google_reviews and web_fetch are available too and the limits are lifted.",
|
|
24
24
|
"isRequired": false,
|
|
25
25
|
"isSecret": true,
|
|
26
26
|
"format": "string"
|