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 +7 -1
- package/package.json +1 -1
- package/server.js +19 -12
- package/src/cli/commands/login.js +176 -0
- package/src/cli/index.js +2 -0
- package/src/core/AuthManager.js +19 -6
- package/src/core/ElicitationHelper.js +157 -76
- package/src/server/requestContext.js +50 -0
- package/src/server/transports/streamableHttp.js +17 -4
- package/src/server/withAuth.js +47 -9
- package/src/tools/advanced/batchScrape/index.js +29 -19
- package/src/tools/agent/agent.js +9 -4
- package/src/tools/crawl/crawlDeep.js +10 -4
- package/src/tools/extract/extractStructured.js +62 -43
- package/src/tools/research/deepResearch.js +10 -4
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.
|
|
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.
|
|
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.
|
package/src/core/AuthManager.js
CHANGED
|
@@ -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
|
|
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
|
|
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 (
|
|
292
|
-
return
|
|
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
|
|
5
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
74
|
-
*
|
|
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
|
-
|
|
77
|
-
const
|
|
78
|
-
if (
|
|
109
|
+
_clientCapabilities(ctx) {
|
|
110
|
+
const fromEnvelope = ctx?.mcpReq?.envelope?.[CLIENT_CAPABILITIES_META_KEY];
|
|
111
|
+
if (fromEnvelope) return fromEnvelope;
|
|
79
112
|
try {
|
|
80
|
-
|
|
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
|
|
115
|
+
return undefined;
|
|
86
116
|
}
|
|
87
117
|
}
|
|
88
118
|
|
|
89
119
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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
|
-
|
|
96
|
-
return this.
|
|
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
|
-
*
|
|
105
|
-
* so tools continue working in non-elicitation clients).
|
|
130
|
+
* SYNCHRONOUS — it performs no I/O. Do not `await` it.
|
|
106
131
|
*
|
|
107
|
-
* @param {
|
|
108
|
-
* @param {
|
|
109
|
-
* @
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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);
|
package/src/server/withAuth.js
CHANGED
|
@@ -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,
|
|
279
|
-
// callers, who have no transport-provided store,
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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:
|
|
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 (
|
|
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:
|
|
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 {
|
package/src/tools/agent/agent.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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).',
|