@opencraw/mcp 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/README.md +103 -0
- package/bin/opencraw-mcp.mjs +4 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.esm.js +373 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/list-tool/index.d.ts +3 -0
- package/dist/src/list-tool/list-recipes-tool.handler.d.ts +32 -0
- package/dist/src/main.d.ts +7 -0
- package/dist/src/probe-tool/index.d.ts +2 -0
- package/dist/src/probe-tool/probe-tool.handler.d.ts +27 -0
- package/dist/src/recipe-source/index.d.ts +3 -0
- package/dist/src/recipe-source/recipe-source.mapper.d.ts +21 -0
- package/dist/src/run-tool/index.d.ts +3 -0
- package/dist/src/run-tool/run-tool.handler.d.ts +49 -0
- package/dist/src/server/create-server.use-case.d.ts +11 -0
- package/dist/src/server/index.d.ts +2 -0
- package/dist/src/validate-tool/index.d.ts +3 -0
- package/dist/src/validate-tool/validate-tool.handler.d.ts +31 -0
- package/package.json +95 -0
package/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# @opencraw/mcp
|
|
2
|
+
|
|
3
|
+
An MCP (Model Context Protocol) server exposing [`@opencraw/core`](../core) as tools an agent can call
|
|
4
|
+
directly: probe a page, validate recipes, run a crawl, list what's already authored. Local transport only
|
|
5
|
+
(stdio) — the host launches it as a subprocess, the same shape as the [`opencraw` cli](../cli).
|
|
6
|
+
|
|
7
|
+
This does **not** auto-author recipes from a sentence. The four tools are the same primitives the
|
|
8
|
+
`opencraw` cli gives a terminal; the calling agent still writes the JSON recipes, using `probe` and
|
|
9
|
+
`validate` to iterate, the same way this project's own example recipes were built by hand. An agent that
|
|
10
|
+
can write files passes their `paths`; one that can't (a chat-only host) passes the `recipes` inline.
|
|
11
|
+
|
|
12
|
+
## Register it
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{
|
|
16
|
+
"mcpServers": {
|
|
17
|
+
"opencraw": {
|
|
18
|
+
"command": "node",
|
|
19
|
+
"args": ["/absolute/path/to/opencraw/packages/mcp/bin/opencraw-mcp.mjs"],
|
|
20
|
+
"env": { "OPENCRAW_CHROMIUM": "/path/to/chrome" }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`OPENCRAW_CHROMIUM` and `OPENCRAW_INSECURE_TLS=1` in the server's environment are the defaults for every
|
|
27
|
+
`probe`/`run` call's `browserPath`/`insecureTls`, so a sandbox without Playwright's own bundled browser
|
|
28
|
+
does not need every tool call to repeat them.
|
|
29
|
+
|
|
30
|
+
`OPENCRAW_HOOKS` points at a JavaScript module whose default export is `{ name: function }`: the hooks the
|
|
31
|
+
recipes call (`hook` steps and transforms). Only the server's environment names it, never a tool call, so an
|
|
32
|
+
agent can run recipes that use your hooks but can't make the server load a module of its choosing.
|
|
33
|
+
|
|
34
|
+
`OPENCRAW_ACCESS` points at an access config ([access.md](../../docs/recipes/access.md)): proxy profiles, with
|
|
35
|
+
credentials as `{{env.NAME}}` read from the server's environment. The `probe` and `run` tools then take an
|
|
36
|
+
`access` argument naming a profile. The file and the credentials never pass through a tool call.
|
|
37
|
+
|
|
38
|
+
## Tools
|
|
39
|
+
|
|
40
|
+
### `probe`
|
|
41
|
+
|
|
42
|
+
Fetches a page and reports where its data lives: JSON-LD blocks, inline JSON objects above a size with
|
|
43
|
+
their keys, `.json` URLs referenced in the markup, script hosts, and links that look like an API. With
|
|
44
|
+
`browser: true` it also renders the page and lists the JSON responses observed while it settles, for
|
|
45
|
+
endpoints only a script fetches after load.
|
|
46
|
+
|
|
47
|
+
| Input | Meaning |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `url` | The page to fetch. |
|
|
50
|
+
| `browser?` | Also render it in a browser (slower, a few seconds). |
|
|
51
|
+
| `browserPath?`, `insecureTls?`, `userAgent?` | As in the cli. |
|
|
52
|
+
| `access?` | An access profile from the server's `OPENCRAW_ACCESS` config. |
|
|
53
|
+
|
|
54
|
+
Returns `{ url, status, findings: { jsonLd, inlineJson, jsonUrls, scriptHosts, apiLinks }, observed }`.
|
|
55
|
+
|
|
56
|
+
### `validate`
|
|
57
|
+
|
|
58
|
+
Loads and binds recipes (an output recipe plus its input recipes), from files or passed inline: parses each
|
|
59
|
+
against its schema, checks every mapping resolves, reports every problem with its JSON path.
|
|
60
|
+
|
|
61
|
+
| Input | Meaning |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `paths` | Recipe files (`.json`, `.jsonl`) or directories of them. |
|
|
64
|
+
| `recipes` | Instead of `paths`: the recipes themselves, as an array of recipe objects or as JSON / JSON Lines text. For a host that can't write files. An inline recipe's issues name it by position: `recipes[1]`, or `recipes:2` for a line. |
|
|
65
|
+
|
|
66
|
+
Give exactly one of `paths` and `recipes`.
|
|
67
|
+
|
|
68
|
+
Returns `{ ok, output?, inputs: string[], issues: [{ path, message, source }] }`.
|
|
69
|
+
|
|
70
|
+
### `run`
|
|
71
|
+
|
|
72
|
+
Crawls the recipes at the given paths, or passed inline.
|
|
73
|
+
|
|
74
|
+
| Input | Meaning |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `paths` or `recipes` | As in `validate`: exactly one output recipe, any number of input recipes. |
|
|
77
|
+
| `out?` | Write records to this JSON Lines file instead of returning them inline. Use this for anything beyond a handful of records — the tool result is not the place for a large crawl's output. |
|
|
78
|
+
| `append?`, `resume?` | As in the cli (`resume` needs `append`). |
|
|
79
|
+
| `only?` | Run only these input recipe ids. |
|
|
80
|
+
| `dryRun?` | One record per input recipe instead of a full crawl — for checking a recipe under construction. |
|
|
81
|
+
| `headed?`, `browserPath?`, `insecureTls?`, `userAgent?` | As in the cli. |
|
|
82
|
+
| `access?` | The access profile for recipes that name none, from the server's `OPENCRAW_ACCESS` config. |
|
|
83
|
+
|
|
84
|
+
Returns `{ report: CrawlReport, records?, truncated? }`. `records`/`truncated` are present only when `out`
|
|
85
|
+
was not given, and `records` is capped at 50 even then.
|
|
86
|
+
|
|
87
|
+
### `list_recipes`
|
|
88
|
+
|
|
89
|
+
Lists the recipe files in a directory, split by kind, with their ids — cheaper than a full `validate`
|
|
90
|
+
call, for checking what already exists before writing a new recipe.
|
|
91
|
+
|
|
92
|
+
| Input | Meaning |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `dir` | A directory of recipe files. |
|
|
95
|
+
|
|
96
|
+
Returns `{ outputs: [{ path, id }], inputs: [{ path, id, output, mode }], others: string[] }`.
|
|
97
|
+
|
|
98
|
+
## Building
|
|
99
|
+
|
|
100
|
+
`nx build @opencraw/mcp`, `nx test @opencraw/mcp` (unit tests per tool), `nx run @opencraw/mcp:e2e`
|
|
101
|
+
(spawns the built server and drives it with the MCP SDK's own `Client`/`StdioClientTransport`, against the
|
|
102
|
+
fixture shop `@opencraw/core` ships; needs a browser — `npm run playwright:install` once, or
|
|
103
|
+
`OPENCRAW_CHROMIUM=/path/to/chrome`).
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./src/index.js";
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { readRecipeFiles, probeUrl, loadForRun, resolveAccess, loadHooks } from '@opencraw/cli';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { memorySink, jsonLinesSink, createCrawler, RecipeSet, RecipeValidationError, RecipeBindingError, parseOutputRecipe, parseInputRecipe, bindRecipeSet } from '@opencraw/core';
|
|
6
|
+
|
|
7
|
+
/** Input schema for the `list_recipes` tool. */
|
|
8
|
+
const listRecipesInputShape = {
|
|
9
|
+
dir: z.string().describe('A directory of recipe files.')
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* The `list_recipes` tool: what recipes already exist in a directory, cheaper
|
|
13
|
+
* than a full `validate` call, so an agent can check before writing a new one
|
|
14
|
+
* or find what an existing output recipe already covers.
|
|
15
|
+
*
|
|
16
|
+
* @param args - The tool's parsed input.
|
|
17
|
+
* @returns The MCP tool result.
|
|
18
|
+
*/
|
|
19
|
+
async function listRecipesTool(args) {
|
|
20
|
+
const files = await readRecipeFiles([args.dir]);
|
|
21
|
+
const result = {
|
|
22
|
+
outputs: files.outputs.map(file => ({
|
|
23
|
+
path: file.path,
|
|
24
|
+
id: idOf(file.recipe)
|
|
25
|
+
})),
|
|
26
|
+
inputs: files.inputs.map(file => ({
|
|
27
|
+
path: file.path,
|
|
28
|
+
id: idOf(file.recipe),
|
|
29
|
+
output: fieldOf(file.recipe, 'output'),
|
|
30
|
+
mode: fieldOf(file.recipe, 'mode')
|
|
31
|
+
})),
|
|
32
|
+
others: files.others
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
content: [{
|
|
36
|
+
type: 'text',
|
|
37
|
+
text: JSON.stringify(result)
|
|
38
|
+
}],
|
|
39
|
+
structuredContent: result
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function idOf(recipe) {
|
|
43
|
+
return fieldOf(recipe, 'id');
|
|
44
|
+
}
|
|
45
|
+
function fieldOf(recipe, name) {
|
|
46
|
+
if (typeof recipe !== 'object' || recipe === null) return undefined;
|
|
47
|
+
const value = recipe[name];
|
|
48
|
+
return typeof value === 'string' ? value : undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Input schema for the `probe` tool: a raw zod shape, as `McpServer.registerTool` expects. */
|
|
52
|
+
const probeInputShape = {
|
|
53
|
+
url: z.string().describe('The page to fetch and inspect.'),
|
|
54
|
+
browser: z.boolean().optional().describe('Also render the page in a browser and list the JSON responses it fetches while settling. Slower (a few seconds); finds endpoints a plain fetch of the initial HTML cannot.'),
|
|
55
|
+
browserPath: z.string().optional().describe('A browser binary other than the one Playwright installed. Defaults to OPENCRAW_CHROMIUM in the server\'s environment.'),
|
|
56
|
+
insecureTls: z.boolean().optional().describe("Accept an intercepting proxy's certificate. Defaults to OPENCRAW_INSECURE_TLS=1 in the server's environment."),
|
|
57
|
+
userAgent: z.string().optional().describe('The user agent to send.'),
|
|
58
|
+
access: z.string().optional().describe('An access profile (a proxy) from the server\'s access config file, OPENCRAW_ACCESS.')
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* The `probe` tool: fetches a page and reports where its data lives, so an
|
|
62
|
+
* agent can write an extraction recipe without guessing selectors.
|
|
63
|
+
*
|
|
64
|
+
* @param args - The tool's parsed input.
|
|
65
|
+
* @returns The MCP tool result: the probe findings as JSON text.
|
|
66
|
+
*/
|
|
67
|
+
async function probeTool(args) {
|
|
68
|
+
try {
|
|
69
|
+
const result = await probeUrl(args.url, {
|
|
70
|
+
browser: args.browser ?? false,
|
|
71
|
+
browserPath: args.browserPath ?? process.env.OPENCRAW_CHROMIUM,
|
|
72
|
+
insecureTls: args.insecureTls === true || process.env.OPENCRAW_INSECURE_TLS === '1',
|
|
73
|
+
userAgent: args.userAgent,
|
|
74
|
+
access: process.env.OPENCRAW_ACCESS === '' ? undefined : process.env.OPENCRAW_ACCESS,
|
|
75
|
+
accessProfile: args.access
|
|
76
|
+
});
|
|
77
|
+
return {
|
|
78
|
+
content: [{
|
|
79
|
+
type: 'text',
|
|
80
|
+
text: JSON.stringify(result)
|
|
81
|
+
}],
|
|
82
|
+
structuredContent: result
|
|
83
|
+
};
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return {
|
|
86
|
+
content: [{
|
|
87
|
+
type: 'text',
|
|
88
|
+
text: `probe failed: ${error instanceof Error ? error.message : String(error)}`
|
|
89
|
+
}],
|
|
90
|
+
isError: true
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const recipeObject = z.record(z.string(), z.unknown());
|
|
96
|
+
const recipeObjects = z.array(recipeObject).min(1);
|
|
97
|
+
/** The two ways a tool takes recipes: files, or the recipes themselves. */
|
|
98
|
+
const recipeSourceShape = {
|
|
99
|
+
paths: z.array(z.string()).min(1).optional().describe('Recipe files or directories (every .json and .jsonl file in a directory is read). Give this or "recipes".'),
|
|
100
|
+
recipes: z.union([z.string(), recipeObjects]).optional().describe('The recipes themselves instead of files: an array of recipe objects, or JSON / JSON Lines text (one recipe per line). For a host that cannot write files. Give this or "paths".')
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* The recipe source a tool call names.
|
|
104
|
+
*
|
|
105
|
+
* @param args - The tool's parsed input.
|
|
106
|
+
* @returns What core reads: the paths, or the inline recipes.
|
|
107
|
+
* @throws Error when the call gives both or neither.
|
|
108
|
+
*/
|
|
109
|
+
function recipeSourceOf(args) {
|
|
110
|
+
if (args.paths === undefined === (args.recipes === undefined)) throw new Error('give exactly one of "paths" or "recipes"');
|
|
111
|
+
if (args.paths !== undefined) return args.paths;
|
|
112
|
+
// Inline text is always recipes, never a path, whatever it starts with.
|
|
113
|
+
return typeof args.recipes === 'string' ? Buffer.from(args.recipes) : args.recipes ?? [];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const RECORD_CAP = 50;
|
|
117
|
+
/** Input schema for the `run` tool. */
|
|
118
|
+
const runInputShape = {
|
|
119
|
+
...recipeSourceShape,
|
|
120
|
+
out: z.string().optional().describe('Write records to this JSON Lines file instead of returning them inline. Use for a crawl expected to produce more than a handful of records.'),
|
|
121
|
+
append: z.boolean().optional().describe('Keep what "out" holds and add to it; each line carries _key. Needs "out".'),
|
|
122
|
+
resume: z.boolean().optional().describe('Skip records "out" already has. Needs "append".'),
|
|
123
|
+
only: z.array(z.string()).optional().describe('Run only these input recipe ids.'),
|
|
124
|
+
dryRun: z.boolean().optional().describe('One record per input recipe, with the scope it was mapped from, instead of a full crawl. For checking a recipe under construction.'),
|
|
125
|
+
headed: z.boolean().optional().describe('Show the browser instead of running headless.'),
|
|
126
|
+
browserPath: z.string().optional().describe('A browser binary other than the one Playwright installed. Defaults to OPENCRAW_CHROMIUM in the server\'s environment.'),
|
|
127
|
+
insecureTls: z.boolean().optional().describe("Accept an intercepting proxy's certificate. Defaults to OPENCRAW_INSECURE_TLS=1 in the server's environment."),
|
|
128
|
+
userAgent: z.string().optional().describe('The user agent to send.'),
|
|
129
|
+
access: z.string().optional().describe('An access profile (a proxy) from the server\'s access config file, OPENCRAW_ACCESS, used by every recipe that names none. The error for an unknown name lists the profiles.')
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* The `run` tool: crawls the recipes in `paths` or `recipes` (exactly one
|
|
133
|
+
* output recipe, any number of inputs) and returns what happened,
|
|
134
|
+
* structured, instead of the cli's printed summary.
|
|
135
|
+
*
|
|
136
|
+
* @param args - The tool's parsed input.
|
|
137
|
+
* @returns The MCP tool result.
|
|
138
|
+
*/
|
|
139
|
+
async function runTool(args) {
|
|
140
|
+
let set;
|
|
141
|
+
try {
|
|
142
|
+
set = await loadForRun(recipeSourceOf(args), args.only ?? []);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
return {
|
|
145
|
+
content: [{
|
|
146
|
+
type: 'text',
|
|
147
|
+
text: loadErrorText(error)
|
|
148
|
+
}],
|
|
149
|
+
isError: true
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
if (set.inputs.length === 0) {
|
|
153
|
+
return {
|
|
154
|
+
content: [{
|
|
155
|
+
type: 'text',
|
|
156
|
+
text: args.only !== undefined && args.only.length > 0 ? `no input recipe matches "only": ${args.only.join(', ')}` : 'no input recipes found'
|
|
157
|
+
}],
|
|
158
|
+
isError: true
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
let crawler;
|
|
162
|
+
const sink = args.dryRun === true || args.out === undefined ? memorySink() : jsonLinesSink(args.out, {
|
|
163
|
+
append: args.append
|
|
164
|
+
});
|
|
165
|
+
try {
|
|
166
|
+
const hooksFile = serverFile('OPENCRAW_HOOKS');
|
|
167
|
+
crawler = createCrawler({
|
|
168
|
+
sink,
|
|
169
|
+
hooks: hooksFile === undefined ? undefined : await loadHooks(hooksFile),
|
|
170
|
+
access: await resolveAccess({
|
|
171
|
+
insecureTls: false,
|
|
172
|
+
access: serverFile('OPENCRAW_ACCESS'),
|
|
173
|
+
accessProfile: args.access
|
|
174
|
+
}),
|
|
175
|
+
resume: args.resume,
|
|
176
|
+
browser: {
|
|
177
|
+
headless: args.headed !== true,
|
|
178
|
+
executablePath: args.browserPath ?? process.env.OPENCRAW_CHROMIUM,
|
|
179
|
+
ignoreHTTPSErrors: args.insecureTls === true || process.env.OPENCRAW_INSECURE_TLS === '1'
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
} catch (error) {
|
|
183
|
+
return {
|
|
184
|
+
content: [{
|
|
185
|
+
type: 'text',
|
|
186
|
+
text: error instanceof Error ? error.message : String(error)
|
|
187
|
+
}],
|
|
188
|
+
isError: true
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
const inputs = args.dryRun === true ? set.inputs.map(input => ({
|
|
193
|
+
...input,
|
|
194
|
+
limits: {
|
|
195
|
+
...input.limits,
|
|
196
|
+
maxRecords: 1
|
|
197
|
+
}
|
|
198
|
+
})) : set.inputs;
|
|
199
|
+
const report = await crawler.run(new RecipeSet(set.output, inputs));
|
|
200
|
+
const result = {
|
|
201
|
+
report
|
|
202
|
+
};
|
|
203
|
+
if (args.out === undefined) {
|
|
204
|
+
const records = sink.records.map(record => record.data);
|
|
205
|
+
result.records = records.slice(0, RECORD_CAP);
|
|
206
|
+
result.truncated = records.length > RECORD_CAP;
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
content: [{
|
|
210
|
+
type: 'text',
|
|
211
|
+
text: JSON.stringify(result)
|
|
212
|
+
}],
|
|
213
|
+
structuredContent: result,
|
|
214
|
+
isError: report.recipes.some(recipe => recipe.error !== undefined)
|
|
215
|
+
};
|
|
216
|
+
} finally {
|
|
217
|
+
await crawler.close();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* A file the server was started with (`OPENCRAW_ACCESS`, `OPENCRAW_HOOKS`).
|
|
222
|
+
* Never a tool argument: a caller picks a profile, never a file, and never
|
|
223
|
+
* names a module for the server to run.
|
|
224
|
+
*/
|
|
225
|
+
function serverFile(name) {
|
|
226
|
+
const file = process.env[name];
|
|
227
|
+
return file === undefined || file === '' ? undefined : file;
|
|
228
|
+
}
|
|
229
|
+
function loadErrorText(error) {
|
|
230
|
+
if (error instanceof RecipeValidationError) return error.issues.map(issue => `${error.source}: ${issue.path === '' ? '(root)' : issue.path}: ${issue.message}`).join('\n');
|
|
231
|
+
if (error instanceof RecipeBindingError) return error.issues.map(issue => `${issue.recipeId}: ${issue.path}: ${issue.message}`).join('\n');
|
|
232
|
+
return error instanceof Error ? error.message : String(error);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Input schema for the `validate` tool. */
|
|
236
|
+
const validateInputShape = recipeSourceShape;
|
|
237
|
+
/**
|
|
238
|
+
* The `validate` tool: loads and binds recipes, returning every problem
|
|
239
|
+
* with its JSON path instead of a printed report, so an agent authoring a
|
|
240
|
+
* recipe can check it without a round trip through a terminal.
|
|
241
|
+
*
|
|
242
|
+
* @param args - The tool's parsed input.
|
|
243
|
+
* @returns The MCP tool result.
|
|
244
|
+
*/
|
|
245
|
+
async function validateTool(args) {
|
|
246
|
+
let files;
|
|
247
|
+
try {
|
|
248
|
+
files = await readRecipeFiles(recipeSourceOf(args));
|
|
249
|
+
} catch (error) {
|
|
250
|
+
return reply({
|
|
251
|
+
ok: false,
|
|
252
|
+
inputs: [],
|
|
253
|
+
issues: issuesOf(error, '')
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
const issues = files.others.map(path => ({
|
|
257
|
+
path: '',
|
|
258
|
+
message: '"kind" is missing or not "input"/"output"',
|
|
259
|
+
source: path
|
|
260
|
+
}));
|
|
261
|
+
if (files.outputs.length === 0) issues.push({
|
|
262
|
+
path: '',
|
|
263
|
+
message: 'no output recipe found',
|
|
264
|
+
source: ''
|
|
265
|
+
});
|
|
266
|
+
if (files.outputs.length > 1) issues.push({
|
|
267
|
+
path: '',
|
|
268
|
+
message: `more than one output recipe: ${files.outputs.map(file => file.path).join(', ')}`,
|
|
269
|
+
source: ''
|
|
270
|
+
});
|
|
271
|
+
const result = {
|
|
272
|
+
ok: issues.length === 0,
|
|
273
|
+
inputs: [],
|
|
274
|
+
issues
|
|
275
|
+
};
|
|
276
|
+
if (files.outputs.length === 1) {
|
|
277
|
+
try {
|
|
278
|
+
const output = parseOutputRecipe(files.outputs[0].recipe, files.outputs[0].path);
|
|
279
|
+
result.output = output.id;
|
|
280
|
+
const inputs = files.inputs.flatMap(file => {
|
|
281
|
+
try {
|
|
282
|
+
const input = parseInputRecipe(file.recipe, file.path);
|
|
283
|
+
result.inputs.push(input.id);
|
|
284
|
+
return [input];
|
|
285
|
+
} catch (error) {
|
|
286
|
+
issues.push(...issuesOf(error, file.path));
|
|
287
|
+
return [];
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
try {
|
|
291
|
+
bindRecipeSet(output, inputs);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
issues.push(...issuesOf(error, output.id));
|
|
294
|
+
}
|
|
295
|
+
} catch (error) {
|
|
296
|
+
issues.push(...issuesOf(error, files.outputs[0].path));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
result.ok = issues.length === 0;
|
|
300
|
+
return reply(result);
|
|
301
|
+
}
|
|
302
|
+
function reply(result) {
|
|
303
|
+
return {
|
|
304
|
+
content: [{
|
|
305
|
+
type: 'text',
|
|
306
|
+
text: JSON.stringify(result)
|
|
307
|
+
}],
|
|
308
|
+
structuredContent: result
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
function issuesOf(error, source) {
|
|
312
|
+
if (error instanceof RecipeValidationError) return error.issues.map(issue => ({
|
|
313
|
+
path: issue.path,
|
|
314
|
+
message: issue.message,
|
|
315
|
+
source: error.source
|
|
316
|
+
}));
|
|
317
|
+
if (error instanceof RecipeBindingError) return error.issues.map(issue => ({
|
|
318
|
+
path: issue.path,
|
|
319
|
+
message: issue.message,
|
|
320
|
+
source: issue.recipeId
|
|
321
|
+
}));
|
|
322
|
+
return [{
|
|
323
|
+
path: '',
|
|
324
|
+
message: error instanceof Error ? error.message : String(error),
|
|
325
|
+
source
|
|
326
|
+
}];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const SERVER_INFO = {
|
|
330
|
+
name: 'opencraw',
|
|
331
|
+
version: '0.0.1'
|
|
332
|
+
};
|
|
333
|
+
/**
|
|
334
|
+
* Builds the MCP server: one tool per crawl primitive (`probe`, `validate`,
|
|
335
|
+
* `run`, `list_recipes`), each a thin wrapper over `@opencraw/core` and
|
|
336
|
+
* `@opencraw/cli`'s structured helpers. No transport is attached; `main`
|
|
337
|
+
* connects it over stdio.
|
|
338
|
+
*
|
|
339
|
+
* @returns The server, ready to `connect`.
|
|
340
|
+
*/
|
|
341
|
+
function createServer() {
|
|
342
|
+
const server = new McpServer(SERVER_INFO);
|
|
343
|
+
server.registerTool('probe', {
|
|
344
|
+
description: 'Fetch a page and report where its data lives: JSON-LD blocks, inline JSON objects, .json URLs, script hosts, API-looking links. Use before writing an input recipe, to see the shape a site\'s data actually takes.',
|
|
345
|
+
inputSchema: probeInputShape
|
|
346
|
+
}, args => probeTool(args));
|
|
347
|
+
server.registerTool('validate', {
|
|
348
|
+
description: 'Load and bind recipes (an output recipe plus its input recipes), from files or passed inline: parse each against its schema, check every mapping resolves, report every problem with its JSON path.',
|
|
349
|
+
inputSchema: validateInputShape
|
|
350
|
+
}, args => validateTool(args));
|
|
351
|
+
server.registerTool('run', {
|
|
352
|
+
description: 'Crawl the recipes at the given paths, or passed inline as "recipes". Use dryRun to check a recipe under construction (one record per input, returned inline); use "out" for anything beyond a handful of records.',
|
|
353
|
+
inputSchema: runInputShape
|
|
354
|
+
}, args => runTool(args));
|
|
355
|
+
server.registerTool('list_recipes', {
|
|
356
|
+
description: 'List the recipe files in a directory, split by kind, with their ids. Check before writing a new recipe, or to see what an existing output recipe covers.',
|
|
357
|
+
inputSchema: listRecipesInputShape
|
|
358
|
+
}, args => listRecipesTool(args));
|
|
359
|
+
return server;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Runs the server over stdio until the transport closes. This is what
|
|
364
|
+
* `bin/opencraw-mcp.mjs` calls; an MCP host launches it as a subprocess and
|
|
365
|
+
* talks JSON-RPC over stdin/stdout, so there is no argv to parse.
|
|
366
|
+
*/
|
|
367
|
+
async function main() {
|
|
368
|
+
const server = createServer();
|
|
369
|
+
await server.connect(new StdioServerTransport());
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export { createServer, listRecipesInputShape, listRecipesTool, main, probeInputShape, probeTool, runInputShape, runTool, validateInputShape, validateTool };
|
|
373
|
+
//# sourceMappingURL=index.esm.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { main } from './main.js';
|
|
2
|
+
export { createServer } from './server/index.js';
|
|
3
|
+
export { probeTool, probeInputShape } from './probe-tool/index.js';
|
|
4
|
+
export { validateTool, validateInputShape } from './validate-tool/index.js';
|
|
5
|
+
export type { ValidateResult } from './validate-tool/index.js';
|
|
6
|
+
export { runTool, runInputShape } from './run-tool/index.js';
|
|
7
|
+
export type { RunResult } from './run-tool/index.js';
|
|
8
|
+
export { listRecipesTool, listRecipesInputShape } from './list-tool/index.js';
|
|
9
|
+
export type { ListRecipesResult } from './list-tool/index.js';
|
|
10
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
/** Input schema for the `list_recipes` tool. */
|
|
4
|
+
export declare const listRecipesInputShape: {
|
|
5
|
+
dir: z.ZodString;
|
|
6
|
+
};
|
|
7
|
+
/** What the `list_recipes` tool returns. */
|
|
8
|
+
export interface ListRecipesResult {
|
|
9
|
+
outputs: {
|
|
10
|
+
path: string;
|
|
11
|
+
id?: string;
|
|
12
|
+
}[];
|
|
13
|
+
inputs: {
|
|
14
|
+
path: string;
|
|
15
|
+
id?: string;
|
|
16
|
+
output?: string;
|
|
17
|
+
mode?: string;
|
|
18
|
+
}[];
|
|
19
|
+
others: string[];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The `list_recipes` tool: what recipes already exist in a directory, cheaper
|
|
23
|
+
* than a full `validate` call, so an agent can check before writing a new one
|
|
24
|
+
* or find what an existing output recipe already covers.
|
|
25
|
+
*
|
|
26
|
+
* @param args - The tool's parsed input.
|
|
27
|
+
* @returns The MCP tool result.
|
|
28
|
+
*/
|
|
29
|
+
export declare function listRecipesTool(args: {
|
|
30
|
+
dir: string;
|
|
31
|
+
}): Promise<CallToolResult>;
|
|
32
|
+
//# sourceMappingURL=list-recipes-tool.handler.d.ts.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs the server over stdio until the transport closes. This is what
|
|
3
|
+
* `bin/opencraw-mcp.mjs` calls; an MCP host launches it as a subprocess and
|
|
4
|
+
* talks JSON-RPC over stdin/stdout, so there is no argv to parse.
|
|
5
|
+
*/
|
|
6
|
+
export declare function main(): Promise<void>;
|
|
7
|
+
//# sourceMappingURL=main.d.ts.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
/** Input schema for the `probe` tool: a raw zod shape, as `McpServer.registerTool` expects. */
|
|
4
|
+
export declare const probeInputShape: {
|
|
5
|
+
url: z.ZodString;
|
|
6
|
+
browser: z.ZodOptional<z.ZodBoolean>;
|
|
7
|
+
browserPath: z.ZodOptional<z.ZodString>;
|
|
8
|
+
insecureTls: z.ZodOptional<z.ZodBoolean>;
|
|
9
|
+
userAgent: z.ZodOptional<z.ZodString>;
|
|
10
|
+
access: z.ZodOptional<z.ZodString>;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* The `probe` tool: fetches a page and reports where its data lives, so an
|
|
14
|
+
* agent can write an extraction recipe without guessing selectors.
|
|
15
|
+
*
|
|
16
|
+
* @param args - The tool's parsed input.
|
|
17
|
+
* @returns The MCP tool result: the probe findings as JSON text.
|
|
18
|
+
*/
|
|
19
|
+
export declare function probeTool(args: {
|
|
20
|
+
url: string;
|
|
21
|
+
browser?: boolean;
|
|
22
|
+
browserPath?: string;
|
|
23
|
+
insecureTls?: boolean;
|
|
24
|
+
userAgent?: string;
|
|
25
|
+
access?: string;
|
|
26
|
+
}): Promise<CallToolResult>;
|
|
27
|
+
//# sourceMappingURL=probe-tool.handler.d.ts.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { RecipeSource } from '@opencraw/core';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
/** The two ways a tool takes recipes: files, or the recipes themselves. */
|
|
4
|
+
export declare const recipeSourceShape: {
|
|
5
|
+
paths: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
6
|
+
recipes: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>]>>;
|
|
7
|
+
};
|
|
8
|
+
/** A tool's recipe arguments. */
|
|
9
|
+
export interface RecipeSourceArgs {
|
|
10
|
+
paths?: string[];
|
|
11
|
+
recipes?: string | Record<string, unknown>[];
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The recipe source a tool call names.
|
|
15
|
+
*
|
|
16
|
+
* @param args - The tool's parsed input.
|
|
17
|
+
* @returns What core reads: the paths, or the inline recipes.
|
|
18
|
+
* @throws Error when the call gives both or neither.
|
|
19
|
+
*/
|
|
20
|
+
export declare function recipeSourceOf(args: RecipeSourceArgs): RecipeSource;
|
|
21
|
+
//# sourceMappingURL=recipe-source.mapper.d.ts.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import type { CrawlReport } from '@opencraw/core';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import type { RecipeSourceArgs } from '../recipe-source/index.js';
|
|
5
|
+
/** Input schema for the `run` tool. */
|
|
6
|
+
export declare const runInputShape: {
|
|
7
|
+
out: z.ZodOptional<z.ZodString>;
|
|
8
|
+
append: z.ZodOptional<z.ZodBoolean>;
|
|
9
|
+
resume: z.ZodOptional<z.ZodBoolean>;
|
|
10
|
+
only: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
11
|
+
dryRun: z.ZodOptional<z.ZodBoolean>;
|
|
12
|
+
headed: z.ZodOptional<z.ZodBoolean>;
|
|
13
|
+
browserPath: z.ZodOptional<z.ZodString>;
|
|
14
|
+
insecureTls: z.ZodOptional<z.ZodBoolean>;
|
|
15
|
+
userAgent: z.ZodOptional<z.ZodString>;
|
|
16
|
+
access: z.ZodOptional<z.ZodString>;
|
|
17
|
+
paths: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
18
|
+
recipes: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>]>>;
|
|
19
|
+
};
|
|
20
|
+
interface RunArgs extends RecipeSourceArgs {
|
|
21
|
+
out?: string;
|
|
22
|
+
append?: boolean;
|
|
23
|
+
resume?: boolean;
|
|
24
|
+
only?: string[];
|
|
25
|
+
dryRun?: boolean;
|
|
26
|
+
headed?: boolean;
|
|
27
|
+
browserPath?: string;
|
|
28
|
+
insecureTls?: boolean;
|
|
29
|
+
userAgent?: string;
|
|
30
|
+
access?: string;
|
|
31
|
+
}
|
|
32
|
+
/** What the `run` tool returns. */
|
|
33
|
+
export interface RunResult {
|
|
34
|
+
report: CrawlReport;
|
|
35
|
+
/** Present when "out" was not given: the records this call produced, capped. */
|
|
36
|
+
records?: Record<string, unknown>[];
|
|
37
|
+
truncated?: boolean;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The `run` tool: crawls the recipes in `paths` or `recipes` (exactly one
|
|
41
|
+
* output recipe, any number of inputs) and returns what happened,
|
|
42
|
+
* structured, instead of the cli's printed summary.
|
|
43
|
+
*
|
|
44
|
+
* @param args - The tool's parsed input.
|
|
45
|
+
* @returns The MCP tool result.
|
|
46
|
+
*/
|
|
47
|
+
export declare function runTool(args: RunArgs): Promise<CallToolResult>;
|
|
48
|
+
export {};
|
|
49
|
+
//# sourceMappingURL=run-tool.handler.d.ts.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
/**
|
|
3
|
+
* Builds the MCP server: one tool per crawl primitive (`probe`, `validate`,
|
|
4
|
+
* `run`, `list_recipes`), each a thin wrapper over `@opencraw/core` and
|
|
5
|
+
* `@opencraw/cli`'s structured helpers. No transport is attached; `main`
|
|
6
|
+
* connects it over stdio.
|
|
7
|
+
*
|
|
8
|
+
* @returns The server, ready to `connect`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function createServer(): McpServer;
|
|
11
|
+
//# sourceMappingURL=create-server.use-case.d.ts.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import type { RecipeSourceArgs } from '../recipe-source/index.js';
|
|
3
|
+
/** Input schema for the `validate` tool. */
|
|
4
|
+
export declare const validateInputShape: {
|
|
5
|
+
paths: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
|
|
6
|
+
recipes: import("zod").ZodOptional<import("zod").ZodUnion<readonly [import("zod").ZodString, import("zod").ZodArray<import("zod").ZodRecord<import("zod").ZodString, import("zod").ZodUnknown>>]>>;
|
|
7
|
+
};
|
|
8
|
+
interface Issue {
|
|
9
|
+
path: string;
|
|
10
|
+
message: string;
|
|
11
|
+
/** The file (`path:line` in JSON Lines), `recipes[i]` / `recipes:line` for inline recipes, or the recipe id when the issue came from binding. */
|
|
12
|
+
source: string;
|
|
13
|
+
}
|
|
14
|
+
/** What the `validate` tool returns. */
|
|
15
|
+
export interface ValidateResult {
|
|
16
|
+
ok: boolean;
|
|
17
|
+
output?: string;
|
|
18
|
+
inputs: string[];
|
|
19
|
+
issues: Issue[];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The `validate` tool: loads and binds recipes, returning every problem
|
|
23
|
+
* with its JSON path instead of a printed report, so an agent authoring a
|
|
24
|
+
* recipe can check it without a round trip through a terminal.
|
|
25
|
+
*
|
|
26
|
+
* @param args - The tool's parsed input.
|
|
27
|
+
* @returns The MCP tool result.
|
|
28
|
+
*/
|
|
29
|
+
export declare function validateTool(args: RecipeSourceArgs): Promise<CallToolResult>;
|
|
30
|
+
export {};
|
|
31
|
+
//# sourceMappingURL=validate-tool.handler.d.ts.map
|
package/package.json
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@opencraw/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.esm.js",
|
|
6
|
+
"module": "./dist/index.esm.js",
|
|
7
|
+
"types": "./dist/src/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"opencraw-mcp": "./bin/opencraw-mcp.mjs"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
"./package.json": "./package.json",
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/src/index.d.ts",
|
|
15
|
+
"import": "./dist/index.esm.js",
|
|
16
|
+
"default": "./dist/index.esm.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"bin",
|
|
22
|
+
"!**/*.tsbuildinfo",
|
|
23
|
+
"!**/*.d.ts.map",
|
|
24
|
+
"!**/*.js.map"
|
|
25
|
+
],
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@opencraw/core": "^0.1.0",
|
|
28
|
+
"@opencraw/cli": "^0.1.0",
|
|
29
|
+
"@modelcontextprotocol/sdk": "^1.30.1",
|
|
30
|
+
"playwright": "^1.63.0",
|
|
31
|
+
"zod": "^4.6.5"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"description": "An MCP server exposing OpenCraw's crawler as agent tools: probe a page, validate recipes, run a crawl, list recipes.",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/russoedu/open.craw.git",
|
|
41
|
+
"directory": "packages/mcp"
|
|
42
|
+
},
|
|
43
|
+
"keywords": [
|
|
44
|
+
"crawler",
|
|
45
|
+
"scraper",
|
|
46
|
+
"mcp",
|
|
47
|
+
"model-context-protocol",
|
|
48
|
+
"recipe"
|
|
49
|
+
],
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=22"
|
|
52
|
+
},
|
|
53
|
+
"sideEffects": false,
|
|
54
|
+
"nx": {
|
|
55
|
+
"targets": {
|
|
56
|
+
"typecheck": {
|
|
57
|
+
"dependsOn": [
|
|
58
|
+
"^build"
|
|
59
|
+
]
|
|
60
|
+
},
|
|
61
|
+
"lint": {
|
|
62
|
+
"dependsOn": [
|
|
63
|
+
"^build"
|
|
64
|
+
]
|
|
65
|
+
},
|
|
66
|
+
"e2e": {
|
|
67
|
+
"executor": "nx:run-commands",
|
|
68
|
+
"dependsOn": [
|
|
69
|
+
"build",
|
|
70
|
+
{
|
|
71
|
+
"projects": [
|
|
72
|
+
"@opencraw/core"
|
|
73
|
+
],
|
|
74
|
+
"target": "e2e"
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"projects": [
|
|
78
|
+
"@opencraw/cli"
|
|
79
|
+
],
|
|
80
|
+
"target": "e2e"
|
|
81
|
+
}
|
|
82
|
+
],
|
|
83
|
+
"inputs": [
|
|
84
|
+
"default",
|
|
85
|
+
"^production",
|
|
86
|
+
"{workspaceRoot}/packages/core/e2e/**"
|
|
87
|
+
],
|
|
88
|
+
"options": {
|
|
89
|
+
"command": "tsc -p tsconfig.e2e.json && jest --config jest.e2e.config.cts",
|
|
90
|
+
"cwd": "packages/mcp"
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|