crawlforge-mcp-server 6.0.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.
package/README.md CHANGED
@@ -69,7 +69,13 @@ npm install -g crawlforge-mcp-server
69
69
 
70
70
  ### 2. Setup Your API Key (required)
71
71
 
72
- Every tool requires a CrawlForge API key — new accounts get 1,000 free trial credits to start:
72
+ Every tool requires a CrawlForge API key — new accounts get 1,000 free trial credits to start. The recommended path signs you in through the browser, so the key is never pasted into a terminal (a coding agent can run this for you and relay the URL):
73
+
74
+ ```bash
75
+ crawlforge login
76
+ ```
77
+
78
+ It prints an approval URL; open it, approve, and the key is stored in `~/.crawlforge/config.json`. Then run `crawlforge init` to register the MCP server with your client. Or use the interactive wizard, which also configures your clients:
73
79
 
74
80
  ```bash
75
81
  npx crawlforge-setup
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "6.0.0",
3
+ "version": "6.1.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
@@ -5,7 +5,7 @@
5
5
  export { isCreatorModeVerified } from './src/core/creatorMode.js';
6
6
 
7
7
  // Import everything else
8
- import { McpServer, ResourceTemplate } from "@modelcontextprotocol/server";
8
+ import { McpServer, ResourceTemplate, isInputRequiredResult } from "@modelcontextprotocol/server";
9
9
  import { z } from "zod";
10
10
  import { logger } from "./src/utils/Logger.js";
11
11
  import { SearchWebTool } from "./src/tools/search/searchWeb.js";
@@ -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.0.0",
110
+ version: "6.1.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",
@@ -607,12 +607,15 @@ registerToolIfEnabled("crawl_deep", {
607
607
  ...REDACT_PII_PARAM
608
608
  },
609
609
  outputSchema: OUTPUT_SCHEMAS.crawl_deep
610
- }, withAuth("crawl_deep", async ({ url, max_depth, max_pages, include_patterns, exclude_patterns, follow_external, respect_robots, extract_content, content_max_length, concurrency, enable_link_analysis, link_analysis_options, domain_filter, import_filter_config, session }) => {
610
+ }, withAuth("crawl_deep", async ({ url, max_depth, max_pages, include_patterns, exclude_patterns, follow_external, respect_robots, extract_content, content_max_length, concurrency, enable_link_analysis, link_analysis_options, domain_filter, import_filter_config, session }, ctx) => {
611
611
  try {
612
612
  if (!url) {
613
613
  return { content: [{ type: "text", text: "URL parameter is required" }], isError: true };
614
614
  }
615
- const result = await crawlDeepTool.execute({ url, max_depth, max_pages, include_patterns, exclude_patterns, follow_external, respect_robots, extract_content, content_max_length, concurrency, enable_link_analysis, link_analysis_options, domain_filter, import_filter_config, session });
615
+ const result = await crawlDeepTool.execute({ url, max_depth, max_pages, include_patterns, exclude_patterns, follow_external, respect_robots, extract_content, content_max_length, concurrency, enable_link_analysis, link_analysis_options, domain_filter, import_filter_config, session }, ctx);
616
+ // A confirmation round trip is the SDK's result shape, not a tool payload:
617
+ // it must reach the transport unwrapped (Phase 4.4).
618
+ if (isInputRequiredResult(result)) return result;
616
619
  return dualOutput(result);
617
620
  } catch (error) {
618
621
  return { content: [{ type: "text", text: `Crawl failed: ${error.message}` }], isError: true };
@@ -763,12 +766,13 @@ registerToolIfEnabled("extract_structured", {
763
766
  ...VERIFY_NUMBERS_PARAM
764
767
  },
765
768
  outputSchema: OUTPUT_SCHEMAS.extract_structured
766
- }, withAuth("extract_structured", async (params) => {
769
+ }, withAuth("extract_structured", async (params, ctx) => {
767
770
  try {
768
771
  // Forward params whole. This wrapper used to destructure a fixed six, which
769
772
  // silently dropped respect_robots and user_agent — both declared here and
770
773
  // read by the tool, so the G5 override was accepted and ignored.
771
- const result = await extractStructuredTool.execute(params);
774
+ const result = await extractStructuredTool.execute(params, ctx);
775
+ if (isInputRequiredResult(result)) return result;
772
776
  return dualOutput(result);
773
777
  } catch (error) {
774
778
  return { content: [{ type: "text", text: `Structured extraction failed: ${error.message}` }], isError: true };
@@ -855,9 +859,10 @@ registerToolIfEnabled("batch_scrape", {
855
859
  ...MAX_INLINE_CHARS_PARAM,
856
860
  ...REDACT_PII_PARAM
857
861
  }
858
- }, withAuth("batch_scrape", async (params) => {
862
+ }, withAuth("batch_scrape", async (params, ctx) => {
859
863
  try {
860
- const result = await batchScrapeTool.execute(params);
864
+ const result = await batchScrapeTool.execute(params, ctx);
865
+ if (isInputRequiredResult(result)) return result;
861
866
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
862
867
  } catch (error) {
863
868
  return { content: [{ type: "text", text: `Batch scrape failed: ${error.message}` }], isError: true };
@@ -1056,9 +1061,10 @@ registerToolIfEnabled("deep_research", {
1056
1061
  }).optional().describe("Webhook for progress and completion notifications"),
1057
1062
  ...MAX_INLINE_CHARS_PARAM
1058
1063
  }
1059
- }, withAuth("deep_research", async (params) => {
1064
+ }, withAuth("deep_research", async (params, ctx) => {
1060
1065
  try {
1061
- const result = await deepResearchTool.execute(params);
1066
+ const result = await deepResearchTool.execute(params, ctx);
1067
+ if (isInputRequiredResult(result)) return result;
1062
1068
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1063
1069
  } catch (error) {
1064
1070
  return { content: [{ type: "text", text: `Deep research failed: ${error.message}` }], isError: true };
@@ -1120,9 +1126,10 @@ registerToolIfEnabled("agent", {
1120
1126
  maxSteps: z.number().min(1).max(10).optional().default(5).describe("Max fetch iterations (hard cap: 10)"),
1121
1127
  maxUrls: z.number().min(1).max(20).optional().default(10).describe("Max URLs to fetch (hard cap: 20)")
1122
1128
  }
1123
- }, withAuth("agent", async (params) => {
1129
+ }, withAuth("agent", async (params, ctx) => {
1124
1130
  try {
1125
- const result = await agentTool.execute(params);
1131
+ const result = await agentTool.execute(params, ctx);
1132
+ if (isInputRequiredResult(result)) return result;
1126
1133
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1127
1134
  } catch (error) {
1128
1135
  return { content: [{ type: "text", text: `Agent failed: ${error.message}` }], isError: true };
@@ -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.
@@ -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
  }
@@ -1,36 +1,56 @@
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
- * The request goes out as a 2025-era server→client `elicitation/create`,
9
- * awaited inline in the middle of a tool's work. Two things the SDK does NOT
10
- * do for us, and which `supported` therefore decides before we send:
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.
11
16
  *
12
- * - `Server.elicitInput()` refuses a client that declared a bare
13
- * `elicitation: {}` (it demands `elicitation.form`), even though the SDK's
14
- * own capability rule counts a bare declaration as form-capable that is
15
- * the pre-mode 2025 meaning, and it is what the SDK's legacy shim and its
16
- * 2026 seam both apply. We send through `Server.request()`, which applies
17
- * that same lenient rule, so those clients get their prompt.
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).
18
22
  *
19
- * - Both `elicitInput()` and `request()` throw on a 2026-07-28-era
20
- * connection: that revision has no server→client request channel at all.
21
- * There is no inline substitute the replacement is an `input_required`
22
- * result RETURNED by a tools/call handler, which this helper cannot do
23
- * from the middle of a tool's execution. So `supported` reports false and
24
- * the operation proceeds unasked, exactly as it does for a client with no
25
- * elicitation capability. A confirmation prompt is a nicety; failing the
26
- * call is not an acceptable substitute. See docs/mcp-spec-adoption.md.
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.
27
50
  */
28
51
 
29
- /**
30
- * First revision of the modern protocol era. Revisions are ISO dates, so
31
- * lexicographic comparison orders them chronologically (the SDK's own rule).
32
- */
33
- const FIRST_MODERN_PROTOCOL_VERSION = '2026-07-28';
52
+ import { inputRequired, inputResponse, CLIENT_CAPABILITIES_META_KEY } from '@modelcontextprotocol/server';
53
+ import { servingRequestId, servingServer } from '../server/requestContext.js';
34
54
 
35
55
  /** The one-boolean schema a confirmation asks with. */
36
56
  const CONFIRM_SCHEMA = {
@@ -70,69 +90,101 @@ export class ElicitationHelper {
70
90
  }
71
91
 
72
92
  /**
73
- * Whether an inline elicitation round trip will actually reach the user.
74
- * @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
75
108
  */
76
- get supported() {
77
- const server = this._mcpServer?.server;
78
- if (typeof server?.request !== 'function') return false;
109
+ _clientCapabilities(ctx) {
110
+ const fromEnvelope = ctx?.mcpReq?.envelope?.[CLIENT_CAPABILITIES_META_KEY];
111
+ if (fromEnvelope) return fromEnvelope;
79
112
  try {
80
- // No server→client request channel exists on the 2026-07-28 era.
81
- const negotiated = server.getNegotiatedProtocolVersion?.();
82
- if (typeof negotiated === 'string' && negotiated >= FIRST_MODERN_PROTOCOL_VERSION) return false;
83
- return formElicitationDeclared(server.getClientCapabilities?.());
113
+ return this._server?.server?.getClientCapabilities?.();
84
114
  } catch {
85
- return false;
115
+ return undefined;
86
116
  }
87
117
  }
88
118
 
89
119
  /**
90
- * Send one form-mode `elicitation/create` and return the ElicitResult.
91
- * This is byte-for-byte the message `Server.elicitInput()` sends; it just
92
- * does not impose that method's stricter `elicitation.form` gate.
93
- * @private
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}
94
123
  */
95
- async _elicit(message, requestedSchema) {
96
- return this._mcpServer.server.request({
97
- method: 'elicitation/create',
98
- params: { message, requestedSchema, mode: 'form' },
99
- });
124
+ supported(ctx) {
125
+ return formElicitationDeclared(this._clientCapabilities(ctx));
100
126
  }
101
127
 
102
128
  /**
103
129
  * Ask for user confirmation before proceeding with an expensive operation.
104
- * Returns true if confirmed (or if elicitation is unsupported — fail-open
105
- * so tools continue working in non-elicitation clients).
130
+ * SYNCHRONOUS it performs no I/O. Do not `await` it.
106
131
  *
107
- * @param {string} message - Human-readable explanation of what requires confirmation
108
- * @param {object} [details] - Additional context (projected cost, URL count, etc.)
109
- * @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.
110
138
  */
111
- async confirm(message, details = {}) {
112
- if (!this.supported) {
113
- this._logger.warn('Elicitation not supported by client — proceeding without confirmation', { message });
114
- return true;
115
- }
139
+ confirm(ctx, key, message, details = {}) {
140
+ const responses = ctx?.mcpReq?.inputResponses;
141
+ const answered = inputResponse(responses, key);
116
142
 
117
- try {
118
- const detailLines = Object.entries(details)
119
- .map(([k, v]) => ` ${k}: ${v}`)
120
- .join('\n');
121
- const fullMessage = detailLines ? `${message}\n\n${detailLines}` : message;
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' };
148
+ }
122
149
 
123
- const result = await this._elicit(fullMessage, CONFIRM_SCHEMA);
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
+ }
124
157
 
125
- // Only an explicit accept + confirmed=true proceeds; decline/cancel = stop.
126
- return result?.action === 'accept' && result?.content?.confirmed === true;
127
- } catch (err) {
128
- this._logger.warn('Elicitation request failed — proceeding without confirmation', { error: err.message });
129
- 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' };
130
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
+ };
131
176
  }
132
177
 
133
178
  /**
134
179
  * Ask the user to provide a string value (e.g. missing schema field).
135
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
+ *
136
188
  * @param {string} message
137
189
  * @param {object} [options]
138
190
  * @param {string} [options.fieldName]
@@ -141,24 +193,43 @@ export class ElicitationHelper {
141
193
  * @returns {Promise<string|null>} - The user-provided value or null if cancelled/unsupported
142
194
  */
143
195
  async requestString(message, { fieldName = 'value', fieldDescription = '', defaultValue } = {}) {
144
- 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) {
145
202
  this._logger.warn('Elicitation not supported — using default value', { fieldName, defaultValue });
146
203
  return defaultValue || null;
147
204
  }
148
205
 
149
206
  try {
150
- const result = await this._elicit(message, {
151
- type: 'object',
152
- properties: {
153
- [fieldName]: {
154
- type: 'string',
155
- title: fieldName,
156
- description: fieldDescription,
157
- ...(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],
227
+ },
228
+ mode: 'form',
158
229
  },
159
230
  },
160
- required: [fieldName],
161
- });
231
+ relatedRequestId === null ? undefined : { relatedRequestId }
232
+ );
162
233
 
163
234
  // The answer is client-supplied and no longer schema-checked by the SDK
164
235
  // on this path, so hold it to the type we asked for.
@@ -171,4 +242,14 @@ export class ElicitationHelper {
171
242
  return defaultValue || null;
172
243
  }
173
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
+ }
174
255
  }
@@ -69,3 +69,53 @@ export function setActualCost(n) {
69
69
  export function reportedActualCost() {
70
70
  return requestContext.getStore()?.actualCost ?? null;
71
71
  }
72
+
73
+ /**
74
+ * Record the McpServer instance that is actually serving this request, and the
75
+ * wire era it speaks.
76
+ *
77
+ * Neither HTTP leg serves from the top-level McpServer that server.js
78
+ * registers everything on: the 2025-era path connects one clone per session,
79
+ * and the modern leg builds a fresh clone per request (see
80
+ * transports/streamableHttp.js). Only a clone is ever `.connect()`ed, so only a
81
+ * clone has a negotiated protocol version, the client's declared capabilities,
82
+ * and a channel to send a server-to-client request on. The template has none of
83
+ * those, which is why anything reading them off it (ElicitationHelper) got
84
+ * `undefined` on every HTTP request.
85
+ *
86
+ * Stdio stamps nothing: there the top-level instance IS the connected one, and
87
+ * the accessors below return null so callers fall back to it.
88
+ *
89
+ * @param {object|null} server the serving McpServer
90
+ * @param {'legacy'|'modern'|null} [era] the wire era it serves
91
+ */
92
+ export function setServingServer(server, era = null) {
93
+ const store = requestContext.getStore();
94
+ if (!store) return;
95
+ store.servingServer = server ?? null;
96
+ store.servingEra = era;
97
+ }
98
+
99
+ /** The McpServer serving this request, or null on stdio / outside a context. */
100
+ export function servingServer() {
101
+ return requestContext.getStore()?.servingServer ?? null;
102
+ }
103
+
104
+ /** The wire era serving this request: 'legacy' | 'modern' | null (stdio). */
105
+ export function servingEra() {
106
+ return requestContext.getStore()?.servingEra ?? null;
107
+ }
108
+
109
+ /**
110
+ * The JSON-RPC id of the request being served, or null when unknown.
111
+ *
112
+ * A server-to-client request sent from inside a tool has to say which inbound
113
+ * request it belongs to: the 2025-era streamable HTTP transport routes an
114
+ * unrelated request to the standalone GET SSE stream and silently DROPS it when
115
+ * the client never opened one, which turns an elicitation prompt into a
116
+ * 60-second stall before it fails open. withAuth stamps this from the SDK's
117
+ * per-request `ctx`; stdio has no streams to pick between and ignores it.
118
+ */
119
+ export function servingRequestId() {
120
+ return requestContext.getStore()?.servingRequestId ?? null;
121
+ }
@@ -31,7 +31,7 @@ import { NodeStreamableHTTPServerTransport, toNodeHandler, toWebRequest } from "
31
31
  import { createServer } from 'node:http';
32
32
  import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
33
33
  import { readFileSync } from 'node:fs';
34
- import { requestContext } from '../requestContext.js';
34
+ import { requestContext, setServingServer } from '../requestContext.js';
35
35
  import { applySpecHygiene } from '../specHygiene.js';
36
36
 
37
37
  const pkg = JSON.parse(readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'));
@@ -214,7 +214,14 @@ export async function connectStreamableHttp(server, authManager, logger, options
214
214
  // this handler, so the modern leg never has to serve one. The SDK owns the
215
215
  // Content-Type gate (415), the Mcp-Method/Mcp-Name cross-checks (-32020 on
216
216
  // 400) and `server/discover`; nothing here re-implements them.
217
- const modernHandler = createMcpHandler(() => cloneServerForSession(server), {
217
+ // The factory runs once per request, inside the requestContext.run() below,
218
+ // so the clone it builds can be stamped on the store: that clone — never the
219
+ // template — is the instance this request is actually served from.
220
+ const modernHandler = createMcpHandler((ctx) => {
221
+ const requestServer = cloneServerForSession(server);
222
+ setServingServer(requestServer, ctx?.era ?? 'modern');
223
+ return requestServer;
224
+ }, {
218
225
  legacy: 'reject',
219
226
  onerror: (err) => logger.warn('2026-era MCP request rejected', { error: err?.message })
220
227
  });
@@ -362,7 +369,10 @@ export async function connectStreamableHttp(server, authManager, logger, options
362
369
  const existing = sessionIdHeader ? sessions.get(String(sessionIdHeader)) : undefined;
363
370
 
364
371
  if (existing) {
365
- await requestContext.run({ internal }, () => existing.transport.handleRequest(req, res, parsedBody));
372
+ await requestContext.run(
373
+ { internal, servingServer: existing.server, servingEra: 'legacy' },
374
+ () => existing.transport.handleRequest(req, res, parsedBody)
375
+ );
366
376
  return;
367
377
  }
368
378
 
@@ -395,7 +405,10 @@ export async function connectStreamableHttp(server, authManager, logger, options
395
405
 
396
406
  try {
397
407
  await sessionServer.connect(transport);
398
- await requestContext.run({ internal }, () => transport.handleRequest(req, res, parsedBody));
408
+ await requestContext.run(
409
+ { internal, servingServer: sessionServer, servingEra: 'legacy' },
410
+ () => transport.handleRequest(req, res, parsedBody)
411
+ );
399
412
  } catch (err) {
400
413
  logger.error('Streamable HTTP session initialization failed', { error: err?.message });
401
414
  safeClose(transport);
@@ -8,7 +8,11 @@
8
8
  * so a valid API key is required for every invocation
9
9
  * - try/finally guarantees a single `tool invocation` log line per call
10
10
  * - log payload: { toolName, paramHash, durationMs, outcome, creditCost, creatorMode }
11
- * - outcome ∈ { 'success' | 'error' | 'insufficient_credits' }
11
+ * - outcome ∈ { 'success' | 'error' | 'insufficient_credits' | 'input_required' }
12
+ * - an `input_required` return (Phase 4.4) is a round trip, not an answer: the
13
+ * handler did no work, so it is billed NOTHING and reports no usage. The
14
+ * SDK re-enters the handler with the reply and the terminal entry bills
15
+ * once, so a confirmation costs exactly what the call always cost (G4).
12
16
  * - error results get a "Next step:" hint naming the tool to try next
13
17
  * (src/server/fallbackHints.js) so a failure is not followed by a blind retry
14
18
  * - emits an OTel span via src/observability/tracing.js (no-op if disabled)
@@ -16,6 +20,7 @@
16
20
  */
17
21
 
18
22
  import { createHash } from 'node:crypto';
23
+ import { isInputRequiredResult } from '@modelcontextprotocol/server';
19
24
  import { recordToolInvocation } from '../observability/tracing.js';
20
25
  import { isInternalRequest, preflightRefusal, reportedActualCost, requestContext } from './requestContext.js';
21
26
  import { appendFallbackHint } from './fallbackHints.js';
@@ -71,7 +76,7 @@ export function hashParams(params) {
71
76
  */
72
77
  export function makeWithAuth({ authManager, logger, metrics = null, mcpServer = null }) {
73
78
  return function withAuth(toolName, handler) {
74
- const invoke = async (params) => {
79
+ const invoke = async (params, ctx) => {
75
80
  const startTime = Date.now();
76
81
  const paramHash = hashParams(params);
77
82
  const creatorMode = authManager.isCreatorMode();
@@ -109,7 +114,14 @@ export function makeWithAuth({ authManager, logger, metrics = null, mcpServer =
109
114
  // end user's credits before forwarding — checking the static key's
110
115
  // balance here would gate users on an unrelated account).
111
116
  if (!billingExempt) {
112
- const hasCredits = await authManager.checkCredits(creditCost);
117
+ const hasCredits = await authManager.checkCredits(creditCost, ctx);
118
+ // The low-credit warning asks as a round trip (Phase 4.4). Nothing has
119
+ // run, so this costs nothing and reports no usage; the SDK re-enters
120
+ // with the answer.
121
+ if (isInputRequiredResult(hasCredits)) {
122
+ outcome = 'input_required';
123
+ return hasCredits;
124
+ }
113
125
  if (!hasCredits) {
114
126
  outcome = 'insufficient_credits';
115
127
  return {
@@ -127,7 +139,21 @@ export function makeWithAuth({ authManager, logger, metrics = null, mcpServer =
127
139
  }
128
140
 
129
141
  handlerStarted = true;
130
- const result = await handler(params);
142
+ const result = await handler(params, ctx);
143
+
144
+ // Phase 4.4: a multi-round-trip handler answers `input_required` when it
145
+ // needs the user before it can start. Nothing was fetched, so nothing is
146
+ // owed: no charge, no usage report, and none of the result stages below
147
+ // (there is no result yet to redact, shape or price). The SDK gathers the
148
+ // answer and re-enters this same wrapper; whichever entry finally returns
149
+ // a real result is the one that bills, exactly once. Without this branch
150
+ // an `input_required` is not `isError`, so it books as a success and
151
+ // bills in full on every round — up to eight — for a call that did no
152
+ // work, and a declined confirmation bills too (G4).
153
+ if (isInputRequiredResult(result)) {
154
+ outcome = 'input_required';
155
+ return result;
156
+ }
131
157
 
132
158
  // Tools catch their own failures and return { isError:true } rather than
133
159
  // throwing (the shared pattern in server.js). That is still an ERROR
@@ -275,11 +301,23 @@ export function makeWithAuth({ authManager, logger, metrics = null, mcpServer =
275
301
 
276
302
  // Every invocation runs in its own context so the compliance gate can stamp
277
303
  // a refusal where the billing decision can see it. Any outer store (the
278
- // HTTP transport's `internal` flag) is spread in, not replaced — and stdio
279
- // callers, who have no transport-provided store, get one here.
280
- return async (params) => requestContext.run(
281
- { ...(requestContext.getStore() ?? {}), preflightRefusal: null, actualCost: null },
282
- () => invoke(params)
304
+ // HTTP transport's `internal` flag, the serving McpServer) is spread in,
305
+ // not replaced — and stdio callers, who have no transport-provided store,
306
+ // get one here.
307
+ //
308
+ // `ctx` is the SDK's per-request context (v2 calls a tool callback with
309
+ // `(args, ctx)`). It is passed straight through to the handler; existing
310
+ // 1-arity handlers ignore it. Its request id is stamped on the context so a
311
+ // server-to-client request sent from inside the tool can ride the same
312
+ // stream as this call — see servingRequestId() in requestContext.js.
313
+ return async (params, ctx) => requestContext.run(
314
+ {
315
+ ...(requestContext.getStore() ?? {}),
316
+ preflightRefusal: null,
317
+ actualCost: null,
318
+ servingRequestId: ctx?.mcpReq?.id
319
+ },
320
+ () => invoke(params, ctx)
283
321
  );
284
322
  };
285
323
  }
@@ -83,41 +83,51 @@ export class BatchScrapeTool extends EventEmitter {
83
83
  this._elicitation = new ElicitationHelper({ mcpServer });
84
84
  }
85
85
 
86
- async execute(params) {
86
+ async execute(params, ctx) {
87
87
  try {
88
88
  const validated = BatchScrapeSchema.parse(params);
89
- this.stats.totalBatches++;
90
89
  const batchId = this._generateBatchId();
91
- const startTime = Date.now();
92
-
93
- this._log('info', `Starting batch scrape ${batchId} with ${validated.urls.length} URLs in ${validated.mode} mode`);
94
-
95
- const urlConfigs = this._normalizeUrlConfigs(validated.urls, validated);
96
90
 
97
- let webhookConfig = null;
98
- if (validated.webhook && this.enableWebhookNotifications) {
99
- webhookConfig = this._registerWebhook(validated.webhook, batchId);
100
- }
101
-
102
- // D1.4: Elicitation warn when batch is large in sync mode
103
- if (validated.mode === 'sync' && urlConfigs.length > 25) {
104
- const proceed = await this._elicitation.confirm(
105
- `batch_scrape (sync mode) will fetch ${urlConfigs.length} URLs synchronously. This may take a while and consume significant credits.`,
91
+ // D1.4: Elicitation — warn when batch is large in sync mode. An
92
+ // unanswered gate returns an input-required result the SDK answers by
93
+ // re-entering this handler from the top, so it runs before anything that
94
+ // leaves a trace: below it are the batch counter and the webhook
95
+ // registration, which a second entry would repeat (a second webhook
96
+ // registration for one batch). The count comes from validated.urls,
97
+ // which _normalizeUrlConfigs maps one-for-one.
98
+ if (validated.mode === 'sync' && validated.urls.length > 25) {
99
+ const gate = this._elicitation.confirm(
100
+ ctx,
101
+ 'batch_scrape:large_sync',
102
+ `batch_scrape (sync mode) will fetch ${validated.urls.length} URLs synchronously. This may take a while and consume significant credits.`,
106
103
  {
107
- url_count: urlConfigs.length,
104
+ url_count: validated.urls.length,
108
105
  mode: 'sync',
109
106
  suggestion: 'Consider using mode:"async" for large batches.',
110
107
  }
111
108
  );
112
- if (!proceed) {
109
+ if (gate.status === 'ask') return gate.result;
110
+ if (gate.status === 'cancelled') {
113
111
  return {
114
112
  batchId, mode: 'sync', success: false,
115
113
  error: 'Batch scrape cancelled by user (elicitation declined).',
116
- totalUrls: urlConfigs.length,
114
+ totalUrls: validated.urls.length,
117
115
  };
118
116
  }
119
117
  }
120
118
 
119
+ this.stats.totalBatches++;
120
+ const startTime = Date.now();
121
+
122
+ this._log('info', `Starting batch scrape ${batchId} with ${validated.urls.length} URLs in ${validated.mode} mode`);
123
+
124
+ const urlConfigs = this._normalizeUrlConfigs(validated.urls, validated);
125
+
126
+ let webhookConfig = null;
127
+ if (validated.webhook && this.enableWebhookNotifications) {
128
+ webhookConfig = this._registerWebhook(validated.webhook, batchId);
129
+ }
130
+
121
131
  if (validated.mode === 'sync') {
122
132
  return await this._processBatchSync(batchId, urlConfigs, validated, webhookConfig, startTime);
123
133
  } else {
@@ -35,16 +35,21 @@ export class AgentTool {
35
35
  this._elicitation = new ElicitationHelper({ mcpServer });
36
36
  }
37
37
 
38
- async execute(params) {
38
+ async execute(params, ctx) {
39
39
  const validated = AgentInputSchema.parse(params);
40
40
 
41
- // Request confirmation before a pro run (expensive)
41
+ // Request confirmation before a pro run (expensive). An unanswered gate
42
+ // returns an input-required result the SDK answers by re-entering this
43
+ // handler from the top, so nothing above it may fetch or leave a trace.
42
44
  if (validated.model === 'pro') {
43
- const proceed = await this._elicitation.confirm(
45
+ const gate = this._elicitation.confirm(
46
+ ctx,
47
+ 'agent:pro_model',
44
48
  'agent tool: pro model uses ResearchOrchestrator and may incur significant costs.',
45
49
  { model: 'pro', maxUrls: validated.maxUrls, note: 'External LLM API costs billed separately if keys are set.' }
46
50
  );
47
- if (!proceed) {
51
+ if (gate.status === 'ask') return gate.result;
52
+ if (gate.status === 'cancelled') {
48
53
  return {
49
54
  success: false,
50
55
  cancelled: true,
@@ -117,7 +117,7 @@ export class CrawlDeepTool {
117
117
  this._elicitation = new ElicitationHelper({ mcpServer });
118
118
  }
119
119
 
120
- async execute(params) {
120
+ async execute(params, ctx) {
121
121
  try {
122
122
  const validated = CrawlDeepSchema.parse(params);
123
123
 
@@ -147,9 +147,14 @@ export class CrawlDeepTool {
147
147
  if (cached) return { ...cached, cached: true };
148
148
  }
149
149
 
150
- // D1.4: Elicitation — warn when max_pages is very high
150
+ // D1.4: Elicitation — warn when max_pages is very high. An unanswered
151
+ // gate returns an input-required result the SDK answers by re-entering
152
+ // this handler from the top; everything above is the clamp arithmetic and
153
+ // a cache read, so a second entry fetches nothing and leaves no trace.
151
154
  if (effectiveMaxPages > 500) {
152
- const proceed = await this._elicitation.confirm(
155
+ const gate = this._elicitation.confirm(
156
+ ctx,
157
+ 'crawl_deep:max_pages',
153
158
  `crawl_deep will crawl up to ${effectiveMaxPages} pages from ${validated.url}. Large crawls consume many credits.`,
154
159
  {
155
160
  url: validated.url,
@@ -157,7 +162,8 @@ export class CrawlDeepTool {
157
162
  max_depth: effectiveMaxDepth,
158
163
  }
159
164
  );
160
- if (!proceed) {
165
+ if (gate.status === 'ask') return gate.result;
166
+ if (gate.status === 'cancelled') {
161
167
  return {
162
168
  success: false,
163
169
  error: 'Crawl cancelled by user (elicitation declined).',
@@ -174,36 +174,78 @@ export class ExtractStructuredTool {
174
174
  * @param {Object} params - Extraction parameters
175
175
  * @returns {Promise<Object>} Extraction result
176
176
  */
177
- async execute(params) {
177
+ async execute(params, ctx) {
178
178
  const startTime = Date.now();
179
179
 
180
180
  try {
181
181
  const validated = ExtractStructuredSchema.parse(params);
182
182
  const { url, schema, prompt, llmConfig, fallbackToSelectors, selectorHints, respect_robots, user_agent, verify_numbers } = validated;
183
183
 
184
- // Step 1: Fetch and parse — shared helper strips scripts/styles/iframes/svgs
185
- const { html, $, textContent, warnings } = await fetchAndParse(url, {
186
- userAgent: user_agent || this.userAgent,
187
- respectRobots: respect_robots,
188
- tool: 'extract_structured'
189
- });
190
-
191
- // What the model reads — see shownText().
192
- const shown = shownText($, html, url, textContent);
193
-
194
- // Step 3: Try LLM extraction first
195
184
  let extractionResult = null;
196
185
  let extractionMethod = 'llm';
197
186
  let llmErrorMessage = null;
198
187
  let llmAvailable = false;
188
+ let llm = null;
199
189
 
190
+ // Step 0: LLM readiness, resolved before the fetch so the D1.4 gate below
191
+ // can ask before any network work — an unanswered gate returns an
192
+ // input-required result the SDK answers by re-entering this handler from
193
+ // the top, and everything above the gate runs a second time.
200
194
  try {
201
- const llm = this._ensureLLMManager(llmConfig || {});
195
+ llm = this._ensureLLMManager(llmConfig || {});
202
196
  // ready() probes Ollama, which has no API key to gate on. isAvailable()
203
197
  // alone reported false on any machine without a cloud key, so a running
204
198
  // local Ollama was never used.
205
199
  llmAvailable = await llm.ready();
206
- if (llmAvailable) {
200
+ } catch (llmError) {
201
+ // No usable LLM — this falls through to the CSS fallback. Keep the
202
+ // message so callers can tell "LLM broken" apart from "no LLM
203
+ // configured".
204
+ llmErrorMessage = llmError.message;
205
+ }
206
+
207
+ // D1.4: no LLM configured and the schema demands more than 3 required
208
+ // fields — confirm before running the lower-fidelity CSS fallback. With
209
+ // no LLM, step 3 extracts nothing, so landing on that fallback is already
210
+ // settled here, before the page is fetched.
211
+ const requiredCount = (schema.required || []).length;
212
+ if (fallbackToSelectors !== false && !llmAvailable && requiredCount > 3) {
213
+ const gate = this._elicitation.confirm(
214
+ ctx,
215
+ 'extract_structured:no_llm_required_fields',
216
+ `No LLM provider is configured and the requested schema has ${requiredCount} required fields. ` +
217
+ `extract_structured will fall back to lower-fidelity CSS selector extraction, which may miss required fields.`,
218
+ { url, required_fields: requiredCount }
219
+ );
220
+ if (gate.status === 'ask') return gate.result;
221
+ if (gate.status === 'cancelled') {
222
+ return {
223
+ success: false,
224
+ url,
225
+ data: {},
226
+ extraction_method: 'none',
227
+ confidence: 0,
228
+ schema_used: schema,
229
+ processingTime: Date.now() - startTime,
230
+ error: 'Extraction cancelled by user (elicitation declined).',
231
+ validation: { valid: false, errors: ['Extraction cancelled by user (elicitation declined).'] }
232
+ };
233
+ }
234
+ }
235
+
236
+ // Step 1: Fetch and parse — shared helper strips scripts/styles/iframes/svgs
237
+ const { html, $, textContent, warnings } = await fetchAndParse(url, {
238
+ userAgent: user_agent || this.userAgent,
239
+ respectRobots: respect_robots,
240
+ tool: 'extract_structured'
241
+ });
242
+
243
+ // What the model reads — see shownText().
244
+ const shown = shownText($, html, url, textContent);
245
+
246
+ // Step 3: Try LLM extraction first (readiness resolved in step 0)
247
+ if (llmAvailable) {
248
+ try {
207
249
  const result = await llm.extractStructured(shown, schema, {
208
250
  prompt: prompt || '',
209
251
  maxContentLength: SHOWN_TEXT_BUDGET
@@ -218,12 +260,11 @@ export class ExtractStructuredTool {
218
260
  } else {
219
261
  llmErrorMessage = result?.error || 'LLM did not return usable JSON';
220
262
  }
263
+ } catch (llmError) {
264
+ // LLM failed — will fall through to CSS fallback.
265
+ extractionResult = null;
266
+ llmErrorMessage = llmError.message;
221
267
  }
222
- } catch (llmError) {
223
- // LLM failed — will fall through to CSS fallback. Keep the message so
224
- // callers can tell "LLM broken" apart from "no LLM configured".
225
- extractionResult = null;
226
- llmErrorMessage = llmError.message;
227
268
  }
228
269
 
229
270
  // Step 3b (3.4): numeric provenance. Only the LLM path invents numbers —
@@ -318,31 +359,9 @@ export class ExtractStructuredTool {
318
359
  if (guarded.checked.skipped) provenance.skipped = guarded.checked.skipped;
319
360
  }
320
361
 
321
- // Step 4: CSS selector fallback if LLM unavailable or failed
362
+ // Step 4: CSS selector fallback if LLM unavailable or failed (the D1.4
363
+ // confirmation for this path is gated above, before the fetch)
322
364
  if (!extractionResult && fallbackToSelectors !== false) {
323
- // D1.4: no LLM configured and the schema demands more than 3 required
324
- // fields — confirm before running the lower-fidelity CSS fallback.
325
- const requiredCount = (schema.required || []).length;
326
- if (!llmAvailable && requiredCount > 3) {
327
- const proceed = await this._elicitation.confirm(
328
- `No LLM provider is configured and the requested schema has ${requiredCount} required fields. ` +
329
- `extract_structured will fall back to lower-fidelity CSS selector extraction, which may miss required fields.`,
330
- { url, required_fields: requiredCount }
331
- );
332
- if (!proceed) {
333
- return {
334
- success: false,
335
- url,
336
- data: {},
337
- extraction_method: 'none',
338
- confidence: 0,
339
- schema_used: schema,
340
- processingTime: Date.now() - startTime,
341
- error: 'Extraction cancelled by user (elicitation declined).',
342
- validation: { valid: false, errors: ['Extraction cancelled by user (elicitation declined).'] }
343
- };
344
- }
345
- }
346
365
  extractionResult = this._cssExtraction($, schema, selectorHints || {});
347
366
  extractionMethod = 'css_fallback';
348
367
  }
@@ -115,7 +115,7 @@ export class DeepResearchTool {
115
115
  this._elicitation = new ElicitationHelper({ mcpServer });
116
116
  }
117
117
 
118
- async execute(params) {
118
+ async execute(params, ctx) {
119
119
  try {
120
120
  const validated = DeepResearchSchema.parse(params);
121
121
  const sessionId = this.generateSessionId();
@@ -137,10 +137,15 @@ export class DeepResearchTool {
137
137
  }
138
138
 
139
139
  // D1.4: Elicitation — warn user if projected cost exceeds 50 credits
140
- // deep_research costs approximately 1 credit per URL; maxUrls > 50 → confirm
140
+ // deep_research costs approximately 1 credit per URL; maxUrls > 50 → confirm.
141
+ // An unanswered gate returns an input-required result the SDK answers by
142
+ // re-entering this handler from the top, so it sits above the session
143
+ // registration below — a second entry would otherwise leak a session.
141
144
  if (validated.maxUrls > 50) {
142
145
  const projectedCredits = validated.maxUrls;
143
- const proceed = await this._elicitation.confirm(
146
+ const gate = this._elicitation.confirm(
147
+ ctx,
148
+ 'deep_research:max_urls',
144
149
  `deep_research will scan up to ${validated.maxUrls} URLs, projecting ~${projectedCredits} credits.`,
145
150
  {
146
151
  topic: validated.topic,
@@ -148,7 +153,8 @@ export class DeepResearchTool {
148
153
  max_urls: validated.maxUrls,
149
154
  }
150
155
  );
151
- if (!proceed) {
156
+ if (gate.status === 'ask') return gate.result;
157
+ if (gate.status === 'cancelled') {
152
158
  return {
153
159
  success: false,
154
160
  error: 'Research cancelled by user before starting (elicitation declined).',