litescrape-mcp-server 0.2.0 → 0.2.1

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 CHANGED
@@ -141,7 +141,7 @@ Requests time out after 120 seconds by default (the API's own deadline is 90 sec
141
141
 
142
142
  `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
143
 
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; it is not needed for a private deployment, where the server's own address is metered.
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, 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
145
 
146
146
  ```
147
147
  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. X-Forwarded-For is passed through
23
- * unchanged: the API meters it only when it holds exactly one address, so a
24
- * crafted multi-hop value is refused there instead of being trusted here.
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. X-Forwarded-For is passed through
25
- * unchanged: the API meters it only when it holds exactly one address, so a
26
- * crafted multi-hop value is refused there instead of being trusted here.
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
- const forwarded = req.headers['x-forwarded-for'];
30
- const value = (Array.isArray(forwarded) ? forwarded.join(', ') : forwarded)?.trim();
31
- if (value)
32
- return value;
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({ proxySecret, apiUrl, timeoutMs, log: console.error });
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 ${proxySecret ? 'on' : 'off'})`);
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "litescrape-mcp-server",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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,12 +8,12 @@
8
8
  "source": "github"
9
9
  },
10
10
  "websiteUrl": "https://litescrape.com",
11
- "version": "0.2.0",
11
+ "version": "0.2.1",
12
12
  "packages": [
13
13
  {
14
14
  "registryType": "npm",
15
15
  "identifier": "litescrape-mcp-server",
16
- "version": "0.2.0",
16
+ "version": "0.2.1",
17
17
  "transport": {
18
18
  "type": "stdio"
19
19
  },