crawlforge-mcp-server 6.1.0 → 6.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "6.1.0",
3
+ "version": "6.2.0",
4
4
  "mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
5
5
  "description": "CrawlForge MCP Server - Professional Model Context Protocol server with 30 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -23,7 +23,7 @@ import { ListOllamaModelsTool } from "./src/tools/extract/listOllamaModels.js";
23
23
  import { BatchScrapeTool } from "./src/tools/advanced/BatchScrapeTool.js";
24
24
  import { ScrapeWithActionsTool } from "./src/tools/advanced/ScrapeWithActionsTool.js";
25
25
  import { DeepResearchTool } from "./src/tools/research/deepResearch.js";
26
- import { TrackChangesTool } from "./src/tools/tracking/trackChanges/index.js";
26
+ import { TrackChangesTool, TRACK_CHANGES_INPUT_SHAPE } from "./src/tools/tracking/trackChanges/index.js";
27
27
  import { GenerateLLMsTxtTool } from "./src/tools/llmstxt/generateLLMsTxt.js";
28
28
  import { ScrapeTemplateTool } from "./src/tools/templates/ScrapeTemplateTool.js"; // D3.3
29
29
  import { UnifiedScrapeTool, SCRAPE_INPUT_SHAPE } from "./src/tools/scrape/unifiedScrape.js"; // D4 D1
@@ -107,7 +107,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
107
107
  // Create the server
108
108
  const server = new McpServer({
109
109
  name: "crawlforge",
110
- version: "6.1.0",
110
+ version: "6.2.0",
111
111
  description: "Production-ready MCP server with 30 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
112
112
  homepage: "https://www.crawlforge.dev",
113
113
  icon: "https://www.crawlforge.dev/icon.png",
@@ -1138,103 +1138,11 @@ registerToolIfEnabled("agent", {
1138
1138
 
1139
1139
  // Tool: track_changes
1140
1140
  registerToolIfEnabled("track_changes", {
1141
- description: "Use this to monitor a URL for content changes over time - competitor pricing, regulation updates, product availability. Start with operation:\"create_baseline\", then periodically use operation:\"compare\" to diff; repeated compare calls on the same URL are expected. Supports webhooks and scheduled monitoring. Not for a one-off read (scrape). Cost: 3 credits. Example: track_changes({url: \"https://example.com/pricing\", operation: \"create_baseline\"})",
1141
+ description: "Use this to monitor a URL for content changes over time - competitor pricing, regulation updates, product availability. Start with operation:\"create_baseline\", then periodically use operation:\"compare\" to diff; repeated compare calls on the same URL are expected. Supports webhooks and scheduled monitoring, and scheduledMonitorOptions.hosted:true runs the monitor on CrawlForge's servers with email and signed webhooks. Not for a one-off read (scrape). Cost: 3 credits. Example: track_changes({url: \"https://example.com/pricing\", operation: \"create_baseline\"})",
1142
1142
  annotations: { title: "Track Changes", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1143
+ // The tool module owns the schema (G5); this is the same shape it validates with.
1143
1144
  inputSchema: {
1144
- url: z.string().url().optional().describe("The URL to track changes for (optional for list_scheduled_monitors)"),
1145
- operation: z.enum([
1146
- 'create_baseline', 'compare', 'monitor', 'get_history', 'get_stats',
1147
- 'create_scheduled_monitor', 'stop_scheduled_monitor', 'list_scheduled_monitors', 'get_dashboard',
1148
- 'export_history', 'create_alert_rule', 'generate_trend_report', 'get_monitoring_templates'
1149
- ]).default('compare').describe("Tracking operation to perform"),
1150
- content: z.string().optional().describe("Content to compare against baseline"),
1151
- html: z.string().optional().describe("HTML content to compare against baseline"),
1152
- trackingOptions: z.object({
1153
- granularity: z.enum(['page', 'section', 'element', 'text']).default('section'),
1154
- trackText: z.boolean().default(true),
1155
- trackStructure: z.boolean().default(true),
1156
- trackAttributes: z.boolean().default(false),
1157
- trackImages: z.boolean().default(false),
1158
- trackLinks: z.boolean().default(true),
1159
- ignoreWhitespace: z.boolean().default(true),
1160
- ignoreCase: z.boolean().default(false),
1161
- customSelectors: z.array(z.string()).optional(),
1162
- excludeSelectors: z.array(z.string()).optional(),
1163
- significanceThresholds: z.object({
1164
- minor: z.number().min(0).max(1).default(0.1),
1165
- moderate: z.number().min(0).max(1).default(0.3),
1166
- major: z.number().min(0).max(1).default(0.7)
1167
- }).optional()
1168
- }).optional().describe("Options for how changes are tracked and compared"),
1169
- monitoringOptions: z.object({
1170
- enabled: z.boolean().default(false),
1171
- interval: z.number().min(60000).max(24 * 60 * 60 * 1000).default(300000),
1172
- maxRetries: z.number().min(0).max(5).default(3),
1173
- retryDelay: z.number().min(1000).max(60000).default(5000),
1174
- notificationThreshold: z.enum(['minor', 'moderate', 'major', 'critical']).default('moderate'),
1175
- enableWebhook: z.boolean().default(false),
1176
- webhookUrl: z.string().url().optional(),
1177
- webhookSecret: z.string().optional()
1178
- }).optional().describe("Monitoring schedule and notification settings"),
1179
- storageOptions: z.object({
1180
- enableSnapshots: z.boolean().default(true),
1181
- retainHistory: z.boolean().default(true),
1182
- maxHistoryEntries: z.number().min(1).max(1000).default(100),
1183
- compressionEnabled: z.boolean().default(true),
1184
- deltaStorageEnabled: z.boolean().default(true)
1185
- }).optional().describe("Storage and history retention settings"),
1186
- queryOptions: z.object({
1187
- limit: z.number().min(1).max(500).default(50),
1188
- offset: z.number().min(0).default(0),
1189
- startTime: z.number().optional(),
1190
- endTime: z.number().optional(),
1191
- includeContent: z.boolean().default(false),
1192
- significanceFilter: z.enum(['all', 'minor', 'moderate', 'major', 'critical']).optional()
1193
- }).optional().describe("Query options for history and stats retrieval"),
1194
- notificationOptions: z.object({
1195
- webhook: z.object({
1196
- enabled: z.boolean().default(false),
1197
- url: z.string().url().optional(),
1198
- method: z.enum(['POST', 'PUT']).default('POST'),
1199
- headers: z.record(z.string()).optional(),
1200
- signingSecret: z.string().optional(),
1201
- includeContent: z.boolean().default(false)
1202
- }).optional(),
1203
- slack: z.object({
1204
- enabled: z.boolean().default(false),
1205
- webhookUrl: z.string().url().optional(),
1206
- channel: z.string().optional(),
1207
- username: z.string().optional()
1208
- }).optional()
1209
- }).optional().describe("Notification configuration for webhooks and Slack"),
1210
- scheduledMonitorOptions: z.object({
1211
- schedule: z.string().optional().describe("Optional cron expression (power users)"),
1212
- templateId: z.string().optional(),
1213
- enabled: z.boolean().default(true),
1214
- interval: z.number().min(60000).optional().describe("Polling interval in ms (default 1h)"),
1215
- goal: z.string().optional().describe("Plain-English alert goal; an LLM judges whether a change matches (degrades to threshold if no LLM)"),
1216
- monitorId: z.string().optional().describe("Monitor id for stop_scheduled_monitor"),
1217
- notificationThreshold: z.enum(['minor', 'moderate', 'major', 'critical']).optional()
1218
- }).optional().describe("Scheduled monitoring: recurring compare + notify, optional plain-English goal"),
1219
- alertRuleOptions: z.object({
1220
- ruleId: z.string().optional(),
1221
- condition: z.string().optional(),
1222
- actions: z.array(z.enum(['webhook', 'email', 'slack'])).optional(),
1223
- throttle: z.number().min(0).optional(),
1224
- priority: z.enum(['low', 'medium', 'high']).optional()
1225
- }).optional().describe("Alert rule configuration for change notifications"),
1226
- exportOptions: z.object({
1227
- format: z.enum(['json', 'csv']).default('json'),
1228
- startTime: z.number().optional(),
1229
- endTime: z.number().optional(),
1230
- includeContent: z.boolean().default(false),
1231
- includeSnapshots: z.boolean().default(false)
1232
- }).optional().describe("Export options for change history data"),
1233
- dashboardOptions: z.object({
1234
- includeRecentAlerts: z.boolean().default(true),
1235
- includeTrends: z.boolean().default(true),
1236
- includeMonitorStatus: z.boolean().default(true)
1237
- }).optional().describe("Dashboard display options"),
1145
+ ...TRACK_CHANGES_INPUT_SHAPE,
1238
1146
  ...COMPLIANCE_PARAMS
1239
1147
  }
1240
1148
  }, withAuth("track_changes", async (params) => {
@@ -61,19 +61,33 @@ export function register(program) {
61
61
  .option('--threshold <level>', 'Notification threshold: minor|moderate|major|critical', 'moderate')
62
62
  .option('--cron <expr>', 'Optional cron expression (advanced)')
63
63
  .option('--selector <css>', 'CSS selector to scope monitoring')
64
+ .option('--hosted', "Run the monitor on CrawlForge's servers (fires without this process; email + signed webhooks; 3 credits per compared target per check)")
65
+ .option('--email <addresses>', 'Comma-separated notification emails (sent by hosted monitors only)')
66
+ .option('--name <text>', 'Display name for a hosted monitor (default: the URL host)')
64
67
  .action(async (url, opts) => {
65
68
  const tool = new TrackChangesTool(getToolConfig('track_changes'));
69
+ const notificationOptions = {
70
+ ...(opts.webhook ? { webhook: { enabled: true, url: opts.webhook } } : {}),
71
+ ...(opts.email ? { email: { enabled: true, recipients: opts.email.split(',').map((s) => s.trim()).filter(Boolean) } } : {})
72
+ };
73
+ if (opts.email && !opts.hosted) {
74
+ process.stderr.write('Warning: local monitors do not send email; add --hosted for --email to take effect.\n');
75
+ }
66
76
  try {
67
77
  const res = await tool.execute({
68
78
  url,
69
79
  operation: 'create_scheduled_monitor',
70
80
  ...(opts.selector ? { trackingOptions: { customSelectors: [opts.selector] } } : {}),
71
- ...(opts.webhook ? { notificationOptions: { webhook: { enabled: true, url: opts.webhook } } } : {}),
81
+ ...(Object.keys(notificationOptions).length ? { notificationOptions } : {}),
72
82
  scheduledMonitorOptions: {
73
83
  interval: Math.max(parseInt(opts.every, 10), 60) * 1000,
74
84
  ...(opts.goal ? { goal: opts.goal } : {}),
75
85
  ...(opts.cron ? { schedule: opts.cron } : {}),
76
- notificationThreshold: opts.threshold
86
+ ...(opts.hosted ? { hosted: true } : {}),
87
+ ...(opts.name ? { name: opts.name } : {}),
88
+ // Local only: a hosted check has no significance threshold, and
89
+ // the option's default would otherwise warn on every hosted create.
90
+ ...(opts.hosted ? {} : { notificationThreshold: opts.threshold })
77
91
  }
78
92
  });
79
93
  emit(res);
@@ -86,7 +100,7 @@ export function register(program) {
86
100
 
87
101
  program
88
102
  .command('monitor:list')
89
- .description('List persisted scheduled monitors')
103
+ .description('List scheduled monitors (local and hosted)')
90
104
  .action(async () => {
91
105
  const tool = new TrackChangesTool(getToolConfig('track_changes'));
92
106
  try {
@@ -100,7 +114,7 @@ export function register(program) {
100
114
 
101
115
  program
102
116
  .command('monitor:stop <id>')
103
- .description('Stop and remove a scheduled monitor by id')
117
+ .description('Stop and remove a scheduled monitor by id (local or hosted)')
104
118
  .action(async (id) => {
105
119
  const tool = new TrackChangesTool(getToolConfig('track_changes'));
106
120
  try {
@@ -68,7 +68,14 @@ CLI: `crawlforge track https://example.com --selector ".price" --threshold 1`.
68
68
 
69
69
  ## Scheduled monitoring & notifications
70
70
 
71
- Run continuous monitoring with webhooks instead of polling manually:
71
+ `create_scheduled_monitor` repeats `compare` on a schedule and notifies on
72
+ change. It comes in two kinds.
73
+
74
+ **Local** (default): persisted in `~/.crawlforge/monitors/`; fires in-process
75
+ only while this MCP server runs (missed runs catch up on restart; `crawlforge
76
+ monitor:run-due` from system cron guarantees firing). Notifies by webhook or
77
+ Slack — never email. `goal` (plain-English LLM judge) and
78
+ `notificationThreshold` apply to local monitors only.
72
79
 
73
80
  ```json
74
81
  {
@@ -76,19 +83,47 @@ Run continuous monitoring with webhooks instead of polling manually:
76
83
  "params": {
77
84
  "url": "https://example.com/pricing",
78
85
  "operation": "create_scheduled_monitor",
79
- "monitoringOptions": {
80
- "enabled": true,
81
- "interval": 300000,
82
- "notificationThreshold": "moderate",
83
- "enableWebhook": true,
84
- "webhookUrl": "https://my-site.com/notify"
86
+ "scheduledMonitorOptions": { "interval": 1800000, "notificationThreshold": "moderate" },
87
+ "notificationOptions": { "webhook": { "enabled": true, "url": "https://my-site.com/notify" } }
88
+ }
89
+ }
90
+ ```
91
+
92
+ **Hosted** (`scheduledMonitorOptions.hosted: true`): registered with
93
+ CrawlForge's hosted monitors API under your API key; nothing is created locally
94
+ and this process never fetches the page. CrawlForge's own scheduler runs every
95
+ check whether or not this process is alive, records it, and notifies by email
96
+ and signed webhook on every changed, new, blocked or errored page. Passing
97
+ `goal` or `notificationThreshold` adds a `warnings` entry.
98
+
99
+ ```json
100
+ {
101
+ "tool": "track_changes",
102
+ "params": {
103
+ "url": "https://example.com/pricing",
104
+ "operation": "create_scheduled_monitor",
105
+ "trackingOptions": { "customSelectors": [".price"] },
106
+ "scheduledMonitorOptions": { "hosted": true, "interval": 21600000 },
107
+ "notificationOptions": {
108
+ "email": { "enabled": true, "recipients": ["you@example.com"] },
109
+ "webhook": { "enabled": true, "url": "https://my-site.com/notify" }
85
110
  }
86
111
  }
87
112
  }
88
113
  ```
89
114
 
90
- CLI (runs until Ctrl+C):
91
- `crawlforge monitor https://example.com --interval 60 --webhook https://my-site.com/notify`.
115
+ Hosted mapping: each `customSelectors` entry becomes a target selector on the
116
+ URL; `schedule` (cron) passes through, otherwise `interval` becomes a cron of
117
+ 5–60 minutes dividing 60, whole hours dividing 24, or daily — other values
118
+ round to the nearest and `warnings` says what they became. Up to 5 email
119
+ recipients. `webhook.signingSecret` (16–128 chars) becomes the webhook secret;
120
+ omit it and the response returns a generated `webhookSecret`. The response's
121
+ `monitor` carries the hosted `id`, `nextRunAt`, `estimatedCreditsPerMonth` and
122
+ a `dashboardUrl` for managing it.
123
+
124
+ CLI: `crawlforge monitor:create <url> --every 1800 --webhook <url>` (local) or
125
+ `crawlforge monitor:create <url> --hosted --email you@example.com` (hosted);
126
+ `monitor:list` shows both kinds and `monitor:stop <id>` removes either.
92
127
 
93
128
  ## Other operations
94
129
 
@@ -99,10 +134,12 @@ CLI (runs until Ctrl+C):
99
134
  | `monitor` | One monitoring pass. |
100
135
  | `get_history` | Retrieve past change records (`queryOptions`). |
101
136
  | `get_stats` | Summary statistics for a tracked URL. |
102
- | `create_scheduled_monitor` / `stop_scheduled_monitor` | Manage cron-style monitors. |
137
+ | `create_scheduled_monitor` | Recurring `compare` + notify; local by default, `scheduledMonitorOptions.hosted: true` for a CrawlForge-run monitor (see above). |
138
+ | `list_scheduled_monitors` | Local monitors (`hosted: false`) then hosted ones (`hosted: true`), with `localCount`/`hostedCount`; `hostedError` if the website is unreachable. |
139
+ | `stop_scheduled_monitor` | By `scheduledMonitorOptions.monitorId`: stops a local monitor, or deletes the hosted one with that id. By `url` alone: stops every local monitor on it and deletes hosted monitors whose targets are all that exact URL. |
103
140
  | `get_dashboard` | Aggregate status, recent alerts, trends. |
104
141
  | `export_history` | Export change history as `json` or `csv`. |
105
- | `create_alert_rule` | Conditional alerts (webhook / email / slack). |
142
+ | `create_alert_rule` | Conditional alerts fired from `compare` (webhook / slack; the email action is not sent by a local process — use a hosted monitor for email). |
106
143
  | `generate_trend_report` | Trend analysis over time. |
107
144
  | `get_monitoring_templates` | List built-in monitoring presets. |
108
145
 
@@ -111,6 +148,8 @@ against the baseline without re-fetching.
111
148
 
112
149
  ## Cost note
113
150
 
114
- `track_changes` = 3 credits per call. A typical watch is one `create_baseline`
115
- plus periodic `compare` calls; scheduled monitors run server-side and notify via
116
- webhook/Slack so you don't pay for manual polling loops.
151
+ `track_changes` = 3 credits per call, except that creating a hosted monitor or
152
+ stopping a hosted-only one charges 0 (3 is the projected ceiling). A typical
153
+ watch is one `create_baseline` plus periodic `compare` calls, or one scheduled
154
+ monitor. Each hosted check bills 3 credits per compared target to your account;
155
+ blocked and errored targets are free.
@@ -0,0 +1,176 @@
1
+ /**
2
+ * TrackChanges — hosted monitors (Phase 6.1).
3
+ *
4
+ * `scheduledMonitorOptions.hosted: true` registers a monitor with the
5
+ * website's /api/v1/monitors instead of the local MonitorStore. The website's
6
+ * cron then fetches, compares, bills the account and sends the notifications
7
+ * (email, signed webhooks), so the monitor fires whether or not this process
8
+ * is alive. This module is the thin client; index.js decides when to use it
9
+ * and shapes the tool results.
10
+ *
11
+ * These calls go to our own configured backend (AuthManager.apiEndpoint, from
12
+ * CRAWLFORGE_API_URL through endpointGuard), not to a caller-supplied URL, so
13
+ * they use bare fetch with the X-API-Key header exactly as AuthManager does.
14
+ * The SSRF guard is for pages a caller names; the endpoint is legitimately
15
+ * localhost in development.
16
+ */
17
+ import authManager from '../../../core/AuthManager.js';
18
+
19
+ const HOSTED_TIMEOUT_MS = 30_000;
20
+ const MINUTE = 60_000;
21
+ const HOUR = 60 * MINUTE;
22
+
23
+ export const HOSTED_FIRING_GUARANTEE_NOTE =
24
+ "Runs from CrawlForge's scheduler whether or not this process is alive. Each check bills " +
25
+ '3 credits per compared target from the account; blocked and errored targets are free.';
26
+
27
+ export const NO_KEY_MESSAGE =
28
+ 'hosted monitors need a CrawlForge API key — run `crawlforge-setup` or `crawlforge login`';
29
+
30
+ /**
31
+ * The endpoint and key the hosted calls authenticate with, from the sources
32
+ * the server and the CLI already use: CRAWLFORGE_API_KEY (the CLI's preAction
33
+ * hook fills it from --api-key or the stored config), then the key AuthManager
34
+ * loaded at startup, then the stored config read directly — the server skips
35
+ * loading it in creator mode, and a hosted monitor is billed to an account
36
+ * either way. No network: initialize() would re-validate the key.
37
+ */
38
+ export async function resolveHostedCredentials() {
39
+ let apiKey = process.env.CRAWLFORGE_API_KEY || authManager.getConfig()?.apiKey;
40
+ if (!apiKey) {
41
+ try {
42
+ await authManager.loadConfig();
43
+ apiKey = authManager.getConfig()?.apiKey;
44
+ } catch {
45
+ /* no stored config */
46
+ }
47
+ }
48
+ if (!apiKey) throw new Error(NO_KEY_MESSAGE);
49
+ return { endpoint: authManager.apiEndpoint, apiKey };
50
+ }
51
+
52
+ // resolveApiEndpoint keeps a trailing slash on the configured endpoint; joined
53
+ // to an absolute path that is `//api/...`, a redirect on every call and a
54
+ // dashboard link with a double slash.
55
+ const base = (endpoint) => String(endpoint).replace(/\/+$/, '');
56
+
57
+ async function request(method, pathname, creds, body) {
58
+ const response = await fetch(`${base(creds.endpoint)}${pathname}`, {
59
+ method,
60
+ headers: {
61
+ 'X-API-Key': creds.apiKey,
62
+ ...(body ? { 'Content-Type': 'application/json' } : {})
63
+ },
64
+ ...(body ? { body: JSON.stringify(body) } : {}),
65
+ signal: AbortSignal.timeout(HOSTED_TIMEOUT_MS)
66
+ });
67
+ let payload = null;
68
+ try {
69
+ payload = await response.json();
70
+ } catch {
71
+ /* no JSON body */
72
+ }
73
+ if (!response.ok) {
74
+ // `{ error: { code, message, details? } }` from the monitors API; the
75
+ // API-key middleware's 401 is the same envelope. The website's own words
76
+ // reach the caller so a validation or robots refusal is readable.
77
+ const err = payload?.error;
78
+ const code = err?.code || `HTTP_${response.status}`;
79
+ const message = (typeof err === 'string' ? err : err?.message) || response.statusText || 'request failed';
80
+ const details = err?.details !== undefined ? ` ${JSON.stringify(err.details)}` : '';
81
+ const failure = new Error(`${code}: ${message}${details}`);
82
+ failure.code = code;
83
+ failure.status = response.status;
84
+ throw failure;
85
+ }
86
+ return payload?.data;
87
+ }
88
+
89
+ export function createHostedMonitor(input, creds) {
90
+ return request('POST', '/api/v1/monitors', creds, input);
91
+ }
92
+
93
+ export async function listHostedMonitors(creds) {
94
+ // An account holds at most 50 monitors, so one page is the whole list.
95
+ return (await request('GET', '/api/v1/monitors?limit=100', creds)) ?? [];
96
+ }
97
+
98
+ export function deleteHostedMonitor(id, creds) {
99
+ return request('DELETE', `/api/v1/monitors/${encodeURIComponent(id)}`, creds);
100
+ }
101
+
102
+ // The cron slots the website accepts: consecutive runs at least 5 minutes
103
+ // apart. For a `*/N` minute step that means N must divide 60 (`*/7` has a
104
+ // 4-minute gap at the top of every hour); for an hour step, H must divide 24.
105
+ const SLOTS = [
106
+ ...[5, 6, 10, 12, 15, 20, 30].map((m) => ({ ms: m * MINUTE, cron: `*/${m} * * * *` })),
107
+ { ms: HOUR, cron: '0 * * * *' },
108
+ ...[2, 3, 4, 6, 8, 12].map((h) => ({ ms: h * HOUR, cron: `0 */${h} * * *` })),
109
+ { ms: 24 * HOUR, cron: '0 0 * * *' }
110
+ ];
111
+
112
+ /**
113
+ * The hosted schedule for a polling interval in ms.
114
+ * @returns {{ cron: string, effectiveIntervalMs: number, adjusted: boolean }}
115
+ * `adjusted` is true when the interval was not an accepted slot and the
116
+ * nearest one was used; a tie goes to the longer interval (fewer billed checks).
117
+ */
118
+ export function intervalToCron(ms) {
119
+ let best = SLOTS[0];
120
+ for (const slot of SLOTS) {
121
+ const d = Math.abs(slot.ms - ms);
122
+ const bestD = Math.abs(best.ms - ms);
123
+ if (d < bestD || (d === bestD && slot.ms > best.ms)) best = slot;
124
+ }
125
+ return { cron: best.cron, effectiveIntervalMs: best.ms, adjusted: best.ms !== ms };
126
+ }
127
+
128
+ export function formatInterval(ms) {
129
+ return ms % HOUR === 0 ? `${ms / HOUR} h` : `${Math.round(ms / MINUTE)} min`;
130
+ }
131
+
132
+ const parseIso = (iso) => (iso ? Date.parse(iso) || null : null);
133
+
134
+ export function hostedDashboardUrl(endpoint, id) {
135
+ return `${base(endpoint)}/dashboard/monitors/${id}`;
136
+ }
137
+
138
+ /** The `monitor` a hosted create_scheduled_monitor returns. */
139
+ export function createdHostedMonitor(record, endpoint) {
140
+ return {
141
+ id: record.id,
142
+ hosted: true,
143
+ name: record.name,
144
+ targets: record.targets,
145
+ schedule: record.schedule_cron,
146
+ timezone: record.timezone,
147
+ notifyEmails: record.notify_emails,
148
+ webhookUrl: record.webhook_url,
149
+ webhookSecret: record.webhook_secret,
150
+ status: record.status,
151
+ nextRunAt: parseIso(record.next_run_at),
152
+ estimatedCreditsPerMonth: record.estimated_credits_per_month,
153
+ dashboardUrl: hostedDashboardUrl(endpoint, record.id)
154
+ };
155
+ }
156
+
157
+ /** A hosted monitor as list_scheduled_monitors shows it, beside the local ones. */
158
+ export function listedHostedMonitor(record, endpoint) {
159
+ const active = record.status === 'active';
160
+ return {
161
+ id: record.id,
162
+ hosted: true,
163
+ url: record.targets?.[0]?.url,
164
+ targets: record.targets,
165
+ name: record.name,
166
+ schedule: record.schedule_cron,
167
+ timezone: record.timezone,
168
+ enabled: active,
169
+ nextDueAt: parseIso(record.next_run_at),
170
+ lastCheckAt: parseIso(record.last_check_at),
171
+ lastCheck: record.last_check ?? null,
172
+ estimatedCreditsPerMonth: record.estimated_credits_per_month,
173
+ dashboardUrl: hostedDashboardUrl(endpoint, record.id),
174
+ scheduled: active
175
+ };
176
+ }
@@ -21,10 +21,18 @@ import SnapshotManager from '../../../core/SnapshotManager.js';
21
21
  import CacheManager from '../../../core/cache/CacheManager.js';
22
22
  import { MonitorStore } from '../../../core/MonitorStore.js';
23
23
  import { MonitorScheduler } from '../../../core/MonitorScheduler.js';
24
+ import { setActualCost } from '../../../server/requestContext.js';
24
25
  import { TrackChangesSchema } from './schema.js';
25
26
  import { fetchContent, mergeHistoryData, matchesSignificanceFilter, calculateAverageInterval, calculateSignificanceDistribution } from './differ.js';
26
27
  import { performMonitoringCheck, stopMonitor } from './monitor.js';
27
28
  import { sendNotifications } from './notifier.js';
29
+ import {
30
+ HOSTED_FIRING_GUARANTEE_NOTE, createHostedMonitor, createdHostedMonitor, deleteHostedMonitor,
31
+ formatInterval, intervalToCron, listHostedMonitors, listedHostedMonitor, resolveHostedCredentials
32
+ } from './hosted.js';
33
+
34
+ // server.js spreads this into the registered inputSchema (G5: one declaration).
35
+ export { TRACK_CHANGES_INPUT_SHAPE } from './schema.js';
28
36
 
29
37
  export class TrackChangesTool extends EventEmitter {
30
38
  constructor(options = {}) {
@@ -42,6 +50,9 @@ export class TrackChangesTool extends EventEmitter {
42
50
  enableRealTimeMonitoring: true,
43
51
  maxConcurrentMonitors: 50,
44
52
  defaultPollingInterval: 300000,
53
+ // The key and endpoint hosted monitors authenticate with; tests inject
54
+ // a stub so nothing reads ~/.crawlforge or reaches the website.
55
+ resolveHostedCredentials,
45
56
  ...options
46
57
  };
47
58
 
@@ -404,6 +415,9 @@ export class TrackChangesTool extends EventEmitter {
404
415
  );
405
416
  }
406
417
  }
418
+ if (opts.hosted) {
419
+ return this._createHostedMonitor({ url, opts, preset, trackingOptions, notificationOptions });
420
+ }
407
421
  // Precedence: scheduledMonitorOptions > preset > monitoringOptions. The
408
422
  // schema fills monitoringOptions.interval/notificationThreshold with
409
423
  // defaults, so they cannot sit above a preset without always winning.
@@ -423,25 +437,127 @@ export class TrackChangesTool extends EventEmitter {
423
437
  };
424
438
  }
425
439
 
440
+ /**
441
+ * Hosted (6.1): the website's /api/v1/monitors owns the monitor — its cron
442
+ * fetches, compares, bills and notifies — so nothing is stored or fetched
443
+ * here. The interval precedence matches the local path except that
444
+ * monitoringOptions.interval (schema-defaulted to 5 min) is not consulted:
445
+ * a hosted check is billed, and the website's own default is hourly.
446
+ */
447
+ async _createHostedMonitor({ url, opts, preset, trackingOptions, notificationOptions }) {
448
+ const creds = await this.options.resolveHostedCredentials();
449
+ const warnings = [];
450
+ let scheduleCron = opts.schedule;
451
+ const interval = opts.interval ?? preset?.frequency;
452
+ if (!scheduleCron && interval) {
453
+ const slot = intervalToCron(interval);
454
+ scheduleCron = slot.cron;
455
+ if (slot.adjusted) {
456
+ warnings.push(
457
+ `interval ${formatInterval(interval)} is not a hosted schedule slot; the monitor runs every ` +
458
+ `${formatInterval(slot.effectiveIntervalMs)} (${slot.cron}). Hosted runs are at least 5 minutes apart ` +
459
+ 'and divide the hour or the day evenly.'
460
+ );
461
+ }
462
+ }
463
+ if (opts.goal ?? preset?.goal) {
464
+ warnings.push('goal is judged by the local goal judge only and is not applied to a hosted monitor, which notifies on every changed, new, blocked or errored page');
465
+ }
466
+ if (opts.notificationThreshold) {
467
+ warnings.push('notificationThreshold has no effect on a hosted monitor; hosted checks have no significance threshold');
468
+ }
469
+
470
+ const tracking = preset ? { ...preset.options, ...(trackingOptions || {}) } : (trackingOptions || {});
471
+ const selectors = tracking.customSelectors || [];
472
+ const email = notificationOptions?.email;
473
+ const webhook = notificationOptions?.webhook;
474
+ const secret = webhook?.signingSecret;
475
+ const record = await createHostedMonitor({
476
+ name: opts.name || new URL(url).host.slice(0, 80),
477
+ targets: selectors.length ? selectors.map((selector) => ({ url, selector })) : [{ url }],
478
+ ...(scheduleCron ? { schedule_cron: scheduleCron } : {}),
479
+ timezone: 'UTC',
480
+ ...(email?.enabled && email.recipients?.length ? { notify_emails: email.recipients } : {}),
481
+ ...(webhook?.enabled && webhook.url ? { webhook_url: webhook.url } : {}),
482
+ // A secret outside 16-128 chars is left out so the website generates one.
483
+ ...(webhook?.enabled && webhook.url && secret?.length >= 16 && secret.length <= 128 ? { webhook_secret: secret } : {}),
484
+ status: 'active'
485
+ }, creds);
486
+ // Nothing ran on this machine and the monitors API is free (G4).
487
+ setActualCost(0);
488
+ return {
489
+ success: true, operation: 'create_scheduled_monitor', url, hosted: true,
490
+ ...(preset ? { templateId: preset.id } : {}),
491
+ monitor: createdHostedMonitor(record, creds.endpoint),
492
+ firingGuarantee: HOSTED_FIRING_GUARANTEE_NOTE,
493
+ ...(warnings.length ? { warnings } : {}),
494
+ timestamp: Date.now()
495
+ };
496
+ }
497
+
426
498
  async stopScheduledMonitor(params) {
427
499
  const { url, scheduledMonitorOptions } = params;
428
500
  const monitorId = scheduledMonitorOptions?.monitorId;
429
501
  if (monitorId) {
430
- const result = await this.scheduler.stopMonitor(monitorId);
431
- if (!result.stopped) {
432
- return { success: false, operation: 'stop_scheduled_monitor', monitorId, stopped: false, error: `No scheduled monitor found with id ${monitorId}`, timestamp: Date.now() };
502
+ if (!this.monitorStore._loaded) await this.monitorStore.load();
503
+ if (this.monitorStore.get(monitorId)) {
504
+ await this.scheduler.stopMonitor(monitorId);
505
+ return { success: true, operation: 'stop_scheduled_monitor', monitorId, stopped: true, timestamp: Date.now() };
433
506
  }
434
- return { success: true, operation: 'stop_scheduled_monitor', monitorId, stopped: true, timestamp: Date.now() };
507
+ // Not in the local store: it may be hosted.
508
+ try {
509
+ await deleteHostedMonitor(monitorId, await this.options.resolveHostedCredentials());
510
+ } catch (error) {
511
+ const reason = error.status === 404 ? '' : ` (hosted lookup failed: ${error.message})`;
512
+ return { success: false, operation: 'stop_scheduled_monitor', monitorId, stopped: false, error: `No scheduled monitor found with id ${monitorId}${reason}`, timestamp: Date.now() };
513
+ }
514
+ // Nothing ran on this machine and the monitors API is free (G4).
515
+ setActualCost(0);
516
+ return { success: true, operation: 'stop_scheduled_monitor', monitorId, stopped: true, hosted: true, timestamp: Date.now() };
435
517
  }
436
518
  if (!url) throw new Error('stop_scheduled_monitor requires a url or scheduledMonitorOptions.monitorId');
437
519
  const result = await this.scheduler.stopByUrl(url);
438
- return { success: true, operation: 'stop_scheduled_monitor', url, stoppedMonitors: result.stopped, timestamp: Date.now() };
520
+ // Only a hosted monitor whose every target is this URL — never a
521
+ // multi-target monitor that merely includes it.
522
+ let stoppedHosted = 0;
523
+ let hostedError = null;
524
+ try {
525
+ const creds = await this.options.resolveHostedCredentials();
526
+ for (const m of await listHostedMonitors(creds)) {
527
+ if (m.targets?.length && m.targets.every((t) => t.url === url)) {
528
+ await deleteHostedMonitor(m.id, creds);
529
+ stoppedHosted++;
530
+ }
531
+ }
532
+ } catch (error) {
533
+ hostedError = error.message;
534
+ }
535
+ return {
536
+ success: true, operation: 'stop_scheduled_monitor', url, stoppedMonitors: result.stopped, stoppedHosted,
537
+ ...(hostedError ? { hostedError } : {}),
538
+ timestamp: Date.now()
539
+ };
439
540
  }
440
541
 
441
542
  async listScheduledMonitors() {
442
543
  if (!this.monitorStore._loaded) await this.monitorStore.load();
443
- const monitors = this.scheduler.list();
444
- return { success: true, operation: 'list_scheduled_monitors', monitors, count: monitors.length, timestamp: Date.now() };
544
+ const local = this.scheduler.list().map((m) => ({ ...m, hosted: false }));
545
+ // The local list never fails because the website is unreachable.
546
+ let hosted = [];
547
+ let hostedError = null;
548
+ try {
549
+ const creds = await this.options.resolveHostedCredentials();
550
+ hosted = (await listHostedMonitors(creds)).map((r) => listedHostedMonitor(r, creds.endpoint));
551
+ } catch (error) {
552
+ hostedError = error.message;
553
+ }
554
+ const monitors = [...local, ...hosted];
555
+ return {
556
+ success: true, operation: 'list_scheduled_monitors', monitors,
557
+ count: monitors.length, localCount: local.length, hostedCount: hosted.length,
558
+ ...(hostedError ? { hostedError } : {}),
559
+ timestamp: Date.now()
560
+ };
445
561
  }
446
562
 
447
563
  async getMonitoringDashboard(params) {
@@ -79,12 +79,13 @@ export async function sendWebhookNotification(url, changeResult, webhookConfig,
79
79
  }
80
80
 
81
81
  export async function sendEmailNotification(url, changeResult, emailConfig, emitter) {
82
- // Email integration placeholder requires external service
83
- emitter?.emit('notificationSent', {
82
+ // This process has no mail service. Until 6.2.0 this emitted
83
+ // notificationSent { success: true } for a message that was never sent.
84
+ // Email is sent by the website's cron for hosted monitors.
85
+ emitter?.emit('notificationError', {
84
86
  type: 'email',
85
87
  url,
86
- success: true,
87
- note: 'Email notifications require external service integration'
88
+ error: 'Local monitors do not send email; create the monitor with scheduledMonitorOptions.hosted: true (or in the website dashboard) for email notifications'
88
89
  });
89
90
  }
90
91