litescrape-mcp-server 0.1.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/LICENSE +21 -0
- package/README.md +147 -0
- package/dist/client.d.ts +46 -0
- package/dist/client.js +146 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +16 -0
- package/dist/messages.d.ts +5 -0
- package/dist/messages.js +31 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +86 -0
- package/dist/surfaces.d.ts +16 -0
- package/dist/surfaces.js +389 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +4 -0
- package/package.json +75 -0
- package/server.json +21 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Litescrape
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Litescrape MCP Server
|
|
2
|
+
|
|
3
|
+
Google Search, Bing, DuckDuckGo and Google Maps results for any MCP client, free and without an API key. Add a key for Google AI Mode, Google AI Overview and Google Shopping, and to lift every limit.
|
|
4
|
+
|
|
5
|
+
Results come straight from the search engines' own pages as structured JSON, through the [Litescrape API](https://litescrape.com). Links are real destinations, never redirects, and nothing is cached: every call runs fresh.
|
|
6
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
No account, no key, one line.
|
|
10
|
+
|
|
11
|
+
**Claude Code**
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
claude mcp add litescrape -- npx -y litescrape-mcp-server
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
**Cursor, Windsurf, Claude Desktop, Codex, Gemini CLI and most other clients** (`mcp.json`, `claude_desktop_config.json`, or the client's MCP settings)
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"mcpServers": {
|
|
22
|
+
"litescrape": {
|
|
23
|
+
"command": "npx",
|
|
24
|
+
"args": ["-y", "litescrape-mcp-server"]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**VS Code**
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
code --add-mcp '{"name":"litescrape","command":"npx","args":["-y","litescrape-mcp-server"]}'
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Codex CLI**
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
codex mcp add litescrape -- npx -y litescrape-mcp-server
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Gemini CLI**
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
gemini mcp add litescrape npx -y litescrape-mcp-server
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Requires Node.js 20 or newer.
|
|
49
|
+
|
|
50
|
+
## What you get without a key
|
|
51
|
+
|
|
52
|
+
| Tool | Returns | Free calls per network per UTC day |
|
|
53
|
+
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
|
|
54
|
+
| `search` | Google organic results in fast mode (title, link, snippet); shares the `google_search` allowance | 25 |
|
|
55
|
+
| `google_search` | The full Google results page: organic results, Knowledge Graph, AI Overview, ads, related questions and every other module Google served | 25 |
|
|
56
|
+
| `bing_search` | Bing organic results, answer boxes, Knowledge Graph, Copilot answer and more | 50 |
|
|
57
|
+
| `duckduckgo_search` | DuckDuckGo organic results with region, safety and date filters | 50 |
|
|
58
|
+
| `google_maps` | Google Maps places (name, address, rating, hours, phone, website, coordinates) or one exact place | 50 |
|
|
59
|
+
|
|
60
|
+
One call runs at a time per network, and failed calls never count. When a limit is reached, the tool result says so and tells the model exactly how to add a key, so the agent can relay it to you.
|
|
61
|
+
|
|
62
|
+
## Add an API key
|
|
63
|
+
|
|
64
|
+
A key unlocks `google_ai_mode`, `google_ai_overview` and `google_shopping`, 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:
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"mcpServers": {
|
|
69
|
+
"litescrape": {
|
|
70
|
+
"command": "npx",
|
|
71
|
+
"args": ["-y", "litescrape-mcp-server"],
|
|
72
|
+
"env": {
|
|
73
|
+
"LITESCRAPE_API_KEY": "ls_live_..."
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
With Claude Code: `claude mcp add litescrape -e LITESCRAPE_API_KEY=ls_live_... -- npx -y litescrape-mcp-server`
|
|
81
|
+
|
|
82
|
+
## Tools
|
|
83
|
+
|
|
84
|
+
| Tool | Key needed | What it does |
|
|
85
|
+
| -------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
86
|
+
| `search` | No | Web search through Google, organic results only. Parameters: `q`, `gl`, `hl`, `location`, `num`, `start` |
|
|
87
|
+
| `google_search` | No | Full Google Search page. Localization (`gl`, `hl`, `location`, `uule`, `lat`/`lon`), verticals (`tbm`: news, videos, local, shopping, patents), date filters (`tbs`, `as_qdr`), site restriction (`as_sitesearch`), pagination (`start`, `num`), `device`, `fast_mode` |
|
|
88
|
+
| `bing_search` | No | Bing web search with `mkt`/`cc`, `location`, `lat`/`lon`, `first`, `safeSearch`, `filters`, `device` |
|
|
89
|
+
| `duckduckgo_search` | No | DuckDuckGo web search with `kl`, `safe`, `df`, `start`, `m` |
|
|
90
|
+
| `google_maps` | No | Places by query inside a viewport (`ll`, `location`, `lat`/`lon` with `z` or `m`) with price, rating and opening-hours filters, or one exact place by `place_id`, `data_cid` or `data` |
|
|
91
|
+
| `google_ai_overview` | Yes | Only the AI Overview for a Google search, or `null` when Google shows none |
|
|
92
|
+
| `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 |
|
|
93
|
+
| `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 |
|
|
94
|
+
|
|
95
|
+
Parameter names and accepted values follow the [Litescrape API reference](https://litescrape.com/docs).
|
|
96
|
+
|
|
97
|
+
Every tool accepts `result_groups`, a list of top-level groups to keep (for example `["organic_results", "knowledge_graph"]`) so the model's context stays small. `search_metadata` is always included.
|
|
98
|
+
|
|
99
|
+
## Results
|
|
100
|
+
|
|
101
|
+
A tool result is one text block: a one-line summary, a blank line, then the API's JSON response unchanged.
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
Google Search for "espresso machine": 10 organic results; also knowledge_graph, related_questions, ai_overview. Free allowance: 24 of 25 google_search calls left today (no API key set).
|
|
105
|
+
|
|
106
|
+
{"search_metadata":{...},"search_parameters":{...},"organic_results":[...],...}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The same JSON is also returned as `structuredContent` for clients that read it.
|
|
110
|
+
|
|
111
|
+
## Limits and errors
|
|
112
|
+
|
|
113
|
+
| What happened | Tool result |
|
|
114
|
+
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
115
|
+
| Daily allowance for that tool is used up | Error text naming the tool and the limit, when it resets (00:00 UTC), and how to add a key |
|
|
116
|
+
| A keyless call is already in flight | The server waits for the API's `Retry-After` (a few seconds) and retries up to twice before reporting the limit |
|
|
117
|
+
| A key-only tool was called without a key | Error text naming the tool and the free alternatives, and how to add a key; no request is made |
|
|
118
|
+
| The upstream page could not be fetched | Error text with the API's `error_code`, HTTP status and `request_id`; retryable failures are retried twice, and failed calls are not charged |
|
|
119
|
+
| Invalid arguments | Rejected by the tool schema before any request |
|
|
120
|
+
|
|
121
|
+
Requests time out after 120 seconds by default (the API's own deadline is 90 seconds). AI Mode is the slowest tool.
|
|
122
|
+
|
|
123
|
+
## Configuration
|
|
124
|
+
|
|
125
|
+
| Environment variable | Default | Meaning |
|
|
126
|
+
| ----------------------- | ---------------------------- | -------------------------------------------------- |
|
|
127
|
+
| `LITESCRAPE_API_KEY` | unset | Bearer key; unset means the free keyless allowance |
|
|
128
|
+
| `LITESCRAPE_API_URL` | `https://api.litescrape.com` | API origin, for self-hosted or staging deployments |
|
|
129
|
+
| `LITESCRAPE_TIMEOUT_MS` | `120000` | Per-request timeout in milliseconds |
|
|
130
|
+
|
|
131
|
+
Every request identifies itself with `X-Litescrape-Client: mcp/<version>` and a `litescrape-mcp-server/<version>` user agent. The server writes protocol messages to stdout and its logs to stderr.
|
|
132
|
+
|
|
133
|
+
## Development
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
npm install
|
|
137
|
+
npm run lint # prettier + tsc
|
|
138
|
+
npm test # vitest, including a real stdio round trip
|
|
139
|
+
npm run build # tsc -> dist/
|
|
140
|
+
npm run inspector # MCP Inspector against dist/index.js
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`npm run dev` runs the server from source with tsx. Releases are tagged with the package version (for example `0.1.0`); the publish workflow builds and publishes to npm with provenance.
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
[MIT](LICENSE)
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export declare const DEFAULT_API_URL = "https://api.litescrape.com";
|
|
2
|
+
export declare const DEFAULT_TIMEOUT_MS = 120000;
|
|
3
|
+
export declare const MAX_RETRIES = 2;
|
|
4
|
+
export type Params = Record<string, string | number | boolean | undefined>;
|
|
5
|
+
export type Payload = Record<string, unknown>;
|
|
6
|
+
export interface KeylessAllowance {
|
|
7
|
+
limit: number;
|
|
8
|
+
remaining: number;
|
|
9
|
+
}
|
|
10
|
+
export interface ApiResult {
|
|
11
|
+
payload: Payload;
|
|
12
|
+
requestId?: string;
|
|
13
|
+
/** Present when the call was served from the keyless allowance. */
|
|
14
|
+
keyless?: KeylessAllowance;
|
|
15
|
+
}
|
|
16
|
+
export declare class LitescrapeError extends Error {
|
|
17
|
+
readonly status: number;
|
|
18
|
+
readonly errorCode: string;
|
|
19
|
+
readonly requestId: string | undefined;
|
|
20
|
+
readonly retryable: boolean;
|
|
21
|
+
readonly retryAfterSeconds?: number | undefined;
|
|
22
|
+
constructor(message: string, status: number, errorCode: string, requestId: string | undefined, retryable: boolean, retryAfterSeconds?: number | undefined);
|
|
23
|
+
}
|
|
24
|
+
export interface ClientOptions {
|
|
25
|
+
apiKey?: string;
|
|
26
|
+
apiUrl?: string;
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
fetch?: typeof globalThis.fetch;
|
|
29
|
+
sleep?: (ms: number) => Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
export declare class LitescrapeClient {
|
|
32
|
+
readonly apiUrl: string;
|
|
33
|
+
readonly timeoutMs: number;
|
|
34
|
+
private readonly apiKey;
|
|
35
|
+
private readonly fetchImpl;
|
|
36
|
+
private readonly sleep;
|
|
37
|
+
constructor(options?: ClientOptions);
|
|
38
|
+
static fromEnv(env: NodeJS.ProcessEnv): LitescrapeClient;
|
|
39
|
+
get keyed(): boolean;
|
|
40
|
+
get userAgent(): string;
|
|
41
|
+
url(path: string, params: Params): string;
|
|
42
|
+
headers(): Record<string, string>;
|
|
43
|
+
get(path: string, params: Params): Promise<ApiResult>;
|
|
44
|
+
private once;
|
|
45
|
+
}
|
|
46
|
+
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { PACKAGE_NAME, VERSION } from './version.js';
|
|
2
|
+
export const DEFAULT_API_URL = 'https://api.litescrape.com';
|
|
3
|
+
export const DEFAULT_TIMEOUT_MS = 120_000;
|
|
4
|
+
export const MAX_RETRIES = 2;
|
|
5
|
+
const MAX_RETRY_WAIT_MS = 10_000;
|
|
6
|
+
export class LitescrapeError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
errorCode;
|
|
9
|
+
requestId;
|
|
10
|
+
retryable;
|
|
11
|
+
retryAfterSeconds;
|
|
12
|
+
constructor(message, status, errorCode, requestId, retryable, retryAfterSeconds) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.errorCode = errorCode;
|
|
16
|
+
this.requestId = requestId;
|
|
17
|
+
this.retryable = retryable;
|
|
18
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
19
|
+
this.name = 'LitescrapeError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
23
|
+
function positiveInteger(value) {
|
|
24
|
+
const parsed = Number(value);
|
|
25
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
26
|
+
}
|
|
27
|
+
export class LitescrapeClient {
|
|
28
|
+
apiUrl;
|
|
29
|
+
timeoutMs;
|
|
30
|
+
apiKey;
|
|
31
|
+
fetchImpl;
|
|
32
|
+
sleep;
|
|
33
|
+
constructor(options = {}) {
|
|
34
|
+
this.apiKey = (options.apiKey ?? '').trim();
|
|
35
|
+
this.apiUrl = (options.apiUrl ?? DEFAULT_API_URL).replace(/\/+$/, '');
|
|
36
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
37
|
+
this.fetchImpl = options.fetch ?? globalThis.fetch;
|
|
38
|
+
this.sleep = options.sleep ?? defaultSleep;
|
|
39
|
+
}
|
|
40
|
+
static fromEnv(env) {
|
|
41
|
+
return new LitescrapeClient({
|
|
42
|
+
apiKey: env.LITESCRAPE_API_KEY,
|
|
43
|
+
apiUrl: env.LITESCRAPE_API_URL?.trim() || undefined,
|
|
44
|
+
timeoutMs: positiveInteger(env.LITESCRAPE_TIMEOUT_MS),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
get keyed() {
|
|
48
|
+
return this.apiKey.length > 0;
|
|
49
|
+
}
|
|
50
|
+
get userAgent() {
|
|
51
|
+
return `${PACKAGE_NAME}/${VERSION} (node ${process.versions.node}; ${process.platform})`;
|
|
52
|
+
}
|
|
53
|
+
url(path, params) {
|
|
54
|
+
const query = new URLSearchParams();
|
|
55
|
+
for (const [key, value] of Object.entries(params)) {
|
|
56
|
+
if (value === undefined)
|
|
57
|
+
continue;
|
|
58
|
+
query.set(key, typeof value === 'boolean' ? String(value) : String(value));
|
|
59
|
+
}
|
|
60
|
+
const encoded = query.toString();
|
|
61
|
+
return `${this.apiUrl}${path}${encoded ? `?${encoded}` : ''}`;
|
|
62
|
+
}
|
|
63
|
+
headers() {
|
|
64
|
+
const headers = {
|
|
65
|
+
Accept: 'application/json',
|
|
66
|
+
'User-Agent': this.userAgent,
|
|
67
|
+
'X-Litescrape-Client': `mcp/${VERSION}`,
|
|
68
|
+
};
|
|
69
|
+
if (this.keyed)
|
|
70
|
+
headers.Authorization = `Bearer ${this.apiKey}`;
|
|
71
|
+
return headers;
|
|
72
|
+
}
|
|
73
|
+
async get(path, params) {
|
|
74
|
+
for (let attempt = 0;; attempt += 1) {
|
|
75
|
+
try {
|
|
76
|
+
return await this.once(path, params);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (!(error instanceof LitescrapeError) || !error.retryable || attempt >= MAX_RETRIES) {
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
const backoff = 1_000 * 2 ** attempt;
|
|
83
|
+
const wait = error.retryAfterSeconds ? error.retryAfterSeconds * 1_000 : backoff;
|
|
84
|
+
await this.sleep(Math.min(wait, MAX_RETRY_WAIT_MS));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async once(path, params) {
|
|
89
|
+
const controller = new AbortController();
|
|
90
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
91
|
+
let response;
|
|
92
|
+
try {
|
|
93
|
+
response = await this.fetchImpl(this.url(path, params), {
|
|
94
|
+
method: 'GET',
|
|
95
|
+
headers: this.headers(),
|
|
96
|
+
signal: controller.signal,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (controller.signal.aborted) {
|
|
101
|
+
throw new LitescrapeError(`The request did not complete within ${this.timeoutMs / 1000} seconds.`, 0, 'timeout', undefined, true);
|
|
102
|
+
}
|
|
103
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
104
|
+
throw new LitescrapeError(`Could not reach ${this.apiUrl}: ${reason}`, 0, 'network_error', undefined, true);
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
}
|
|
109
|
+
const requestId = response.headers.get('x-request-id') ?? undefined;
|
|
110
|
+
const body = await response.text();
|
|
111
|
+
let parsed = null;
|
|
112
|
+
try {
|
|
113
|
+
parsed = body ? JSON.parse(body) : null;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
parsed = null;
|
|
117
|
+
}
|
|
118
|
+
const payload = isRecord(parsed) ? parsed : null;
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
const message = typeof payload?.error === 'string' && payload.error
|
|
121
|
+
? payload.error
|
|
122
|
+
: `Litescrape returned HTTP ${response.status}.`;
|
|
123
|
+
const errorCode = typeof payload?.error_code === 'string' && payload.error_code
|
|
124
|
+
? payload.error_code
|
|
125
|
+
: `http_${response.status}`;
|
|
126
|
+
const retryable = typeof payload?.retryable === 'boolean'
|
|
127
|
+
? payload.retryable
|
|
128
|
+
: [429, 502, 503, 504].includes(response.status);
|
|
129
|
+
const retryAfter = positiveInteger(response.headers.get('retry-after') ?? undefined);
|
|
130
|
+
throw new LitescrapeError(message, response.status, errorCode, typeof payload?.request_id === 'string' ? payload.request_id : requestId, retryable, retryAfter);
|
|
131
|
+
}
|
|
132
|
+
if (payload === null) {
|
|
133
|
+
throw new LitescrapeError('Litescrape returned a response that is not a JSON object.', response.status, 'invalid_response', requestId, false);
|
|
134
|
+
}
|
|
135
|
+
const limit = positiveInteger(response.headers.get('x-litescrape-keyless-limit') ?? undefined);
|
|
136
|
+
const remainingHeader = response.headers.get('x-litescrape-keyless-remaining');
|
|
137
|
+
const remaining = remainingHeader === null ? undefined : Number(remainingHeader);
|
|
138
|
+
const keyless = limit !== undefined && remaining !== undefined && Number.isInteger(remaining)
|
|
139
|
+
? { limit, remaining }
|
|
140
|
+
: undefined;
|
|
141
|
+
return { payload, requestId, keyless };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
export function isRecord(value) {
|
|
145
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
146
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { LitescrapeClient } from './client.js';
|
|
4
|
+
import { createServer } from './server.js';
|
|
5
|
+
import { PACKAGE_NAME, VERSION } from './version.js';
|
|
6
|
+
async function main() {
|
|
7
|
+
const client = LitescrapeClient.fromEnv(process.env);
|
|
8
|
+
const server = createServer({ client });
|
|
9
|
+
await server.connect(new StdioServerTransport());
|
|
10
|
+
// stdout carries the protocol; every log line goes to stderr.
|
|
11
|
+
console.error(`${PACKAGE_NAME} ${VERSION} ready (${client.keyed ? 'API key' : 'keyless'} mode, ${client.apiUrl})`);
|
|
12
|
+
}
|
|
13
|
+
main().catch((error) => {
|
|
14
|
+
console.error(`${PACKAGE_NAME} failed to start:`, error);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const KEY_URL = "https://litescrape.com";
|
|
2
|
+
export declare const ACCOUNT_FIX: string;
|
|
3
|
+
export declare const KEYLESS_TOOLS: string[];
|
|
4
|
+
export declare function keyRequiredMessage(tool: string): string;
|
|
5
|
+
export declare function instructions(keyed: boolean): string;
|
package/dist/messages.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export const KEY_URL = 'https://litescrape.com';
|
|
2
|
+
// MCP hosts forward a tool error's text to the model, not structured fields,
|
|
3
|
+
// so the recovery steps live in the sentence itself.
|
|
4
|
+
export const ACCOUNT_FIX = `Fix: Get a free API key at ${KEY_URL}, set LITESCRAPE_API_KEY in the MCP server's ` +
|
|
5
|
+
'environment, then start a new session.';
|
|
6
|
+
export const KEYLESS_TOOLS = ['google_search', 'bing_search', 'google_maps', 'duckduckgo_search'];
|
|
7
|
+
export function keyRequiredMessage(tool) {
|
|
8
|
+
return (`${tool} needs a Litescrape API key. Without one this server offers ` +
|
|
9
|
+
`${KEYLESS_TOOLS.join(', ')} and search for free.\n\n${ACCOUNT_FIX}`);
|
|
10
|
+
}
|
|
11
|
+
export function instructions(keyed) {
|
|
12
|
+
const shared = 'Every tool returns the Litescrape API response as JSON, unchanged, after a one-line ' +
|
|
13
|
+
'summary. Pass result_groups to keep only the top-level groups you need (search_metadata ' +
|
|
14
|
+
'is always kept). Results come straight from Google, Bing, DuckDuckGo or Google Maps; ' +
|
|
15
|
+
'links are real destinations, never redirects. When a call reports a limit, relay its ' +
|
|
16
|
+
'"Fix:" line to the user.';
|
|
17
|
+
if (keyed) {
|
|
18
|
+
return ('This session is authenticated with a Litescrape API key: every tool is available and ' +
|
|
19
|
+
'each successful call is billed to that key. search is google_search in fast mode ' +
|
|
20
|
+
'(organic results only). google_ai_mode generates an answer per request and is the ' +
|
|
21
|
+
'slowest tool. ' +
|
|
22
|
+
shared);
|
|
23
|
+
}
|
|
24
|
+
return ('This session has no Litescrape API key. google_search (25 calls), bing_search (50), ' +
|
|
25
|
+
'google_maps (50) and duckduckgo_search (50) are free per network per UTC day, one call ' +
|
|
26
|
+
'at a time; failed calls do not count. search is google_search in fast mode (organic ' +
|
|
27
|
+
'results only) and shares its allowance. google_ai_mode, google_ai_overview and ' +
|
|
28
|
+
'google_shopping need an API key, which also lifts every limit: get one at ' +
|
|
29
|
+
`${KEY_URL} and set LITESCRAPE_API_KEY in this server's environment. ` +
|
|
30
|
+
shared);
|
|
31
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { LitescrapeClient, LitescrapeError, type KeylessAllowance, type Params, type Payload } from './client.js';
|
|
3
|
+
import { type Surface } from './surfaces.js';
|
|
4
|
+
export { LitescrapeClient, LitescrapeError } from './client.js';
|
|
5
|
+
export { SURFACES } from './surfaces.js';
|
|
6
|
+
export declare function toParams(args: Record<string, unknown>): Params;
|
|
7
|
+
export declare function selectGroups(payload: Payload, groups: unknown): Payload;
|
|
8
|
+
export declare function allowanceLine(surface: Surface, allowance: KeylessAllowance): string;
|
|
9
|
+
export declare function formatError(error: LitescrapeError): string;
|
|
10
|
+
export interface ServerOptions {
|
|
11
|
+
client: LitescrapeClient;
|
|
12
|
+
}
|
|
13
|
+
export declare function createServer({ client }: ServerOptions): McpServer;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { LitescrapeError, } from './client.js';
|
|
3
|
+
import { instructions, keyRequiredMessage } from './messages.js';
|
|
4
|
+
import { SURFACES } from './surfaces.js';
|
|
5
|
+
import { PACKAGE_NAME, VERSION } from './version.js';
|
|
6
|
+
export { LitescrapeClient, LitescrapeError } from './client.js';
|
|
7
|
+
export { SURFACES } from './surfaces.js';
|
|
8
|
+
// Error codes whose message already carries the "Fix:" line for the caller.
|
|
9
|
+
const SELF_EXPLANATORY = new Set([
|
|
10
|
+
'mcp_keyless_daily_limit',
|
|
11
|
+
'mcp_keyless_concurrency_limit',
|
|
12
|
+
'mcp_keyless_capacity_limit',
|
|
13
|
+
'mcp_api_key_required',
|
|
14
|
+
'mcp_keyless_network_unknown',
|
|
15
|
+
]);
|
|
16
|
+
export function toParams(args) {
|
|
17
|
+
const params = {};
|
|
18
|
+
for (const [key, value] of Object.entries(args)) {
|
|
19
|
+
if (value === undefined || value === null || key === 'result_groups')
|
|
20
|
+
continue;
|
|
21
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
22
|
+
params[key] = value;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return params;
|
|
26
|
+
}
|
|
27
|
+
export function selectGroups(payload, groups) {
|
|
28
|
+
if (!Array.isArray(groups) || groups.length === 0)
|
|
29
|
+
return payload;
|
|
30
|
+
const wanted = new Set(groups.filter((group) => typeof group === 'string'));
|
|
31
|
+
wanted.add('search_metadata');
|
|
32
|
+
const selected = {};
|
|
33
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
34
|
+
if (wanted.has(key))
|
|
35
|
+
selected[key] = value;
|
|
36
|
+
}
|
|
37
|
+
return selected;
|
|
38
|
+
}
|
|
39
|
+
export function allowanceLine(surface, allowance) {
|
|
40
|
+
return `Free allowance: ${allowance.remaining} of ${allowance.limit} ${surface.name} calls left today (no API key set).`;
|
|
41
|
+
}
|
|
42
|
+
export function formatError(error) {
|
|
43
|
+
if (SELF_EXPLANATORY.has(error.errorCode))
|
|
44
|
+
return error.message;
|
|
45
|
+
const request = error.requestId ? `, request ${error.requestId}` : '';
|
|
46
|
+
const retry = error.retryable ? ' The call was not charged; retrying may succeed.' : '';
|
|
47
|
+
return `Litescrape ${error.errorCode} (HTTP ${error.status}${request}): ${error.message}${retry}`;
|
|
48
|
+
}
|
|
49
|
+
function textResult(text, structured, isError = false) {
|
|
50
|
+
const result = { content: [{ type: 'text', text }] };
|
|
51
|
+
if (structured !== undefined)
|
|
52
|
+
result.structuredContent = structured;
|
|
53
|
+
if (isError)
|
|
54
|
+
result.isError = true;
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
export function createServer({ client }) {
|
|
58
|
+
const server = new McpServer({ name: PACKAGE_NAME, version: VERSION }, { instructions: instructions(client.keyed), capabilities: { tools: {} } });
|
|
59
|
+
for (const surface of SURFACES) {
|
|
60
|
+
server.registerTool(surface.name, {
|
|
61
|
+
title: surface.title,
|
|
62
|
+
description: surface.description,
|
|
63
|
+
inputSchema: surface.inputSchema,
|
|
64
|
+
annotations: { readOnlyHint: true, idempotentHint: false, openWorldHint: true },
|
|
65
|
+
}, async (args) => {
|
|
66
|
+
if (!client.keyed && !surface.keyless) {
|
|
67
|
+
return textResult(keyRequiredMessage(surface.name), undefined, true);
|
|
68
|
+
}
|
|
69
|
+
const params = { ...toParams(args), ...surface.defaults };
|
|
70
|
+
try {
|
|
71
|
+
const { payload, keyless } = await client.get(surface.path, params);
|
|
72
|
+
const selected = selectGroups(payload, args.result_groups);
|
|
73
|
+
const summary = keyless
|
|
74
|
+
? `${surface.summarize(payload, args)} ${allowanceLine(surface, keyless)}`
|
|
75
|
+
: surface.summarize(payload, args);
|
|
76
|
+
return textResult(`${summary}\n\n${JSON.stringify(selected)}`, selected);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error instanceof LitescrapeError)
|
|
80
|
+
return textResult(formatError(error), undefined, true);
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return server;
|
|
86
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { type Params, type Payload } from './client.js';
|
|
3
|
+
export interface Surface {
|
|
4
|
+
name: string;
|
|
5
|
+
title: string;
|
|
6
|
+
description: string;
|
|
7
|
+
path: string;
|
|
8
|
+
/** Served without an API key from the per-network daily allowance. */
|
|
9
|
+
keyless: boolean;
|
|
10
|
+
inputSchema: z.ZodRawShape;
|
|
11
|
+
/** Fixed query parameters the caller cannot override. */
|
|
12
|
+
defaults?: Params;
|
|
13
|
+
summarize: (payload: Payload, args: Record<string, unknown>) => string;
|
|
14
|
+
}
|
|
15
|
+
export declare const SURFACES: Surface[];
|
|
16
|
+
export declare function findSurface(name: string): Surface | undefined;
|
package/dist/surfaces.js
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { isRecord } from './client.js';
|
|
3
|
+
const device = z
|
|
4
|
+
.enum(['desktop', 'tablet', 'mobile'])
|
|
5
|
+
.optional()
|
|
6
|
+
.describe('Layout to request; default desktop');
|
|
7
|
+
const gl = z
|
|
8
|
+
.string()
|
|
9
|
+
.length(2)
|
|
10
|
+
.optional()
|
|
11
|
+
.describe('Two-letter country for localization, e.g. "us"');
|
|
12
|
+
const hl = z
|
|
13
|
+
.string()
|
|
14
|
+
.min(2)
|
|
15
|
+
.max(10)
|
|
16
|
+
.optional()
|
|
17
|
+
.describe('Language code such as "en" or "en-GB"; default en');
|
|
18
|
+
const googleDomain = z
|
|
19
|
+
.string()
|
|
20
|
+
.optional()
|
|
21
|
+
.describe('Google domain such as google.co.uk; default google.com');
|
|
22
|
+
const location = z
|
|
23
|
+
.string()
|
|
24
|
+
.max(512)
|
|
25
|
+
.optional()
|
|
26
|
+
.describe('Named search origin such as "Austin, Texas"; conflicts with uule and lat/lon');
|
|
27
|
+
const uule = z
|
|
28
|
+
.string()
|
|
29
|
+
.max(2048)
|
|
30
|
+
.optional()
|
|
31
|
+
.describe('Pre-encoded Google location token; conflicts with location');
|
|
32
|
+
const lat = z.number().min(-90).max(90).optional().describe('Latitude; supply together with lon');
|
|
33
|
+
const lon = z
|
|
34
|
+
.number()
|
|
35
|
+
.min(-180)
|
|
36
|
+
.max(180)
|
|
37
|
+
.optional()
|
|
38
|
+
.describe('Longitude; supply together with lat');
|
|
39
|
+
const resultGroups = z
|
|
40
|
+
.array(z.string().min(1))
|
|
41
|
+
.optional()
|
|
42
|
+
.describe('Return only these top-level result groups, e.g. ["organic_results", "knowledge_graph"]; ' +
|
|
43
|
+
'omit for the complete response. search_metadata is always kept');
|
|
44
|
+
const googleSearchBase = {
|
|
45
|
+
q: z
|
|
46
|
+
.string()
|
|
47
|
+
.min(1)
|
|
48
|
+
.max(2048)
|
|
49
|
+
.optional()
|
|
50
|
+
.describe('Search query; required unless ludocid or kgmid is supplied'),
|
|
51
|
+
ludocid: z.string().optional().describe('Google CID of a local entity, for a targeted lookup'),
|
|
52
|
+
kgmid: z.string().optional().describe('Knowledge Graph machine ID such as /m/0k8z'),
|
|
53
|
+
location,
|
|
54
|
+
uule,
|
|
55
|
+
lat,
|
|
56
|
+
lon,
|
|
57
|
+
radius: z
|
|
58
|
+
.number()
|
|
59
|
+
.positive()
|
|
60
|
+
.optional()
|
|
61
|
+
.describe('Radius in meters around the location or coordinates'),
|
|
62
|
+
google_domain: googleDomain,
|
|
63
|
+
gl,
|
|
64
|
+
hl,
|
|
65
|
+
cr: z.string().optional().describe('Country restrict such as countryUS|countryCA'),
|
|
66
|
+
lr: z.string().optional().describe('Language restrict such as lang_en|lang_fr'),
|
|
67
|
+
tbs: z
|
|
68
|
+
.string()
|
|
69
|
+
.max(4096)
|
|
70
|
+
.optional()
|
|
71
|
+
.describe('Google search-filter string, e.g. qdr:w for the past week'),
|
|
72
|
+
tbm: z
|
|
73
|
+
.enum(['lcl', 'vid', 'nws', 'shop', 'pts'])
|
|
74
|
+
.optional()
|
|
75
|
+
.describe('Vertical: lcl local, vid videos, nws news, shop shopping, pts patents'),
|
|
76
|
+
safe: z.enum(['active', 'off']).optional().describe('SafeSearch'),
|
|
77
|
+
nfpr: z.enum(['0', '1']).optional().describe('1 disables spelling auto-correction'),
|
|
78
|
+
filter: z.enum(['0', '1']).optional().describe('0 disables duplicate-content filtering'),
|
|
79
|
+
as_sitesearch: z.string().max(253).optional().describe('Restrict results to this hostname'),
|
|
80
|
+
as_qdr: z
|
|
81
|
+
.string()
|
|
82
|
+
.optional()
|
|
83
|
+
.describe('Date range: d, w, m or y with an optional count, e.g. d7 for the past week'),
|
|
84
|
+
start: z.number().int().min(0).optional().describe('Result offset for pagination'),
|
|
85
|
+
num: z
|
|
86
|
+
.number()
|
|
87
|
+
.int()
|
|
88
|
+
.min(1)
|
|
89
|
+
.max(100)
|
|
90
|
+
.optional()
|
|
91
|
+
.describe('Requested result count, 1-100; Google may return fewer'),
|
|
92
|
+
device,
|
|
93
|
+
};
|
|
94
|
+
function count(payload, key) {
|
|
95
|
+
const value = payload[key];
|
|
96
|
+
return Array.isArray(value) ? value.length : undefined;
|
|
97
|
+
}
|
|
98
|
+
function presentGroups(payload, skip) {
|
|
99
|
+
return Object.keys(payload).filter((key) => !skip.includes(key) && key !== 'search_metadata' && key !== 'search_parameters');
|
|
100
|
+
}
|
|
101
|
+
function quoted(args) {
|
|
102
|
+
return typeof args.q === 'string' ? ` for "${args.q}"` : '';
|
|
103
|
+
}
|
|
104
|
+
function summarizeSerp(engine) {
|
|
105
|
+
return (payload, args) => {
|
|
106
|
+
const organic = count(payload, 'organic_results') ?? 0;
|
|
107
|
+
const others = presentGroups(payload, ['organic_results', 'search_information', 'pagination']);
|
|
108
|
+
const also = others.length ? `; also ${others.join(', ')}` : '';
|
|
109
|
+
return `${engine}${quoted(args)}: ${organic} organic results${also}.`;
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export const SURFACES = [
|
|
113
|
+
{
|
|
114
|
+
name: 'search',
|
|
115
|
+
title: 'Web search',
|
|
116
|
+
description: 'Search the web through Google and return ranked organic results (title, link, snippet) ' +
|
|
117
|
+
'as JSON. Fast mode: no Knowledge Graph, ads or AI modules; use google_search for the ' +
|
|
118
|
+
'full results page. Free without an API key (shares the google_search allowance).',
|
|
119
|
+
path: '/api/google/search',
|
|
120
|
+
keyless: true,
|
|
121
|
+
inputSchema: {
|
|
122
|
+
q: z.string().min(1).max(2048).describe('What to search for'),
|
|
123
|
+
gl,
|
|
124
|
+
hl,
|
|
125
|
+
location,
|
|
126
|
+
num: z
|
|
127
|
+
.number()
|
|
128
|
+
.int()
|
|
129
|
+
.min(1)
|
|
130
|
+
.max(100)
|
|
131
|
+
.optional()
|
|
132
|
+
.describe('Requested result count, 1-100; Google may return fewer'),
|
|
133
|
+
start: z.number().int().min(0).optional().describe('Result offset for pagination'),
|
|
134
|
+
result_groups: resultGroups,
|
|
135
|
+
},
|
|
136
|
+
defaults: { fast_mode: true },
|
|
137
|
+
summarize: summarizeSerp('Google Search (fast mode)'),
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: 'google_search',
|
|
141
|
+
title: 'Google Search',
|
|
142
|
+
description: 'Fetch a first-party Google Search results page as JSON: organic_results plus whichever ' +
|
|
143
|
+
'modules Google served (knowledge_graph, answer_box, ai_overview, ads, related_questions, ' +
|
|
144
|
+
'related_searches, local_results, top_stories, inline_videos and more). Supports ' +
|
|
145
|
+
'localization, verticals (news, videos, local, shopping, patents), date filters and site ' +
|
|
146
|
+
'restriction. Free without an API key: 25 calls per network per day.',
|
|
147
|
+
path: '/api/google/search',
|
|
148
|
+
keyless: true,
|
|
149
|
+
inputSchema: {
|
|
150
|
+
...googleSearchBase,
|
|
151
|
+
fast_mode: z
|
|
152
|
+
.boolean()
|
|
153
|
+
.optional()
|
|
154
|
+
.describe('true returns only organic_results (faster and smaller); skips AI Overview, ' +
|
|
155
|
+
'Knowledge Graph, ads and every other module'),
|
|
156
|
+
result_groups: resultGroups,
|
|
157
|
+
},
|
|
158
|
+
summarize: summarizeSerp('Google Search'),
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: 'bing_search',
|
|
162
|
+
title: 'Bing Search',
|
|
163
|
+
description: 'Fetch a Bing web search results page as JSON: organic_results plus the modules Bing ' +
|
|
164
|
+
'served (answer_box, knowledge_graph, copilot_answer, ads, related_questions, ' +
|
|
165
|
+
'inline_videos, top_stories and more). Free without an API key: 50 calls per network ' +
|
|
166
|
+
'per day.',
|
|
167
|
+
path: '/api/bing/search',
|
|
168
|
+
keyless: true,
|
|
169
|
+
inputSchema: {
|
|
170
|
+
q: z.string().min(1).max(2048).describe('Search query; Bing operators are preserved'),
|
|
171
|
+
location: z.string().max(256).optional().describe('City-level search origin'),
|
|
172
|
+
lat,
|
|
173
|
+
lon,
|
|
174
|
+
mkt: z.string().optional().describe('Market such as en-US; conflicts with cc'),
|
|
175
|
+
cc: z.string().length(2).optional().describe('Two-letter country code; conflicts with mkt'),
|
|
176
|
+
first: z
|
|
177
|
+
.number()
|
|
178
|
+
.int()
|
|
179
|
+
.min(1)
|
|
180
|
+
.optional()
|
|
181
|
+
.describe('One-based organic result offset for pagination; default 1'),
|
|
182
|
+
safeSearch: z.enum(['off', 'moderate', 'strict']).optional().describe('Default moderate'),
|
|
183
|
+
filters: z.string().max(8192).optional().describe('Native Bing display or date filters'),
|
|
184
|
+
device,
|
|
185
|
+
result_groups: resultGroups,
|
|
186
|
+
},
|
|
187
|
+
summarize: summarizeSerp('Bing'),
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
name: 'duckduckgo_search',
|
|
191
|
+
title: 'DuckDuckGo Search',
|
|
192
|
+
description: 'Fetch ranked DuckDuckGo web results (organic_results with title, link, snippet) with ' +
|
|
193
|
+
'region, safety and date controls. Free without an API key: 50 calls per network per day.',
|
|
194
|
+
path: '/api/duckduckgo/search',
|
|
195
|
+
keyless: true,
|
|
196
|
+
inputSchema: {
|
|
197
|
+
q: z.string().min(1).max(500).describe('Search query'),
|
|
198
|
+
kl: z.string().max(32).optional().describe('Region and language token such as us-en'),
|
|
199
|
+
search_assist: z.boolean().optional().describe('Default true; cannot be combined with m'),
|
|
200
|
+
safe: z
|
|
201
|
+
.enum(['1', '-1', '-2'])
|
|
202
|
+
.optional()
|
|
203
|
+
.describe('1 strict, -1 moderate (default), -2 off'),
|
|
204
|
+
df: z.string().optional().describe('Date filter: d, w, m, y, or YYYY-MM-DD..YYYY-MM-DD'),
|
|
205
|
+
start: z.number().int().min(0).max(10000).optional().describe('Result offset'),
|
|
206
|
+
m: z
|
|
207
|
+
.number()
|
|
208
|
+
.int()
|
|
209
|
+
.min(1)
|
|
210
|
+
.max(50)
|
|
211
|
+
.optional()
|
|
212
|
+
.describe('Result count 1-50; cannot be combined with search_assist'),
|
|
213
|
+
result_groups: resultGroups,
|
|
214
|
+
},
|
|
215
|
+
summarize: summarizeSerp('DuckDuckGo'),
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
name: 'google_maps',
|
|
219
|
+
title: 'Google Maps',
|
|
220
|
+
description: 'Search Google Maps for places (local_results with name, address, rating, reviews, ' +
|
|
221
|
+
'hours, phone, website, coordinates) or fetch one exact place (place_results) by ' +
|
|
222
|
+
'place_id, data_cid or data sequence. Filter by price, rating and opening hours. Free ' +
|
|
223
|
+
'without an API key: 50 calls per network per day.',
|
|
224
|
+
path: '/api/google/maps',
|
|
225
|
+
keyless: true,
|
|
226
|
+
inputSchema: {
|
|
227
|
+
q: z.string().optional().describe('Search text; required for type=search'),
|
|
228
|
+
type: z
|
|
229
|
+
.enum(['search', 'place'])
|
|
230
|
+
.optional()
|
|
231
|
+
.describe('search (default) for a query, or place for one exact place identified by ' +
|
|
232
|
+
'place_id, data_cid or data'),
|
|
233
|
+
ll: z.string().optional().describe('Viewport as @lat,lon,14z or @lat,lon,5000m'),
|
|
234
|
+
location,
|
|
235
|
+
lat,
|
|
236
|
+
lon,
|
|
237
|
+
z: z.number().min(3).max(30).optional().describe('Zoom 3-30; use with location or lat/lon'),
|
|
238
|
+
m: z.number().min(1).optional().describe('Radius in meters; use with location or lat/lon'),
|
|
239
|
+
nearby: z.boolean().optional().describe('Restrict results to places near the viewport'),
|
|
240
|
+
place_id: z.string().optional().describe('Exact place by Google place ID'),
|
|
241
|
+
data_cid: z.string().optional().describe('Exact place by decimal Google CID'),
|
|
242
|
+
data: z.string().max(8192).optional().describe('Exact place by Google Maps data sequence'),
|
|
243
|
+
start: z.number().int().min(0).optional().describe('Native Maps result offset'),
|
|
244
|
+
hl,
|
|
245
|
+
gl,
|
|
246
|
+
google_domain: googleDomain,
|
|
247
|
+
min_price: z.number().int().min(0).optional().describe('Price level lower bound'),
|
|
248
|
+
max_price: z.number().int().min(0).optional().describe('Price level upper bound'),
|
|
249
|
+
min_rating: z
|
|
250
|
+
.enum(['2.0', '2.5', '3.0', '3.5', '4.0', '4.5'])
|
|
251
|
+
.optional()
|
|
252
|
+
.describe('Minimum rating preference'),
|
|
253
|
+
open_state: z
|
|
254
|
+
.enum(['now', '24h'])
|
|
255
|
+
.optional()
|
|
256
|
+
.describe('Open now, or open 24 hours; cannot combine with open_on_day'),
|
|
257
|
+
open_on_day: z.enum(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']).optional(),
|
|
258
|
+
open_at_hour: z
|
|
259
|
+
.number()
|
|
260
|
+
.int()
|
|
261
|
+
.min(0)
|
|
262
|
+
.max(23)
|
|
263
|
+
.optional()
|
|
264
|
+
.describe('Hour 0-23; requires open_on_day'),
|
|
265
|
+
result_groups: resultGroups,
|
|
266
|
+
},
|
|
267
|
+
summarize: (payload, args) => {
|
|
268
|
+
const place = payload.place_results;
|
|
269
|
+
if (isRecord(place)) {
|
|
270
|
+
const title = typeof place.title === 'string' ? place.title : 'one place';
|
|
271
|
+
return `Google Maps place: ${title}.`;
|
|
272
|
+
}
|
|
273
|
+
const places = count(payload, 'local_results') ?? 0;
|
|
274
|
+
return `Google Maps${quoted(args)}: ${places} places.`;
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
name: 'google_ai_overview',
|
|
279
|
+
title: 'Google AI Overview',
|
|
280
|
+
description: 'Return only the AI Overview Google generates for a search (ai_overview with ordered ' +
|
|
281
|
+
'text_blocks and cited references), or null when Google shows none. Accepts the ' +
|
|
282
|
+
'google_search parameters. Requires an API key.',
|
|
283
|
+
path: '/api/google/ai-overview',
|
|
284
|
+
keyless: false,
|
|
285
|
+
inputSchema: { ...googleSearchBase, result_groups: resultGroups },
|
|
286
|
+
summarize: (payload, args) => {
|
|
287
|
+
const overview = payload.ai_overview;
|
|
288
|
+
if (!isRecord(overview))
|
|
289
|
+
return `Google served no AI Overview${quoted(args)}.`;
|
|
290
|
+
if (isRecord(overview.error))
|
|
291
|
+
return `Google AI Overview${quoted(args)}: unavailable.`;
|
|
292
|
+
const blocks = count(overview, 'text_blocks') ?? 0;
|
|
293
|
+
const references = count(overview, 'references') ?? 0;
|
|
294
|
+
return `Google AI Overview${quoted(args)}: ${blocks} text blocks, ${references} references.`;
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
name: 'google_ai_mode',
|
|
299
|
+
title: 'Google AI Mode',
|
|
300
|
+
description: 'Ask Google AI Mode a question and return its generated answer (ordered text_blocks: ' +
|
|
301
|
+
'paragraphs, headings, lists, tables, code) with the sources it cited (references). ' +
|
|
302
|
+
'Set continuable to get a token for follow-up questions; image_url adds a picture to ' +
|
|
303
|
+
'the prompt. Slow: answers are generated per request. Requires an API key.',
|
|
304
|
+
path: '/api/google/ai-mode',
|
|
305
|
+
keyless: false,
|
|
306
|
+
inputSchema: {
|
|
307
|
+
q: z.string().min(1).max(2048).describe('The question to ask'),
|
|
308
|
+
location,
|
|
309
|
+
uule,
|
|
310
|
+
google_domain: googleDomain,
|
|
311
|
+
gl,
|
|
312
|
+
hl,
|
|
313
|
+
device,
|
|
314
|
+
continuable: z
|
|
315
|
+
.boolean()
|
|
316
|
+
.optional()
|
|
317
|
+
.describe('Return a subsequent_request_token so the conversation can continue'),
|
|
318
|
+
subsequent_request_token: z
|
|
319
|
+
.string()
|
|
320
|
+
.optional()
|
|
321
|
+
.describe('Token from a previous continuable answer; send with a new q. Expires after ' +
|
|
322
|
+
'30 minutes; cannot combine with image_url'),
|
|
323
|
+
image_url: z
|
|
324
|
+
.string()
|
|
325
|
+
.url()
|
|
326
|
+
.optional()
|
|
327
|
+
.describe('Public http(s) image to include in the prompt; cannot combine with a token'),
|
|
328
|
+
result_groups: resultGroups,
|
|
329
|
+
},
|
|
330
|
+
summarize: (payload, args) => {
|
|
331
|
+
const blocks = count(payload, 'text_blocks') ?? 0;
|
|
332
|
+
const references = count(payload, 'references') ?? 0;
|
|
333
|
+
const token = typeof payload.subsequent_request_token === 'string' ? '; follow-up token included' : '';
|
|
334
|
+
return `Google AI Mode${quoted(args)}: ${blocks} text blocks, ${references} references${token}.`;
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
name: 'google_shopping',
|
|
339
|
+
title: 'Google Shopping',
|
|
340
|
+
description: 'Search Google Shopping and return the product grid (shopping_results with title, ' +
|
|
341
|
+
'price, source, rating, thumbnail), category blocks, sponsored listings and the ' +
|
|
342
|
+
'refinement chips Google renders (filters). Price bounds, sale, shipping and small ' +
|
|
343
|
+
'business refinements are mutually exclusive; sort_by combines with one of them. ' +
|
|
344
|
+
'Requires an API key.',
|
|
345
|
+
path: '/api/google/shopping',
|
|
346
|
+
keyless: false,
|
|
347
|
+
inputSchema: {
|
|
348
|
+
q: z
|
|
349
|
+
.string()
|
|
350
|
+
.min(1)
|
|
351
|
+
.max(2048)
|
|
352
|
+
.optional()
|
|
353
|
+
.describe('Product query; required unless shoprs is supplied'),
|
|
354
|
+
location,
|
|
355
|
+
uule,
|
|
356
|
+
google_domain: googleDomain,
|
|
357
|
+
gl,
|
|
358
|
+
hl,
|
|
359
|
+
shoprs: z
|
|
360
|
+
.string()
|
|
361
|
+
.max(4096)
|
|
362
|
+
.optional()
|
|
363
|
+
.describe("Refinement token from a previous response's filters"),
|
|
364
|
+
min_price: z.number().min(0).optional().describe('Lower price bound'),
|
|
365
|
+
max_price: z.number().min(0).optional().describe('Upper price bound'),
|
|
366
|
+
sort_by: z
|
|
367
|
+
.enum(['1', '2', '3', '4'])
|
|
368
|
+
.optional()
|
|
369
|
+
.describe('1 price low to high, 2 price high to low, 3 rating, 4 relevance'),
|
|
370
|
+
free_shipping: z.boolean().optional(),
|
|
371
|
+
on_sale: z.boolean().optional(),
|
|
372
|
+
small_business: z.boolean().optional(),
|
|
373
|
+
start: z.number().int().min(0).max(1000).optional().describe('Result offset'),
|
|
374
|
+
num: z.number().int().min(1).max(100).optional().describe('Product count, 1-100'),
|
|
375
|
+
device,
|
|
376
|
+
result_groups: resultGroups,
|
|
377
|
+
},
|
|
378
|
+
summarize: (payload, args) => {
|
|
379
|
+
const products = count(payload, 'shopping_results') ?? 0;
|
|
380
|
+
const categories = count(payload, 'categorized_shopping_results') ?? 0;
|
|
381
|
+
const sponsored = count(payload, 'inline_shopping_results') ?? 0;
|
|
382
|
+
return (`Google Shopping${quoted(args)}: ${products} products, ${categories} category blocks, ` +
|
|
383
|
+
`${sponsored} sponsored listings.`);
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
];
|
|
387
|
+
export function findSurface(name) {
|
|
388
|
+
return SURFACES.find((surface) => surface.name === name);
|
|
389
|
+
}
|
package/dist/version.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "litescrape-mcp-server",
|
|
3
|
+
"version": "0.1.0",
|
|
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 and Shopping with one",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://litescrape.com",
|
|
7
|
+
"mcpName": "com.litescrape/litescrape-mcp-server",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"bin": {
|
|
10
|
+
"litescrape-mcp-server": "dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"main": "dist/server.js",
|
|
13
|
+
"types": "dist/server.d.ts",
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"server.json"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=20"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"mcp",
|
|
23
|
+
"mcp-server",
|
|
24
|
+
"model context protocol",
|
|
25
|
+
"litescrape",
|
|
26
|
+
"serp api",
|
|
27
|
+
"google search",
|
|
28
|
+
"google maps",
|
|
29
|
+
"bing search",
|
|
30
|
+
"duckduckgo",
|
|
31
|
+
"google ai mode",
|
|
32
|
+
"google ai overview",
|
|
33
|
+
"google shopping",
|
|
34
|
+
"web search",
|
|
35
|
+
"claude",
|
|
36
|
+
"cursor"
|
|
37
|
+
],
|
|
38
|
+
"author": {
|
|
39
|
+
"name": "Litescraper",
|
|
40
|
+
"email": "support@litescrape.com"
|
|
41
|
+
},
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/litescrape/litescrape-mcp-server.git"
|
|
45
|
+
},
|
|
46
|
+
"bugs": {
|
|
47
|
+
"url": "https://github.com/litescrape/litescrape-mcp-server/issues"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsc -p tsconfig.build.json",
|
|
51
|
+
"typecheck": "tsc --noEmit",
|
|
52
|
+
"test": "vitest run",
|
|
53
|
+
"format": "prettier --write .",
|
|
54
|
+
"format:check": "prettier --check .",
|
|
55
|
+
"lint": "npm run format:check && npm run typecheck",
|
|
56
|
+
"dev": "tsx src/index.ts",
|
|
57
|
+
"inspector": "npx @modelcontextprotocol/inspector node dist/index.js",
|
|
58
|
+
"prepublishOnly": "npm run lint && npm run test && npm run build"
|
|
59
|
+
},
|
|
60
|
+
"publishConfig": {
|
|
61
|
+
"access": "public",
|
|
62
|
+
"provenance": true
|
|
63
|
+
},
|
|
64
|
+
"dependencies": {
|
|
65
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
66
|
+
"zod": "^3.25.76"
|
|
67
|
+
},
|
|
68
|
+
"devDependencies": {
|
|
69
|
+
"@types/node": "^24.0.0",
|
|
70
|
+
"prettier": "3.8.3",
|
|
71
|
+
"tsx": "^4.23.13",
|
|
72
|
+
"typescript": "^5.9.3",
|
|
73
|
+
"vitest": "^4.1.5"
|
|
74
|
+
}
|
|
75
|
+
}
|
package/server.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json",
|
|
3
|
+
"name": "com.litescrape/litescrape-mcp-server",
|
|
4
|
+
"description": "Google Search, Bing, DuckDuckGo and Google Maps results free without an API key; Google AI Mode, AI Overview and Shopping with one.",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"packages": [
|
|
7
|
+
{
|
|
8
|
+
"registryType": "npm",
|
|
9
|
+
"identifier": "litescrape-mcp-server",
|
|
10
|
+
"version": "0.1.0",
|
|
11
|
+
"environmentVariables": [
|
|
12
|
+
{
|
|
13
|
+
"name": "LITESCRAPE_API_KEY",
|
|
14
|
+
"description": "Optional Litescrape API key. Without it, google_search, bing_search, google_maps and duckduckgo_search work from a free daily allowance; with it every tool is available.",
|
|
15
|
+
"isRequired": false,
|
|
16
|
+
"isSecret": true
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|