crawlforge-mcp-server 6.1.0 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,8 +6,17 @@
6
6
 
7
7
  import { z } from 'zod';
8
8
 
9
- export const TrackChangesSchema = z.object({
10
- url: z.string().url().optional(),
9
+ /**
10
+ * The raw input shape — one declaration (G5). server.js spreads it, with the
11
+ * shared compliance params, into the registered inputSchema, so the
12
+ * `.describe()` strings a client sees and the defaults the tool validates
13
+ * with cannot drift apart again (they had: the `email` block and the default
14
+ * `excludeSelectors` never reached tools/list). `.prefault({})` on the option
15
+ * objects fills their inner defaults when the object is omitted; zod 4's
16
+ * `.default({})` would not.
17
+ */
18
+ export const TRACK_CHANGES_INPUT_SHAPE = {
19
+ url: z.string().url().optional().describe("The URL to track changes for (optional for list_scheduled_monitors)"),
11
20
  operation: z.enum([
12
21
  'create_baseline',
13
22
  'compare',
@@ -22,13 +31,10 @@ export const TrackChangesSchema = z.object({
22
31
  'create_alert_rule',
23
32
  'generate_trend_report',
24
33
  'get_monitoring_templates'
25
- ]).default('compare'),
26
-
27
- content: z.string().optional(),
28
- html: z.string().optional(),
34
+ ]).default('compare').describe("Tracking operation to perform"),
29
35
 
30
- respect_robots: z.boolean().optional(),
31
- user_agent: z.string().optional(),
36
+ content: z.string().optional().describe("Content to compare against baseline"),
37
+ html: z.string().optional().describe("HTML content to compare against baseline"),
32
38
 
33
39
  trackingOptions: z.object({
34
40
  granularity: z.enum(['page', 'section', 'element', 'text']).default('section'),
@@ -48,7 +54,7 @@ export const TrackChangesSchema = z.object({
48
54
  moderate: z.number().min(0).max(1).default(0.3),
49
55
  major: z.number().min(0).max(1).default(0.7)
50
56
  }).optional()
51
- }).optional().prefault({}),
57
+ }).optional().prefault({}).describe("Options for how changes are tracked and compared"),
52
58
 
53
59
  monitoringOptions: z.object({
54
60
  enabled: z.boolean().default(false),
@@ -59,7 +65,7 @@ export const TrackChangesSchema = z.object({
59
65
  enableWebhook: z.boolean().default(false),
60
66
  webhookUrl: z.string().url().optional(),
61
67
  webhookSecret: z.string().optional()
62
- }).optional().prefault({}),
68
+ }).optional().prefault({}).describe("Monitoring schedule and notification settings"),
63
69
 
64
70
  storageOptions: z.object({
65
71
  enableSnapshots: z.boolean().default(true),
@@ -67,7 +73,7 @@ export const TrackChangesSchema = z.object({
67
73
  maxHistoryEntries: z.number().min(1).max(1000).default(100),
68
74
  compressionEnabled: z.boolean().default(true),
69
75
  deltaStorageEnabled: z.boolean().default(true)
70
- }).optional().prefault({}),
76
+ }).optional().prefault({}).describe("Storage and history retention settings"),
71
77
 
72
78
  queryOptions: z.object({
73
79
  limit: z.number().min(1).max(500).default(50),
@@ -76,7 +82,7 @@ export const TrackChangesSchema = z.object({
76
82
  endTime: z.number().optional(),
77
83
  includeContent: z.boolean().default(false),
78
84
  significanceFilter: z.enum(['all', 'minor', 'moderate', 'major', 'critical']).optional()
79
- }).optional().prefault({}),
85
+ }).optional().prefault({}).describe("Query options for history and stats retrieval"),
80
86
 
81
87
  notificationOptions: z.object({
82
88
  email: z.object({
@@ -99,17 +105,19 @@ export const TrackChangesSchema = z.object({
99
105
  channel: z.string().optional(),
100
106
  username: z.string().optional()
101
107
  }).optional()
102
- }).optional(),
108
+ }).optional().describe("Notification configuration for webhooks, Slack and email (email is sent by hosted monitors only)"),
103
109
 
104
110
  scheduledMonitorOptions: z.object({
105
- schedule: z.string().optional(),
111
+ schedule: z.string().optional().describe("Optional cron expression (power users)"),
106
112
  templateId: z.string().optional(),
107
113
  enabled: z.boolean().default(true),
108
- interval: z.number().min(60000).optional(),
109
- goal: z.string().optional(),
110
- monitorId: z.string().optional(),
111
- notificationThreshold: z.enum(['minor', 'moderate', 'major', 'critical']).optional()
112
- }).optional(),
114
+ interval: z.number().min(60000).optional().describe("Polling interval in ms (default 1h)"),
115
+ goal: z.string().optional().describe("Plain-English alert goal; an LLM judges whether a change matches (degrades to threshold if no LLM)"),
116
+ monitorId: z.string().optional().describe("Monitor id for stop_scheduled_monitor"),
117
+ notificationThreshold: z.enum(['minor', 'moderate', 'major', 'critical']).optional(),
118
+ hosted: z.boolean().default(false).describe("Run the monitor on CrawlForge's servers: it fires from the hosted scheduler whether or not this process is alive and sends email and signed webhooks. Each check bills 3 credits per compared target from the account; blocked and errored targets are free. Default false = local, in-process."),
119
+ name: z.string().min(1).max(80).optional().describe("Display name for a hosted monitor (default: the URL host)")
120
+ }).optional().describe("Scheduled monitoring: recurring compare + notify, optional plain-English goal"),
113
121
 
114
122
  alertRuleOptions: z.object({
115
123
  ruleId: z.string().optional(),
@@ -117,7 +125,7 @@ export const TrackChangesSchema = z.object({
117
125
  actions: z.array(z.enum(['webhook', 'email', 'slack'])).optional(),
118
126
  throttle: z.number().min(0).optional(),
119
127
  priority: z.enum(['low', 'medium', 'high']).optional()
120
- }).optional(),
128
+ }).optional().describe("Alert rule configuration for change notifications"),
121
129
 
122
130
  exportOptions: z.object({
123
131
  format: z.enum(['json', 'csv']).default('json'),
@@ -125,11 +133,17 @@ export const TrackChangesSchema = z.object({
125
133
  endTime: z.number().optional(),
126
134
  includeContent: z.boolean().default(false),
127
135
  includeSnapshots: z.boolean().default(false)
128
- }).optional(),
136
+ }).optional().describe("Export options for change history data"),
129
137
 
130
138
  dashboardOptions: z.object({
131
139
  includeRecentAlerts: z.boolean().default(true),
132
140
  includeTrends: z.boolean().default(true),
133
141
  includeMonitorStatus: z.boolean().default(true)
134
- }).optional()
142
+ }).optional().describe("Dashboard display options")
143
+ };
144
+
145
+ export const TrackChangesSchema = z.object({
146
+ ...TRACK_CHANGES_INPUT_SHAPE,
147
+ respect_robots: z.boolean().optional(),
148
+ user_agent: z.string().optional()
135
149
  });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Shared JSON-Schema → zod validation.
3
+ *
4
+ * Lifted out of `src/tools/extract/extractWithLlm.js`, where it was local and
5
+ * unexported, so that every consumer of LLM-decoded JSON validates the same
6
+ * way. `LLMManager.validateAgainstSchema` used to hand-roll its own check that
7
+ * only ever looked one level deep: `{countries: ["a string", "another"]}`
8
+ * against `{countries: {type: 'array', items: {type: 'object'}}}` reported
9
+ * `valid: true` because the top-level value was, in fact, an array (R19).
10
+ */
11
+
12
+ import { z } from 'zod';
13
+
14
+ /**
15
+ * Build a zod validator from a JSON-Schema-like hint. Best-effort: unknown
16
+ * shapes fall back to `z.any()` so validation never rejects on constructs the
17
+ * converter does not understand.
18
+ */
19
+ export function jsonSchemaToZod(schema) {
20
+ if (!schema || typeof schema !== 'object') return z.any();
21
+
22
+ // Flat hint map (no `type`/`properties`) → treat values as field hints.
23
+ const isJsonSchema = schema.type || schema.properties || schema.items;
24
+ if (!isJsonSchema) {
25
+ const shape = {};
26
+ for (const [key, val] of Object.entries(schema)) {
27
+ shape[key] = jsonSchemaToZod(typeof val === 'string' ? { type: val } : val).nullable().optional();
28
+ }
29
+ return z.object(shape).passthrough();
30
+ }
31
+
32
+ switch (schema.type) {
33
+ case 'string': return z.string();
34
+ case 'number':
35
+ case 'integer': return z.number();
36
+ case 'boolean': return z.boolean();
37
+ case 'null': return z.null();
38
+ case 'array': return z.array(schema.items ? jsonSchemaToZod(schema.items) : z.any());
39
+ case 'object': {
40
+ const shape = {};
41
+ const required = Array.isArray(schema.required) ? schema.required : [];
42
+ for (const [key, val] of Object.entries(schema.properties || {})) {
43
+ const field = jsonSchemaToZod(val);
44
+ // The model is told to answer null for a field the content never
45
+ // states, so null is the honest answer for a field the schema does
46
+ // not require — not a type violation. A required field stays strict:
47
+ // null there is exactly what the caller needs to hear about.
48
+ shape[key] = required.includes(key) ? field : field.nullable().optional();
49
+ }
50
+ return z.object(shape).passthrough();
51
+ }
52
+ default: return z.any();
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Validate parsed output against the schema hint.
58
+ * @returns {{ valid: boolean, errors: string[] }}
59
+ */
60
+ export function validateAgainstSchema(parsed, schema) {
61
+ try {
62
+ const validator = jsonSchemaToZod(schema);
63
+ const result = validator.safeParse(parsed);
64
+ if (result.success) return { valid: true, errors: [] };
65
+ return {
66
+ valid: false,
67
+ errors: result.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
68
+ };
69
+ } catch {
70
+ // Converter failure should not block extraction — treat as unvalidated.
71
+ return { valid: true, errors: [] };
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Validate against a schema and report problems per field, in the wording
77
+ * callers see in tool output: "Missing required field: x" and
78
+ * `Field "x": expected number, got string`, with a dotted path for anything
79
+ * nested (`Field "countries.0.capital": ...`).
80
+ *
81
+ * Same structural check as validateAgainstSchema — this differs only in how
82
+ * the failures are worded, and in also checking `enum`, which the zod
83
+ * converter does not carry.
84
+ *
85
+ * @returns {{ valid: boolean, errors: string[] }}
86
+ */
87
+ export function validateFieldsAgainstSchema(data, schema) {
88
+ const required = Array.isArray(schema?.required) ? schema.required : [];
89
+ const properties = schema?.properties || {};
90
+ const errors = [];
91
+
92
+ let issues = [];
93
+ try {
94
+ const result = jsonSchemaToZod(schema).safeParse(data);
95
+ if (!result.success) issues = result.error.issues;
96
+ } catch {
97
+ // The converter is best-effort and falls back to z.any() rather than
98
+ // throwing, so this is unreachable in practice. Treat it as unvalidated
99
+ // rather than failing an extraction on a validator bug.
100
+ return { valid: true, errors: [] };
101
+ }
102
+
103
+ for (const issue of issues) {
104
+ const path = issue.path;
105
+ const value = path.reduce((acc, key) => (acc == null ? undefined : acc[key]), data);
106
+ // A required field the decoder left null is "not filled in", the same as
107
+ // absent — the caller wants to hear it is missing, not that null is the
108
+ // wrong type. Optional nulls never reach here: the converter allows them.
109
+ if (path.length === 1 && required.includes(path[0]) && (value === null || value === undefined)) {
110
+ errors.push(`Missing required field: ${path[0]}`);
111
+ continue;
112
+ }
113
+ const where = path.length ? path.join('.') : '(root)';
114
+ if (issue.code === 'invalid_type' && issue.expected) {
115
+ const actualType = Array.isArray(value) ? 'array' : typeof value;
116
+ errors.push(`Field "${where}": expected ${issue.expected}, got ${actualType}`);
117
+ } else {
118
+ errors.push(`Field "${where}": ${issue.message}`);
119
+ }
120
+ }
121
+
122
+ // The converter carries no `enum`, so that check stays here. Top level only.
123
+ for (const [key, fieldSchema] of Object.entries(properties)) {
124
+ const value = data?.[key];
125
+ if (value === null || value === undefined) continue;
126
+ if (fieldSchema?.enum && !fieldSchema.enum.includes(value)) {
127
+ errors.push(`Field "${key}": value "${value}" not in enum ${JSON.stringify(fieldSchema.enum)}`);
128
+ }
129
+ }
130
+
131
+ // A malformed array can raise one issue per element; the full list is pushed
132
+ // into tool output, so cap it.
133
+ const MAX_REPORTED = 10;
134
+ const reported = errors.length > MAX_REPORTED
135
+ ? [...errors.slice(0, MAX_REPORTED), `…and ${errors.length - MAX_REPORTED} more validation errors`]
136
+ : errors;
137
+
138
+ return { valid: errors.length === 0, errors: reported };
139
+ }