crawlforge-mcp-server 5.10.0 → 6.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.
Files changed (34) hide show
  1. package/README.md +7 -1
  2. package/package.json +7 -5
  3. package/server.js +210 -251
  4. package/src/cli/commands/login.js +176 -0
  5. package/src/cli/index.js +2 -0
  6. package/src/core/ActionExecutor.js +1 -1
  7. package/src/core/AuthManager.js +19 -6
  8. package/src/core/ChangeTracker.js +1 -1
  9. package/src/core/ElicitationHelper.js +192 -62
  10. package/src/core/SamplingClient.js +8 -2
  11. package/src/core/analysis/ContentAnalyzer.js +1 -1
  12. package/src/core/processing/BrowserProcessor.js +1 -1
  13. package/src/core/processing/ContentProcessor.js +1 -1
  14. package/src/core/processing/PDFProcessor.js +2 -2
  15. package/src/server/registerTool.js +1 -1
  16. package/src/server/requestContext.js +50 -0
  17. package/src/server/specHygiene.js +17 -22
  18. package/src/server/transports/stdio.js +2 -3
  19. package/src/server/transports/streamableHttp.js +142 -67
  20. package/src/server/withAuth.js +47 -9
  21. package/src/tools/advanced/batchScrape/index.js +29 -19
  22. package/src/tools/agent/agent.js +9 -4
  23. package/src/tools/crawl/crawlDeep.js +14 -8
  24. package/src/tools/extract/analyzeContent.js +1 -1
  25. package/src/tools/extract/extractContent.js +1 -1
  26. package/src/tools/extract/extractStructured.js +62 -43
  27. package/src/tools/extract/processDocument.js +1 -1
  28. package/src/tools/extract/summarizeContent.js +1 -1
  29. package/src/tools/llmstxt/generateLLMsTxt.js +2 -2
  30. package/src/tools/research/deepResearch.js +11 -5
  31. package/src/tools/tracking/trackChanges/schema.js +4 -4
  32. package/src/utils/HumanBehaviorSimulator.js +7 -7
  33. package/src/server/taskSupport.js +0 -233
  34. package/src/server/transports/http.js +0 -22
@@ -0,0 +1,176 @@
1
+ /**
2
+ * login command — browser handoff that stores an API key in ~/.crawlforge/config.json.
3
+ *
4
+ * The CLI mints PKCE parameters, prints an approval URL for the human, and polls
5
+ * the website until the signed-in user approves; the key is delivered once, over
6
+ * the poll, and never typed into a terminal. This command stores the credential
7
+ * ONLY — registering the MCP server with a client is `crawlforge init`.
8
+ */
9
+ import { randomBytes, createHash } from 'node:crypto';
10
+ import { hostname } from 'node:os';
11
+ import { existsSync } from 'node:fs';
12
+ import authManager from '../../core/authManager.js';
13
+ import { resolveApiEndpoint } from '../../core/endpointGuard.js';
14
+
15
+ const POLL_INTERVAL_MS = 3000;
16
+ const MAX_BACKOFF_MS = 30000;
17
+ const MAX_CONSECUTIVE_FAILURES = 10;
18
+
19
+ export function generateLoginParams() {
20
+ const codeVerifier = randomBytes(32).toString('base64url');
21
+ return {
22
+ sessionId: randomBytes(16).toString('hex'),
23
+ codeVerifier,
24
+ codeChallenge: createHash('sha256').update(codeVerifier).digest('base64url'),
25
+ };
26
+ }
27
+
28
+ export function buildApprovalUrl(endpoint, params, name) {
29
+ return `${endpoint}/cli-auth?session_id=${params.sessionId}` +
30
+ `&code_challenge=${params.codeChallenge}&name=${encodeURIComponent(name)}`;
31
+ }
32
+
33
+ function loginError(code, message) {
34
+ const err = new Error(message);
35
+ err.code = code;
36
+ return err;
37
+ }
38
+
39
+ /**
40
+ * Poll the status endpoint until the key arrives. Resolves with the `complete`
41
+ * payload; rejects with an error whose `code` names why (CLI_AUTH_TIMEOUT,
42
+ * CLI_AUTH_VERIFIER_MISMATCH, CLI_AUTH_UNREACHABLE). `sleep` and `now` are
43
+ * injectable so tests neither wait nor hit the network.
44
+ */
45
+ export async function pollStatus(fetchImpl, endpoint, params, {
46
+ intervalMs = POLL_INTERVAL_MS,
47
+ timeoutMs = 600000,
48
+ requestTimeoutMs = 30000,
49
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
50
+ now = Date.now,
51
+ } = {}) {
52
+ const deadline = now() + timeoutMs;
53
+ let interval = intervalMs;
54
+ let failures = 0;
55
+ let lastFailure = '';
56
+
57
+ while (now() < deadline) {
58
+ let response;
59
+ try {
60
+ response = await fetchImpl(`${endpoint}/api/auth/cli/status`, {
61
+ method: 'POST',
62
+ headers: { 'Content-Type': 'application/json' },
63
+ body: JSON.stringify({ session_id: params.sessionId, code_verifier: params.codeVerifier }),
64
+ signal: AbortSignal.timeout(requestTimeoutMs),
65
+ });
66
+ } catch (err) {
67
+ response = null;
68
+ lastFailure = err.message;
69
+ }
70
+
71
+ if (response && response.status === 200) {
72
+ const body = await response.json();
73
+ if (body.status === 'complete') return body;
74
+ if (body.status === 'pending') {
75
+ failures = 0;
76
+ } else {
77
+ failures++;
78
+ lastFailure = `unexpected status "${body.status}"`;
79
+ }
80
+ } else if (response && response.status === 403) {
81
+ let code = 'CLI_AUTH_FORBIDDEN';
82
+ try { code = (await response.json()).error?.code || code; } catch { /* keep default */ }
83
+ throw loginError(code, code === 'CLI_AUTH_VERIFIER_MISMATCH'
84
+ ? 'The website rejected this session\'s verifier. Run crawlforge login again and open the new URL.'
85
+ : `The website refused the login session (${code}).`);
86
+ } else if (response && response.status === 429) {
87
+ interval = Math.min(interval * 2, MAX_BACKOFF_MS);
88
+ } else if (response) {
89
+ failures++;
90
+ lastFailure = `HTTP ${response.status}`;
91
+ } else {
92
+ failures++;
93
+ }
94
+
95
+ if (failures >= MAX_CONSECUTIVE_FAILURES) {
96
+ throw loginError('CLI_AUTH_UNREACHABLE',
97
+ `Gave up after ${failures} consecutive failed status checks (last: ${lastFailure}).`);
98
+ }
99
+ await sleep(interval);
100
+ }
101
+
102
+ throw loginError('CLI_AUTH_TIMEOUT',
103
+ `No approval within ${Math.round(timeoutMs / 1000)} seconds. Run crawlforge login again.`);
104
+ }
105
+
106
+ export function register(program) {
107
+ program
108
+ .command('login')
109
+ .description('Sign in through your browser and store an API key in ~/.crawlforge/config.json (does not touch client configs)')
110
+ .option('--name <name>', 'Name for the API key the approval creates', `CLI on ${hostname()}`)
111
+ // Not `--timeout`: the program-level `--timeout <ms>` parses argv first and
112
+ // would swallow the value, so a subcommand option of that name never gets one.
113
+ .option('--wait <seconds>', 'How long to wait for approval', '600')
114
+ .action(async (opts, cmd) => {
115
+ const json = cmd.parent.opts().json;
116
+ const out = (msg) => process.stderr.write(msg + '\n');
117
+ const fail = (code, message) => {
118
+ if (json) {
119
+ process.stdout.write(JSON.stringify({ status: 'error', code, message }) + '\n', () => process.exit(1));
120
+ } else {
121
+ out('Error: ' + message);
122
+ process.exit(1);
123
+ }
124
+ };
125
+ process.on('SIGINT', () => fail('CLI_AUTH_CANCELLED', 'Login cancelled.'));
126
+
127
+ // resolveApiEndpoint() returns the origin with a trailing slash; strip it
128
+ // so the approval URL and the status endpoint are not `host//path`.
129
+ const endpoint = resolveApiEndpoint(process.env.CRAWLFORGE_API_URL || 'https://www.crawlforge.dev').replace(/\/+$/, '');
130
+ const params = generateLoginParams();
131
+ const hadConfig = existsSync(authManager.configPath);
132
+
133
+ out('Open this URL in your browser and approve the key (session ' + params.sessionId.slice(0, 8) + '):');
134
+ out('');
135
+ out(' ' + buildApprovalUrl(endpoint, params, opts.name));
136
+ out('');
137
+ out('Waiting for approval… (Ctrl-C to cancel)');
138
+
139
+ let result;
140
+ try {
141
+ result = await pollStatus(fetch, endpoint, params, {
142
+ timeoutMs: parseInt(opts.wait, 10) * 1000,
143
+ requestTimeoutMs: parseInt(process.env.CRAWLFORGE_CLI_TIMEOUT || '30000', 10),
144
+ });
145
+ } catch (err) {
146
+ return fail(err.code || 'CLI_AUTH_FAILED', err.message);
147
+ }
148
+
149
+ const validation = await authManager.validateApiKey(result.api_key);
150
+ if (!validation.valid) {
151
+ return fail('CLI_AUTH_KEY_INVALID', 'The delivered API key failed validation: ' + validation.error);
152
+ }
153
+ await authManager.saveConfig(result.api_key, validation.userId, validation.email);
154
+
155
+ if (json) {
156
+ const line = JSON.stringify({
157
+ status: 'complete',
158
+ email: validation.email,
159
+ key_name: result.key_name,
160
+ config_path: authManager.configPath,
161
+ credits_remaining: validation.creditsRemaining,
162
+ plan: validation.planId,
163
+ });
164
+ process.stdout.write(line + '\n', () => process.exit(0));
165
+ return;
166
+ }
167
+
168
+ out('Signed in as ' + validation.email);
169
+ out('Credits remaining: ' + validation.creditsRemaining);
170
+ out('Plan: ' + validation.planId);
171
+ out('API key "' + result.key_name + '" saved to ' + authManager.configPath +
172
+ (hadConfig ? ' (replaced the previous config)' : ''));
173
+ out('Next: crawlforge init --client claude-code|claude-desktop|cursor registers the MCP server with a client (this command did not modify any client config).');
174
+ process.exit(0);
175
+ });
176
+ }
package/src/cli/index.js CHANGED
@@ -59,6 +59,7 @@ import { register as registerMonitor } from './commands/monitor.js';
59
59
  import { register as registerInstallSkills } from './commands/install-skills.js';
60
60
  import { register as registerUninstallSkills } from './commands/uninstall-skills.js';
61
61
  import { register as registerInit } from './commands/init.js';
62
+ import { register as registerLogin } from './commands/login.js';
62
63
 
63
64
  // ─── MCP stdio server mode (backward compatibility) ──────────────────────────
64
65
  // Before v4.1.0 the `crawlforge` bin WAS the MCP server. v4.1.0 turned it into
@@ -138,6 +139,7 @@ registerMonitor(program);
138
139
  registerInstallSkills(program);
139
140
  registerUninstallSkills(program);
140
141
  registerInit(program);
142
+ registerLogin(program);
141
143
 
142
144
  // `crawlforge mcp` / `crawlforge serve` — explicitly start the MCP server over
143
145
  // stdio. Extra args (e.g. --http) are read directly by server.js from argv.
@@ -166,7 +166,7 @@ const ActionChainSchema = z.object({
166
166
  continueOnError: z.boolean().default(false),
167
167
  timeout: z.number().min(1000).max(300000).default(30000),
168
168
  retryChain: z.number().min(0).max(3).default(0),
169
- metadata: z.record(z.any()).default({})
169
+ metadata: z.record(z.any()).prefault({})
170
170
  });
171
171
 
172
172
  export class ActionExecutor extends EventEmitter {
@@ -241,9 +241,16 @@ class AuthManager {
241
241
  }
242
242
 
243
243
  /**
244
- * Check if user has enough credits for a tool
244
+ * Check if user has enough credits for a tool.
245
+ *
246
+ * @param {number} estimatedCredits
247
+ * @param {object} [ctx] the SDK per-request context, so the low-credit
248
+ * warning can ask as a multi-round-trip step (Phase 4.4).
249
+ * @returns {Promise<boolean|object>} `true`/`false` as before, or an
250
+ * `input_required` result the caller must RETURN verbatim — `withAuth`
251
+ * detects it, bills nothing and lets the SDK gather the answer.
245
252
  */
246
- async checkCredits(estimatedCredits = 1) {
253
+ async checkCredits(estimatedCredits = 1, ctx) {
247
254
  // Creator mode has unlimited credits
248
255
  if (this.isCreatorMode()) {
249
256
  return true;
@@ -277,10 +284,16 @@ class AuthManager {
277
284
  this.lastCreditCheck = now;
278
285
  this.lastSuccessfulCreditCheck.set(this.config.userId, now);
279
286
 
280
- // D1.4: If credits are close to running out, elicit confirmation instead of hard-failing
287
+ // D1.4: If credits are close to running out, elicit confirmation instead
288
+ // of hard-failing. Phase 4.4: the ask is a round trip now — `ask` hands
289
+ // an `input_required` result back to withAuth, which returns it unbilled
290
+ // and is re-entered here with the answer. A client that cannot be asked
291
+ // still proceeds, which is what the inline helper did.
281
292
  if (data.creditsRemaining < estimatedCredits) {
282
293
  if (this._elicitation) {
283
- const proceed = await this._elicitation.confirm(
294
+ const gate = this._elicitation.confirm(
295
+ ctx,
296
+ 'credits:low',
284
297
  `Low credits: ${data.creditsRemaining} remaining, this tool needs ~${estimatedCredits}. Proceed anyway?`,
285
298
  {
286
299
  credits_remaining: data.creditsRemaining,
@@ -288,8 +301,8 @@ class AuthManager {
288
301
  note: 'Top up at https://www.crawlforge.dev/dashboard',
289
302
  }
290
303
  );
291
- if (!proceed) return false;
292
- return true; // user confirmed — let tool attempt it
304
+ if (gate.status === 'ask') return gate.result;
305
+ return gate.status === 'proceed'; // confirmed (or unaskable) — let tool attempt it
293
306
  }
294
307
  return false; // no elicitation — standard hard-fail behavior
295
308
  }
@@ -37,7 +37,7 @@ const ChangeTrackingSchema = z.object({
37
37
  moderate: z.number().min(0).max(1).default(0.3),
38
38
  major: z.number().min(0).max(1).default(0.7)
39
39
  }).optional()
40
- }).optional().default({})
40
+ }).optional().prefault({})
41
41
  });
42
42
 
43
43
  const ChangeComparisonSchema = z.object({
@@ -1,13 +1,83 @@
1
1
  /**
2
2
  * ElicitationHelper — MCP Elicitation for CrawlForge
3
3
  *
4
- * Allows tools to request user confirmation or input mid-execution for
5
- * expensive or ambiguous operations. Falls back gracefully when the
6
- * MCP client does not support elicitation.
4
+ * Allows tools to request user confirmation before an expensive or ambiguous
5
+ * operation. Falls back gracefully when the MCP client cannot be asked.
7
6
  *
8
- * MCP Spec 2025-11-25: client/elicit request with requestedSchema
7
+ * Phase 4.4 moved confirmations from an inline server→client request to the
8
+ * 2026-07-28 MULTI-ROUND-TRIP form: `confirm()` no longer sends anything and no
9
+ * longer awaits. It returns a verdict, and when the user must be asked the
10
+ * verdict carries an `input_required` result for the tool to RETURN. The SDK
11
+ * then either hands it to a 2026-era client or, on a 2025-era connection, runs
12
+ * its own legacy shim (real `elicitation/create` + handler re-entry). One shape
13
+ * serves both eras, which is why nothing here branches on the protocol version
14
+ * any more — the previous era guard reported "unsupported" on 2026-07-28 and
15
+ * every prompt there was silently skipped.
16
+ *
17
+ * THE HANDLER IS RE-ENTERED. Everything a tool does above its gate runs a
18
+ * second time when the answer arrives, so a gate belongs above every fetch and
19
+ * every side effect. Billing is not a caller's problem: `withAuth` charges an
20
+ * `input_required` return zero and reports no usage, so a round trip and a
21
+ * declined confirmation are both free (G4).
22
+ *
23
+ * Two properties of the old helper are deliberately preserved:
24
+ *
25
+ * - **Fail-open.** A client that never declared elicitation is not asked, and
26
+ * the operation proceeds. This is not politeness — the SDK answers an
27
+ * `input_required` return on such a connection with `isError: true`
28
+ * ("did not declare the required capability"), so dropping the capability
29
+ * gate would turn a nicety into a failed call. Verified against the SDK.
30
+ * - **We ask at most once.** `inputResponses` is absent on a first entry and
31
+ * present on a retry, so a retry whose answer did not survive the trip
32
+ * (a dropped key, an answer of another kind) proceeds rather than asking
33
+ * again until the shim's round limit fails the call.
34
+ *
35
+ * The one case that cannot be preserved: a client that DECLARES elicitation and
36
+ * then throws answering it now yields an `isError` result from the SDK where the
37
+ * old inline path proceeded. The failure happens inside the SDK after the
38
+ * handler has returned, so nothing here can intercept it. It costs nothing — the
39
+ * handler did no work, so `withAuth` bills zero.
40
+ *
41
+ * Which server instance we ask matters as much as what we ask. server.js
42
+ * constructs this against the top-level template McpServer, but neither HTTP
43
+ * leg serves from it — the 2025-era path connects a clone per session and the
44
+ * modern leg builds one per request, so the template is never `.connect()`ed
45
+ * and reports no client capabilities. The transport stamps the serving clone on
46
+ * the request context; this resolves it from there and falls back to the
47
+ * injected instance, which on stdio IS the connected one. On a 2026-era request
48
+ * there is no connected instance to read at all — capabilities arrive per
49
+ * request in the `_meta` envelope, which is why `ctx` is consulted first.
9
50
  */
10
51
 
52
+ import { inputRequired, inputResponse, CLIENT_CAPABILITIES_META_KEY } from '@modelcontextprotocol/server';
53
+ import { servingRequestId, servingServer } from '../server/requestContext.js';
54
+
55
+ /** The one-boolean schema a confirmation asks with. */
56
+ const CONFIRM_SCHEMA = {
57
+ type: 'object',
58
+ properties: {
59
+ confirmed: {
60
+ type: 'boolean',
61
+ title: 'Proceed?',
62
+ description: 'Confirm to proceed with the operation',
63
+ },
64
+ },
65
+ required: ['confirmed'],
66
+ };
67
+
68
+ /**
69
+ * Whether the client's declared capabilities cover FORM elicitation, by the
70
+ * SDK's own rule: `elicitation.form` counts, and so does a bare `elicitation`
71
+ * declaration naming neither mode (the pre-mode 2025 meaning). A client that
72
+ * declared only `elicitation.url` has not declared form support.
73
+ */
74
+ function formElicitationDeclared(caps) {
75
+ const elicitation = caps?.elicitation;
76
+ if (!elicitation) return false;
77
+ if (elicitation.form !== undefined) return true;
78
+ return elicitation.url === undefined;
79
+ }
80
+
11
81
  export class ElicitationHelper {
12
82
  /**
13
83
  * @param {object} options
@@ -20,69 +90,101 @@ export class ElicitationHelper {
20
90
  }
21
91
 
22
92
  /**
23
- * Whether the connected MCP client supports elicitation.
24
- * @returns {boolean}
93
+ * The McpServer this request is served from: the clone the transport stamped
94
+ * on the request context, else the constructor-injected instance (stdio, and
95
+ * any caller outside a request context).
96
+ * @private
97
+ */
98
+ get _server() {
99
+ return servingServer() ?? this._mcpServer;
100
+ }
101
+
102
+ /**
103
+ * The client's declared capabilities for the request in flight. A 2026-era
104
+ * request carries them per-request in the `_meta` envelope and has no
105
+ * connected server instance to read; a 2025-era one has them on the serving
106
+ * instance and no envelope.
107
+ * @private
25
108
  */
26
- get supported() {
27
- const server = this._mcpServer?.server;
28
- // The MCP SDK exposes elicitation via Server.elicitInput(); it is only
29
- // usable when the connected CLIENT advertised the `elicitation` capability.
30
- if (typeof server?.elicitInput !== 'function') return false;
109
+ _clientCapabilities(ctx) {
110
+ const fromEnvelope = ctx?.mcpReq?.envelope?.[CLIENT_CAPABILITIES_META_KEY];
111
+ if (fromEnvelope) return fromEnvelope;
31
112
  try {
32
- const caps = server.getClientCapabilities?.();
33
- return !!caps?.elicitation;
113
+ return this._server?.server?.getClientCapabilities?.();
34
114
  } catch {
35
- return false;
115
+ return undefined;
36
116
  }
37
117
  }
38
118
 
119
+ /**
120
+ * Whether asking will actually reach the user rather than fail the call.
121
+ * @param {object} [ctx] the SDK per-request context the handler received
122
+ * @returns {boolean}
123
+ */
124
+ supported(ctx) {
125
+ return formElicitationDeclared(this._clientCapabilities(ctx));
126
+ }
127
+
39
128
  /**
40
129
  * Ask for user confirmation before proceeding with an expensive operation.
41
- * Returns true if confirmed (or if elicitation is unsupported — fail-open
42
- * so tools continue working in non-elicitation clients).
130
+ * SYNCHRONOUS — it performs no I/O. Do not `await` it.
43
131
  *
44
- * @param {string} message - Human-readable explanation of what requires confirmation
45
- * @param {object} [details] - Additional context (projected cost, URL count, etc.)
46
- * @returns {Promise<boolean>} - true = proceed, false = cancel
132
+ * @param {object|undefined} ctx - the SDK per-request context the handler received
133
+ * @param {string} key - stable identifier for this question, unique across tools
134
+ * @param {string} message - human-readable explanation of what requires confirmation
135
+ * @param {object} [details] - additional context (projected cost, URL count, etc.)
136
+ * @returns {{status:'proceed'}|{status:'cancelled'}|{status:'ask', result: object}}
137
+ * `ask` carries an `input_required` result the caller must RETURN verbatim.
47
138
  */
48
- async confirm(message, details = {}) {
49
- if (!this.supported) {
50
- this._logger.warn('Elicitation not supported by client — proceeding without confirmation', { message });
51
- return true;
139
+ confirm(ctx, key, message, details = {}) {
140
+ const responses = ctx?.mcpReq?.inputResponses;
141
+ const answered = inputResponse(responses, key);
142
+
143
+ if (answered.kind === 'elicit') {
144
+ // Only an explicit accept + confirmed=true proceeds; decline/cancel = stop.
145
+ return answered.action === 'accept' && answered.content?.confirmed === true
146
+ ? { status: 'proceed' }
147
+ : { status: 'cancelled' };
52
148
  }
53
149
 
54
- try {
55
- const detailLines = Object.entries(details)
56
- .map(([k, v]) => ` ${k}: ${v}`)
57
- .join('\n');
58
- const fullMessage = detailLines ? `${message}\n\n${detailLines}` : message;
59
-
60
- const result = await this._mcpServer.server.elicitInput({
61
- message: fullMessage,
62
- requestedSchema: {
63
- type: 'object',
64
- properties: {
65
- confirmed: {
66
- type: 'boolean',
67
- title: 'Proceed?',
68
- description: 'Confirm to proceed with the operation',
69
- },
70
- },
71
- required: ['confirmed'],
72
- },
73
- });
150
+ // A retry carries an `inputResponses` object even when this key's answer
151
+ // did not survive it. Asking again would burn the shim's rounds and end in
152
+ // a failed call, so one unanswered round trip proceeds instead.
153
+ if (responses !== undefined) {
154
+ this._logger.warn('Elicitation answer did not come back — proceeding without confirmation', { key });
155
+ return { status: 'proceed' };
156
+ }
74
157
 
75
- // Only an explicit accept + confirmed=true proceeds; decline/cancel = stop.
76
- return result?.action === 'accept' && result?.content?.confirmed === true;
77
- } catch (err) {
78
- this._logger.warn('Elicitation request failed — proceeding without confirmation', { error: err.message });
79
- return true; // fail-open
158
+ if (!this.supported(ctx)) {
159
+ this._logger.warn('Elicitation not supported by client — proceeding without confirmation', { message });
160
+ return { status: 'proceed' };
80
161
  }
162
+
163
+ const detailLines = Object.entries(details)
164
+ .map(([k, v]) => ` ${k}: ${v}`)
165
+ .join('\n');
166
+ const fullMessage = detailLines ? `${message}\n\n${detailLines}` : message;
167
+
168
+ return {
169
+ status: 'ask',
170
+ result: inputRequired({
171
+ inputRequests: {
172
+ [key]: inputRequired.elicit({ message: fullMessage, requestedSchema: CONFIRM_SCHEMA }),
173
+ },
174
+ }),
175
+ };
81
176
  }
82
177
 
83
178
  /**
84
179
  * Ask the user to provide a string value (e.g. missing schema field).
85
180
  *
181
+ * Still the 2025-era inline form, and still reached by no tool — this is the
182
+ * repo's one caller-less elicitation path, left as-is under G6 (dead code is
183
+ * reported, not deleted). It therefore keeps the era guard that `confirm()`
184
+ * shed: an inline request throws on a 2026-era connection, so the default is
185
+ * returned there rather than the call being failed. Converting it to a round
186
+ * trip is speculative until something calls it.
187
+ *
86
188
  * @param {string} message
87
189
  * @param {object} [options]
88
190
  * @param {string} [options.fieldName]
@@ -91,29 +193,47 @@ export class ElicitationHelper {
91
193
  * @returns {Promise<string|null>} - The user-provided value or null if cancelled/unsupported
92
194
  */
93
195
  async requestString(message, { fieldName = 'value', fieldDescription = '', defaultValue } = {}) {
94
- if (!this.supported) {
196
+ const server = this._server?.server;
197
+ const inlineUsable = typeof server?.request === 'function'
198
+ && !this._modernEra(server)
199
+ && formElicitationDeclared(this._clientCapabilities());
200
+
201
+ if (!inlineUsable) {
95
202
  this._logger.warn('Elicitation not supported — using default value', { fieldName, defaultValue });
96
203
  return defaultValue || null;
97
204
  }
98
205
 
99
206
  try {
100
- const result = await this._mcpServer.server.elicitInput({
101
- message,
102
- requestedSchema: {
103
- type: 'object',
104
- properties: {
105
- [fieldName]: {
106
- type: 'string',
107
- title: fieldName,
108
- description: fieldDescription,
109
- ...(defaultValue ? { default: defaultValue } : {}),
207
+ // relatedRequestId ties the prompt to the tools/call in flight. Without it
208
+ // the 2025-era HTTP transport puts the request on the standalone GET SSE
209
+ // stream and drops it outright when the client never opened one.
210
+ const relatedRequestId = servingRequestId();
211
+ const result = await server.request(
212
+ {
213
+ method: 'elicitation/create',
214
+ params: {
215
+ message,
216
+ requestedSchema: {
217
+ type: 'object',
218
+ properties: {
219
+ [fieldName]: {
220
+ type: 'string',
221
+ title: fieldName,
222
+ description: fieldDescription,
223
+ ...(defaultValue ? { default: defaultValue } : {}),
224
+ },
225
+ },
226
+ required: [fieldName],
110
227
  },
228
+ mode: 'form',
111
229
  },
112
- required: [fieldName],
113
230
  },
114
- });
231
+ relatedRequestId === null ? undefined : { relatedRequestId }
232
+ );
115
233
 
116
- if (result?.action === 'accept' && result?.content?.[fieldName] != null) {
234
+ // The answer is client-supplied and no longer schema-checked by the SDK
235
+ // on this path, so hold it to the type we asked for.
236
+ if (result?.action === 'accept' && typeof result?.content?.[fieldName] === 'string') {
117
237
  return result.content[fieldName];
118
238
  }
119
239
  return defaultValue || null;
@@ -122,4 +242,14 @@ export class ElicitationHelper {
122
242
  return defaultValue || null;
123
243
  }
124
244
  }
245
+
246
+ /** @private The 2026-07-28 era has no server→client request channel. */
247
+ _modernEra(server) {
248
+ try {
249
+ const negotiated = server?.getNegotiatedProtocolVersion?.();
250
+ return typeof negotiated === 'string' && negotiated >= '2026-07-28';
251
+ } catch {
252
+ return false;
253
+ }
254
+ }
125
255
  }
@@ -7,7 +7,9 @@
7
7
  * Fallback chain (applied in resolveCompletion):
8
8
  * 1. Ollama (local, no API key needed)
9
9
  * 2. Server-side API key (OPENAI_API_KEY / ANTHROPIC_API_KEY)
10
- * 3. MCP sampling request to client
10
+ * 3. MCP sampling request to client — DEPRECATED in MCP revision 2026-07-28
11
+ * (SEP-2577); removal on or after 2027-07-28. Emits a one-line stderr
12
+ * deprecation notice when it serves a completion.
11
13
  * 4. Error
12
14
  */
13
15
 
@@ -157,7 +159,11 @@ export class SamplingClient {
157
159
  includeContext: 'none',
158
160
  });
159
161
  const text = samplingResult?.content?.text || '';
160
- if (text) return { text, provider: 'sampling' };
162
+ if (text) {
163
+ // stderr, never stdout — stdout is the JSON-RPC stream on stdio.
164
+ console.error('[deprecation] MCP sampling served this completion. Sampling was deprecated in MCP revision 2026-07-28 (SEP-2577); CrawlForge removes this fallback on or after 2027-07-28. Run Ollama or set OPENAI_API_KEY / ANTHROPIC_API_KEY.');
165
+ return { text, provider: 'sampling' };
166
+ }
161
167
  } catch (_samplingErr) {
162
168
  // Sampling not supported or failed
163
169
  }
@@ -28,7 +28,7 @@ const ContentAnalyzerSchema = z.object({
28
28
  maxKeywords: z.number().min(1).max(50).default(15),
29
29
  includeReadabilityMetrics: z.boolean().default(true),
30
30
  includeSentiment: z.boolean().default(true)
31
- }).optional().default({})
31
+ }).optional().prefault({})
32
32
  });
33
33
 
34
34
  const AnalysisResult = z.object({
@@ -74,7 +74,7 @@ const BrowserProcessorSchema = z.object({
74
74
  enableTimezoneSpoof: z.boolean().default(true),
75
75
  enableGeoLocationSpoof: z.boolean().default(true)
76
76
  }).optional()
77
- }).optional().default({})
77
+ }).optional().prefault({})
78
78
  });
79
79
 
80
80
  const BrowserResult = z.object({
@@ -20,7 +20,7 @@ const ContentProcessorSchema = z.object({
20
20
  removeBoilerplate: z.boolean().default(true),
21
21
  preserveImageInfo: z.boolean().default(true),
22
22
  extractMetadata: z.boolean().default(true)
23
- }).optional().default({})
23
+ }).optional().prefault({})
24
24
  });
25
25
 
26
26
  const ReadabilityResult = z.object({
@@ -31,8 +31,8 @@ const PDFProcessorSchema = z.object({
31
31
  parseOptions: z.object({
32
32
  normalizeWhitespace: z.boolean().default(true),
33
33
  disableCombineTextItems: z.boolean().default(false)
34
- }).optional().default({})
35
- }).optional().default({})
34
+ }).optional().prefault({})
35
+ }).optional().prefault({})
36
36
  });
37
37
 
38
38
  const PDFResult = z.object({
@@ -10,7 +10,7 @@
10
10
  * validates `structuredContent` against the schema; legacy clients keep
11
11
  * reading the JSON-stringified `content` for backward compatibility.
12
12
  *
13
- * @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
13
+ * @param {import('@modelcontextprotocol/server').McpServer} server
14
14
  * @param {Function} withAuth — from makeWithAuth() in src/server/withAuth.js
15
15
  * @param {Object} descriptor
16
16
  * @param {string} descriptor.name — tool name (MCP identifier)