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