@dokebi/mcp 0.0.0-stage → 0.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/dist/runner.js ADDED
@@ -0,0 +1,740 @@
1
+ import { performance } from 'node:perf_hooks';
2
+ import { isDokebiError } from '@dokebi/core';
3
+ import { diagnostics } from '@dokebi/core/libraries';
4
+ import { imageBytes, isEnvelope } from './envelope.js';
5
+ import { McpError } from './errors.js';
6
+ import { isPlainObject } from './jsonrpc.js';
7
+ import { INTERNAL_ERROR, INVALID_PARAMS, RESOURCE_NOT_FOUND } from './protocol.js';
8
+ const INTERNAL_MESSAGE = 'The call failed unexpectedly';
9
+ const INTERNAL_HINT = 'Try again later; if it keeps failing, tell the person who runs this server.';
10
+ /** The body of a tool error (D-0074 shape plus `next`): code, message, hint, next, data. */
11
+ export function errorBody(error) {
12
+ const data = error.data;
13
+ const next = data?.next;
14
+ const rest = data === undefined
15
+ ? undefined
16
+ : Object.fromEntries(Object.entries(data).filter(([key]) => key !== 'next'));
17
+ return {
18
+ code: error.code,
19
+ message: error.message,
20
+ ...(error.hint === undefined ? {} : { hint: error.hint }),
21
+ ...(typeof next === 'string' || Array.isArray(next) ? { next } : {}),
22
+ ...(rest && Object.keys(rest).length ? { data: rest } : {}),
23
+ };
24
+ }
25
+ /** A tool result with `isError`: the error object as structuredContent and as JSON text. */
26
+ export function toolError(body) {
27
+ return {
28
+ content: [{ type: 'text', text: JSON.stringify(body) }],
29
+ structuredContent: body,
30
+ isError: true,
31
+ };
32
+ }
33
+ function internalBody() {
34
+ return { code: 'INTERNAL', message: INTERNAL_MESSAGE, hint: INTERNAL_HINT };
35
+ }
36
+ function coded(code, message, hint, data) {
37
+ return { code, message, hint, ...(data ? { data } : {}) };
38
+ }
39
+ export class McpRunner {
40
+ #s;
41
+ #repeats;
42
+ constructor(settings) {
43
+ this.#s = settings;
44
+ this.#repeats = new diagnostics.Repeats({
45
+ logger: settings.logger,
46
+ intervalMs: settings.logRepeatWindowMs ?? 10_000,
47
+ });
48
+ }
49
+ /**
50
+ * D-0154: reloaded limits of the next call (the tool deadline, the result size, the picture
51
+ * limits); a call in progress keeps the ones it started with.
52
+ */
53
+ update(next) {
54
+ this.#s = { ...this.#s, ...next };
55
+ }
56
+ /** Writes the counted repeats of error logs (at close). */
57
+ flush() {
58
+ this.#repeats.flush();
59
+ }
60
+ #hookContext(signal, request) {
61
+ return Object.freeze({ ...this.#s.hookContext, signal, request });
62
+ }
63
+ #context(call, signal, fields, rateLimit) {
64
+ return {
65
+ grant: call.grant,
66
+ userId: call.grant.userId,
67
+ logger: this.#s.logger.child({ ...fields, grant: call.grant.id }),
68
+ signal,
69
+ request: call.request,
70
+ version: call.version,
71
+ rateLimit,
72
+ };
73
+ }
74
+ /**
75
+ * The budget of one call: one deadline and one signal for its hooks and its handler. The client's
76
+ * and the app's signals abort it through one listener each, removed by `release` when the call
77
+ * answered. Not AbortSignal.any: with the app signal, which lives as long as the app and never
78
+ * aborts before the stop, each composite stayed among that signal's dependants (about 340 bytes
79
+ * per call on Node 24.15, never freed: R5-MCP-1).
80
+ */
81
+ #budget(call) {
82
+ const controller = new AbortController();
83
+ const links = [];
84
+ for (const source of [call.signal, this.#s.appSignal]) {
85
+ if (source === undefined)
86
+ continue;
87
+ if (source.aborted) {
88
+ controller.abort(source.reason);
89
+ break;
90
+ }
91
+ const onAbort = () => controller.abort(source.reason);
92
+ source.addEventListener('abort', onAbort, { once: true });
93
+ links.push([source, onAbort]);
94
+ }
95
+ return {
96
+ controller,
97
+ signal: controller.signal,
98
+ deadline: performance.now() + this.#s.timeoutMs,
99
+ release: () => {
100
+ for (const [source, onAbort] of links)
101
+ source.removeEventListener('abort', onAbort);
102
+ links.length = 0;
103
+ },
104
+ };
105
+ }
106
+ /**
107
+ * Runs one step (a hook or the handler) within what is left of the call's budget. Nothing runs
108
+ * once the signal is aborted (the client left, the budget ended, the app stops); a step that
109
+ * outlives its answer is observed and its end logged, never lost.
110
+ */
111
+ async #within(budget, work, fields) {
112
+ const signal = budget.signal;
113
+ if (signal.aborted)
114
+ return { kind: 'cancelled' };
115
+ const remaining = budget.deadline - performance.now();
116
+ if (remaining <= 0) {
117
+ budget.controller.abort(this.#timeout());
118
+ return { kind: 'timeout' };
119
+ }
120
+ let timer;
121
+ let onAbort;
122
+ const expired = new Promise((resolve) => {
123
+ timer = setTimeout(() => resolve({ kind: 'timeout' }), remaining);
124
+ });
125
+ const stopped = new Promise((resolve) => {
126
+ onAbort = () => resolve({ kind: 'cancelled' });
127
+ signal.addEventListener('abort', onAbort, { once: true });
128
+ });
129
+ let running;
130
+ try {
131
+ running = Promise.resolve(work(signal));
132
+ }
133
+ catch (error) {
134
+ running = Promise.reject(error);
135
+ }
136
+ const settled = running.then((value) => ({ kind: 'done', value }), (error) => ({ kind: 'failed', error }));
137
+ const outcome = await Promise.race([settled, expired, stopped]);
138
+ clearTimeout(timer);
139
+ if (onAbort)
140
+ signal.removeEventListener('abort', onAbort);
141
+ if (outcome.kind === 'timeout' || outcome.kind === 'cancelled') {
142
+ if (outcome.kind === 'timeout')
143
+ budget.controller.abort(this.#timeout());
144
+ // The step keeps running until it notices the signal: its end is logged. The signal is
145
+ // aborted by now (the budget or the client ended it), so a late rejection is told apart by
146
+ // what it is: the step stopping on the abort is debug, any other failure is an error.
147
+ void settled.then((late) => {
148
+ if (late.kind === 'failed' && !endedByAbort(late.error, signal))
149
+ this.#repeats.log('error', 'MCP handler failed after its answer', 'MCP_LATE_FAILURE', late.error, fields, String(fields.name));
150
+ else
151
+ diagnostics.logDebug(this.#s.logger, 'MCP handler ended after its answer', () => ({
152
+ ...fields,
153
+ outcome: late.kind,
154
+ ...(late.kind === 'failed' ? { error: diagnostics.describeError(late.error) } : {}),
155
+ }));
156
+ });
157
+ }
158
+ return outcome;
159
+ }
160
+ #timeout() {
161
+ return new McpError('TOOL_TIMEOUT', `The call ran longer than ${this.#s.timeoutMs} ms`);
162
+ }
163
+ /** The DokebiError for a thrown value: itself, a translation (D-0093), or undefined. */
164
+ #known(error, call, fields) {
165
+ if (isDokebiError(error)) {
166
+ if ((error.status ?? 0) >= 500)
167
+ this.#repeats.log('error', 'MCP call failed with a server error', 'MCP_CALL_FAILED', error, fields, `${fields.name} ${error.code}`);
168
+ else
169
+ diagnostics.logDebug(this.#s.logger, 'MCP call refused', () => ({
170
+ ...fields,
171
+ errorCode: error.code,
172
+ ...(error.fix === undefined ? {} : { fix: error.fix }),
173
+ }));
174
+ return error;
175
+ }
176
+ for (const { plugin, value } of this.#s.translators) {
177
+ let translated;
178
+ try {
179
+ translated = value.translate(error, call.request);
180
+ }
181
+ catch (failure) {
182
+ this.#repeats.log('error', 'MCP error translator failed', 'MCP_ERROR_TRANSLATOR_FAILED', failure, { ...fields, translator: plugin }, plugin);
183
+ continue;
184
+ }
185
+ if (translated === undefined)
186
+ continue;
187
+ if (!isDokebiError(translated)) {
188
+ this.#repeats.log('error', 'MCP error translator failed', 'MCP_ERROR_TRANSLATOR_FAILED', new McpError('INVALID_HOOK_RESULT', 'An error translator returned something other than a Dokebi error or undefined', { data: { translator: plugin } }), { ...fields, translator: plugin }, plugin);
189
+ continue;
190
+ }
191
+ this.#repeats.log('error', 'MCP call error translated', 'MCP_ERROR_TRANSLATED', translated, { ...fields, translator: plugin, errorCode: translated.code }, `${fields.name} ${translated.code}`);
192
+ return translated;
193
+ }
194
+ return undefined;
195
+ }
196
+ #failed(error, fields) {
197
+ this.#repeats.log('error', 'MCP call failed', 'MCP_CALL_FAILED', error, fields, String(fields.name));
198
+ }
199
+ #hookFailed(hook, error, fields) {
200
+ this.#repeats.log('error', 'MCP hook failed', 'MCP_HOOK_FAILED', error, { ...fields, hook }, hook);
201
+ }
202
+ #log(call, fields, started, bytes, code, envelope) {
203
+ this.report(call, fields.method, String(fields.name), Math.round(performance.now() - started), bytes, code, envelope);
204
+ }
205
+ /**
206
+ * The end of one call: its MCP_CALL info line and, when the request records them, its frozen
207
+ * `onCall` entry with the same values (D-0134). The route's own refusals (the scope, an unknown
208
+ * name) come here too, so the log and the hook see the same calls.
209
+ */
210
+ report(call, method, name, ms, bytes, code, envelope) {
211
+ const images = envelope?.images;
212
+ this.#s.logger.log('info', 'MCP call', {
213
+ code: 'MCP_CALL',
214
+ method,
215
+ name,
216
+ grant: call.grant.id,
217
+ client: call.grant.clientId,
218
+ ms,
219
+ bytes,
220
+ result: code,
221
+ // D-0143: how many pictures went and their bytes; never the bytes themselves.
222
+ ...(images ? { images: images.length, imageBytes: total(images) } : {}),
223
+ });
224
+ if (call.record)
225
+ call.record(Object.freeze({
226
+ method,
227
+ name,
228
+ grant: call.grant.id,
229
+ client: call.grant.clientId,
230
+ userId: call.grant.userId,
231
+ ms,
232
+ bytes,
233
+ result: code,
234
+ ...(envelope === undefined ? {} : { envelope }),
235
+ }));
236
+ }
237
+ /**
238
+ * One token of the grant's bucket. An allowed call keeps the decision (D-0131): the handler reads
239
+ * it as `context.rateLimit`, what is left after this call's token, without another round trip.
240
+ */
241
+ async #limit(bucket, grant) {
242
+ const limiter = this.#s.limiters.get(bucket);
243
+ if (!limiter)
244
+ return { kind: 'none' };
245
+ let decision;
246
+ try {
247
+ decision = await limiter.consume(`${bucket}:${grant.id}`);
248
+ }
249
+ catch (error) {
250
+ // A store failure of the limiter (UNAVAILABLE, CAPACITY): answered with its code.
251
+ return { kind: 'failed', error };
252
+ }
253
+ if (!decision.allowed)
254
+ return { kind: 'refused', seconds: Math.max(1, Math.ceil(decision.retryAfterMs / 1000)) };
255
+ return {
256
+ kind: 'allowed',
257
+ rateLimit: Object.freeze({
258
+ bucket,
259
+ limit: decision.limit,
260
+ remaining: decision.remaining,
261
+ resetAfterMs: decision.resetAfterMs,
262
+ }),
263
+ };
264
+ }
265
+ /** `checkGrant` when the extension has it, within the budget. */
266
+ async #checkGrant(target, call, budget, fields) {
267
+ if (!this.#s.hooks || !this.#s.overridden.has('checkGrant'))
268
+ return undefined;
269
+ const hooks = this.#s.hooks;
270
+ const outcome = await this.#within(budget, (signal) => hooks.checkGrant(call.grant, target, this.#hookContext(signal, call.request)), { ...fields, hook: 'checkGrant' });
271
+ if (outcome.kind === 'timeout' || outcome.kind === 'cancelled')
272
+ return { stopped: outcome.kind };
273
+ if (outcome.kind === 'failed') {
274
+ if (isDokebiError(outcome.error))
275
+ return { error: outcome.error };
276
+ this.#hookFailed('checkGrant', outcome.error, fields);
277
+ return { error: new McpError('INTERNAL', INTERNAL_MESSAGE, { cause: outcome.error }) };
278
+ }
279
+ if (outcome.value === true)
280
+ return undefined;
281
+ if (outcome.value === false) {
282
+ diagnostics.logDebug(this.#s.logger, 'MCP grant refused by checkGrant', () => fields);
283
+ return { refused: true };
284
+ }
285
+ this.#hookFailed('checkGrant', new McpError('INVALID_HOOK_RESULT', 'checkGrant() must return a boolean', {
286
+ data: { hook: 'checkGrant' },
287
+ }), fields);
288
+ return { error: new McpError('INTERNAL', INTERNAL_MESSAGE) };
289
+ }
290
+ /** One `tools/call`, after the scope check. */
291
+ async callTool(compiled, args, call) {
292
+ const budget = this.#budget(call);
293
+ try {
294
+ return await this.#callTool(compiled, args, call, budget);
295
+ }
296
+ finally {
297
+ budget.release();
298
+ }
299
+ }
300
+ async #callTool(compiled, args, call, budget) {
301
+ const started = performance.now();
302
+ const fields = {
303
+ method: 'tools/call',
304
+ name: compiled.name,
305
+ grant: call.grant.id,
306
+ client: call.grant.clientId,
307
+ };
308
+ const done = (result, code, bytes, envelope) => {
309
+ this.#log(call, fields, started, bytes, code, envelope);
310
+ return { result, code };
311
+ };
312
+ const refuse = (body) => {
313
+ const result = toolError(body);
314
+ return done(result, String(body.code), Buffer.byteLength(result.content[0]?.text ?? ''));
315
+ };
316
+ const fromError = (error) => {
317
+ const known = this.#known(error, call, fields);
318
+ if (known)
319
+ return refuse(errorBody(known));
320
+ this.#failed(error, fields);
321
+ return refuse(internalBody());
322
+ };
323
+ const stopped = (kind) => {
324
+ if (kind === 'cancelled')
325
+ return refuse(coded('CANCELLED', 'The call was cancelled', 'Call the tool again if you still need it.'));
326
+ this.#s.logger.log('warn', 'MCP call ran out of time', {
327
+ code: 'MCP_TOOL_TIMEOUT',
328
+ ...fields,
329
+ timeoutMs: this.#s.timeoutMs,
330
+ });
331
+ return refuse(coded('TOOL_TIMEOUT', `The tool ran longer than ${Math.round(this.#s.timeoutMs / 1000)} seconds`, 'Ask for less (a smaller page or a narrower filter), or try again later.'));
332
+ };
333
+ // 1. The limit of this grant and bucket (every attempt takes a token).
334
+ const limited = await this.#limit(compiled.bucket, call.grant);
335
+ if (limited.kind === 'failed')
336
+ return fromError(limited.error);
337
+ if (limited.kind === 'refused')
338
+ return refuse({
339
+ code: 'LIMIT_EXCEEDED',
340
+ message: `This connection used its ${compiled.bucket} limit`,
341
+ hint: `Wait ${limited.seconds} seconds, then call the tool again.`,
342
+ next: `Wait ${limited.seconds} seconds before calling ${compiled.name} again.`,
343
+ retry_after: limited.seconds,
344
+ });
345
+ if (budget.signal.aborted)
346
+ return stopped('cancelled');
347
+ // 2. The grant may still reach this (the binding's project, the membership).
348
+ const grantCheck = await this.#checkGrant({
349
+ kind: 'tool',
350
+ name: compiled.name,
351
+ scope: compiled.scope,
352
+ scopes: compiled.scopes,
353
+ matched: matchedScope(call.grant.scopes, compiled.scopes),
354
+ value: compiled.tool,
355
+ }, call, budget, fields);
356
+ if (grantCheck && 'stopped' in grantCheck)
357
+ return stopped(grantCheck.stopped);
358
+ if (grantCheck && 'refused' in grantCheck)
359
+ return refuse(coded('GRANT_REFUSED', 'This connection may not use this any more', 'Ask the user to check the connection in the application, or to connect again.'));
360
+ if (grantCheck)
361
+ return fromError(grantCheck.error);
362
+ // 3. The input (any failure of the check is an answer, never an HTTP 500).
363
+ const input = args === undefined ? {} : args;
364
+ if (!isPlainObject(input))
365
+ return refuse(coded('INVALID_INPUT', `The arguments of ${compiled.name} must be an object`, 'Send the arguments as one JSON object, as the input schema says.'));
366
+ let problems;
367
+ try {
368
+ problems = compiled.input.validate(input);
369
+ }
370
+ catch (error) {
371
+ this.#failed(new McpError('INVALID_INPUT', `The input check of ${compiled.name} failed`, {
372
+ cause: error,
373
+ }), fields);
374
+ return refuse(internalBody());
375
+ }
376
+ if (problems.length)
377
+ return refuse(coded('INVALID_INPUT', `The arguments do not match the input schema of ${compiled.name}`, 'Correct the listed arguments and call the tool again.', { problems }));
378
+ // 4. The handler, within the budget.
379
+ const outcome = await this.#within(budget, (signal) => {
380
+ const context = Object.freeze({
381
+ ...this.#context(call, signal, { tool: compiled.name }, limited.kind === 'allowed' ? limited.rateLimit : undefined),
382
+ resultMaxChars: this.#s.resultMaxChars,
383
+ images: this.#s.images,
384
+ });
385
+ return compiled.tool.handle(input, context);
386
+ }, fields);
387
+ if (outcome.kind === 'timeout' || outcome.kind === 'cancelled')
388
+ return stopped(outcome.kind);
389
+ if (outcome.kind === 'failed')
390
+ return fromError(outcome.error);
391
+ // 5. The envelope, then the hook that may change it once (within the budget too).
392
+ let result = outcome.value;
393
+ if (!isEnvelope(result)) {
394
+ this.#failed(new McpError('INVALID_RESULT', `${compiled.name}.handle() returned something other than envelope()`, {
395
+ fix: `Return envelope({ content, note, … }) from ${compiled.name}.handle() (import { envelope } from '@dokebi/mcp').`,
396
+ }), fields);
397
+ return refuse(internalBody());
398
+ }
399
+ if (this.#s.hooks && this.#s.overridden.has('transformResult')) {
400
+ const hooks = this.#s.hooks;
401
+ const original = result;
402
+ const changed = await this.#within(budget, (signal) => hooks.transformResult(original, { tool: compiled.tool, grant: call.grant }, this.#hookContext(signal, call.request)), { ...fields, hook: 'transformResult' });
403
+ if (changed.kind === 'timeout' || changed.kind === 'cancelled')
404
+ return stopped(changed.kind);
405
+ if (changed.kind === 'failed') {
406
+ if (isDokebiError(changed.error))
407
+ return fromError(changed.error);
408
+ this.#hookFailed('transformResult', changed.error, fields);
409
+ return refuse(internalBody());
410
+ }
411
+ result = changed.value;
412
+ if (!isEnvelope(result)) {
413
+ this.#hookFailed('transformResult', new McpError('INVALID_HOOK_RESULT', 'transformResult() must return an envelope (amend(result, …))', { data: { hook: 'transformResult' } }), fields);
414
+ return refuse(internalBody());
415
+ }
416
+ }
417
+ // 6. The pictures (D-0143): their own limits, apart from resultMaxChars.
418
+ const images = result.images ?? [];
419
+ if (images.length) {
420
+ const refused = this.#imageLimits(images, fields);
421
+ if (refused)
422
+ return refuse(refused);
423
+ }
424
+ // 7. The size, never cut silently.
425
+ let text;
426
+ try {
427
+ text = JSON.stringify(result);
428
+ }
429
+ catch (error) {
430
+ this.#failed(new McpError('INVALID_RESULT', `The result of ${compiled.name} cannot be written as JSON`, {
431
+ cause: error,
432
+ }), fields);
433
+ return refuse(internalBody());
434
+ }
435
+ if (text.length > this.#s.resultMaxChars)
436
+ return refuse(coded('RESULT_TOO_LARGE', `The result has ${text.length} characters; the limit is ${this.#s.resultMaxChars}`, 'Ask for less: a smaller page (cursor) or a narrower filter.', {
437
+ chars: text.length,
438
+ max: this.#s.resultMaxChars,
439
+ }));
440
+ // The text block first, then one MCP image block per picture (every protocol version has
441
+ // `{ type: 'image', data, mimeType }`; no annotations, so 2025-03-26 clients read it too).
442
+ const content = [{ type: 'text', text }];
443
+ for (const image of images) {
444
+ // Every placeholder of an envelope() has its bytes (isEnvelope above).
445
+ const bytes = imageBytes(image);
446
+ content.push({
447
+ type: 'image',
448
+ data: Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64'),
449
+ mimeType: image.mimeType,
450
+ });
451
+ }
452
+ return done({ content, structuredContent: result }, 'ok', Buffer.byteLength(text), result);
453
+ }
454
+ /** The image limits of one result: undefined when they hold, else the tool error's body. */
455
+ #imageLimits(images, fields) {
456
+ const limits = this.#s.images;
457
+ const bytes = total(images);
458
+ const refuse = (body) => {
459
+ diagnostics.logDebug(this.#s.logger, 'MCP images refused', () => ({
460
+ ...fields,
461
+ errorCode: body.code,
462
+ images: images.length,
463
+ imageBytes: bytes,
464
+ ...limits,
465
+ }));
466
+ return body;
467
+ };
468
+ if (images.length > limits.maxImages)
469
+ return refuse(coded('TOO_MANY_IMAGES', `The result has ${images.length} pictures; the limit is ${limits.maxImages}`, 'Ask for fewer pictures (a narrower filter or a smaller page).', { count: images.length, max: limits.maxImages }));
470
+ for (const image of images)
471
+ if (image.bytes > limits.imageMaxBytes)
472
+ return refuse(coded('IMAGE_TOO_LARGE', `Picture ${image.index} has ${image.bytes} bytes; the limit is ${limits.imageMaxBytes}`, 'Ask for a smaller picture, or describe it in words instead.', { index: image.index, bytes: image.bytes, max: limits.imageMaxBytes }));
473
+ if (bytes > limits.imageTotalMaxBytes)
474
+ return refuse(coded('IMAGE_TOTAL_TOO_LARGE', `The pictures have ${bytes} bytes together; the limit is ${limits.imageTotalMaxBytes}`, 'Ask for fewer or smaller pictures.', { bytes, max: limits.imageTotalMaxBytes }));
475
+ diagnostics.logDebug(this.#s.logger, 'MCP images attached', () => ({
476
+ ...fields,
477
+ images: images.length,
478
+ imageBytes: bytes,
479
+ sizes: images.map((image) => image.bytes),
480
+ mimeTypes: images.map((image) => image.mimeType),
481
+ }));
482
+ return undefined;
483
+ }
484
+ /** The JSON-RPC error of a thrown value (prompts, resources). */
485
+ #rpcFailure(error, call, fields) {
486
+ const known = this.#known(error, call, fields);
487
+ if (known)
488
+ return {
489
+ kind: 'error',
490
+ // A 5xx is the server's state (a storage that is stopping, a store that is down): -32603,
491
+ // with the code and its retryAfterMs; an application refusal is the request's: -32602.
492
+ error: (known.status ?? 0) >= 500 ? INTERNAL_ERROR : INVALID_PARAMS,
493
+ message: known.message,
494
+ data: errorBody(known),
495
+ code: known.code,
496
+ };
497
+ this.#failed(error, fields);
498
+ return {
499
+ kind: 'error',
500
+ error: INTERNAL_ERROR,
501
+ message: INTERNAL_MESSAGE,
502
+ data: internalBody(),
503
+ code: 'INTERNAL',
504
+ };
505
+ }
506
+ /** The size limit of a prompt or resource answer (its JSON). */
507
+ #sized(result, what) {
508
+ const size = JSON.stringify(result).length;
509
+ if (size <= this.#s.resultMaxChars)
510
+ return { kind: 'result', result, code: 'ok' };
511
+ const message = `The ${what} has ${size} characters; the limit is ${this.#s.resultMaxChars}`;
512
+ return {
513
+ kind: 'error',
514
+ error: INVALID_PARAMS,
515
+ message,
516
+ data: coded('RESULT_TOO_LARGE', message, `Ask for a smaller ${what}.`, {
517
+ chars: size,
518
+ max: this.#s.resultMaxChars,
519
+ }),
520
+ code: 'RESULT_TOO_LARGE',
521
+ };
522
+ }
523
+ /** Prompts and resources: the `read` limit, checkGrant and the step, all within the budget. */
524
+ async #guarded(target, call, fields, run, shape) {
525
+ const budget = this.#budget(call);
526
+ try {
527
+ return await this.#guardedIn(budget, target, call, fields, run, shape);
528
+ }
529
+ finally {
530
+ budget.release();
531
+ }
532
+ }
533
+ async #guardedIn(budget, target, call, fields, run, shape) {
534
+ const started = performance.now();
535
+ const finish = (outcome) => {
536
+ const bytes = outcome.kind === 'result' ? Buffer.byteLength(JSON.stringify(outcome.result)) : 0;
537
+ this.#log(call, fields, started, bytes, outcome.code);
538
+ return outcome;
539
+ };
540
+ const failure = (error, code, message, hint, data) => ({
541
+ kind: 'error',
542
+ error,
543
+ message,
544
+ data: coded(code, message, hint, data),
545
+ code,
546
+ });
547
+ const stopped = (kind) => {
548
+ if (kind === 'cancelled')
549
+ return failure(INTERNAL_ERROR, 'CANCELLED', 'The call was cancelled', 'Call again if you still need it.');
550
+ this.#s.logger.log('warn', 'MCP call ran out of time', {
551
+ code: 'MCP_TOOL_TIMEOUT',
552
+ ...fields,
553
+ timeoutMs: this.#s.timeoutMs,
554
+ });
555
+ return failure(INTERNAL_ERROR, 'TOOL_TIMEOUT', `The call ran longer than ${Math.round(this.#s.timeoutMs / 1000)} seconds`, 'Try again later.');
556
+ };
557
+ // Prompts and resources take tokens of the `read` bucket (a batch is no way around it).
558
+ const limited = await this.#limit('read', call.grant);
559
+ if (limited.kind === 'failed')
560
+ return finish(this.#rpcFailure(limited.error, call, fields));
561
+ if (limited.kind === 'refused')
562
+ return finish({
563
+ kind: 'error',
564
+ error: INVALID_PARAMS,
565
+ message: 'This connection used its read limit',
566
+ data: {
567
+ code: 'LIMIT_EXCEEDED',
568
+ message: 'This connection used its read limit',
569
+ hint: `Wait ${limited.seconds} seconds, then call again.`,
570
+ retry_after: limited.seconds,
571
+ },
572
+ code: 'LIMIT_EXCEEDED',
573
+ });
574
+ if (budget.signal.aborted)
575
+ return finish(stopped('cancelled'));
576
+ const grantCheck = await this.#checkGrant(target, call, budget, fields);
577
+ if (grantCheck && 'stopped' in grantCheck)
578
+ return finish(stopped(grantCheck.stopped));
579
+ if (grantCheck && 'refused' in grantCheck)
580
+ return finish(failure(INVALID_PARAMS, 'GRANT_REFUSED', 'This connection may not use this any more', 'Ask the user to check the connection in the application, or to connect again.'));
581
+ if (grantCheck)
582
+ return finish(this.#rpcFailure(grantCheck.error, call, fields));
583
+ const rateLimit = limited.kind === 'allowed' ? limited.rateLimit : undefined;
584
+ const outcome = await this.#within(budget, (signal) => run(signal, rateLimit), fields);
585
+ if (outcome.kind === 'timeout' || outcome.kind === 'cancelled')
586
+ return finish(stopped(outcome.kind));
587
+ if (outcome.kind === 'failed')
588
+ return finish(this.#rpcFailure(outcome.error, call, fields));
589
+ return finish(shape(outcome.value));
590
+ }
591
+ /** One `prompts/get` (arguments already checked against the declaration). */
592
+ async getPrompt(compiled, args, call) {
593
+ const fields = {
594
+ method: 'prompts/get',
595
+ name: compiled.name,
596
+ grant: call.grant.id,
597
+ client: call.grant.clientId,
598
+ };
599
+ return this.#guarded({
600
+ kind: 'prompt',
601
+ name: compiled.name,
602
+ scope: compiled.scope,
603
+ scopes: compiled.scopes,
604
+ matched: matchedScope(call.grant.scopes, compiled.scopes),
605
+ value: compiled.prompt,
606
+ }, call, fields, (signal, rateLimit) => compiled.prompt.handle(args, Object.freeze(this.#context(call, signal, { prompt: compiled.name }, rateLimit))), (value) => {
607
+ const shaped = promptResult(value);
608
+ if (shaped)
609
+ return this.#sized(shaped, 'prompt');
610
+ this.#failed(new McpError('INVALID_RESULT', `${compiled.name}.handle() must return a string, a list of { role, text } or { description, messages }`), fields);
611
+ return {
612
+ kind: 'error',
613
+ error: INTERNAL_ERROR,
614
+ message: INTERNAL_MESSAGE,
615
+ data: internalBody(),
616
+ code: 'INTERNAL',
617
+ };
618
+ });
619
+ }
620
+ /** One `resources/read` of a matched resource. */
621
+ async readResource(compiled, params, uri, call) {
622
+ const fields = {
623
+ method: 'resources/read',
624
+ name: compiled.name,
625
+ grant: call.grant.id,
626
+ client: call.grant.clientId,
627
+ };
628
+ return this.#guarded({
629
+ kind: 'resource',
630
+ name: compiled.name,
631
+ scope: compiled.scope,
632
+ scopes: compiled.scopes,
633
+ matched: matchedScope(call.grant.scopes, compiled.scopes),
634
+ value: compiled.resource,
635
+ }, call, fields, (signal, rateLimit) => compiled.resource.read(params, Object.freeze({
636
+ ...this.#context(call, signal, { resource: compiled.name }, rateLimit),
637
+ uri,
638
+ })), (value) => {
639
+ if (value === undefined)
640
+ return {
641
+ kind: 'error',
642
+ error: RESOURCE_NOT_FOUND,
643
+ message: 'There is no such resource',
644
+ data: { uri, code: 'NOT_FOUND' },
645
+ code: 'NOT_FOUND',
646
+ };
647
+ let entry;
648
+ if (typeof value === 'string')
649
+ entry = { uri, mimeType: compiled.mimeType ?? 'text/plain', text: value };
650
+ else if (value instanceof Uint8Array)
651
+ entry = {
652
+ uri,
653
+ mimeType: compiled.mimeType ?? 'application/octet-stream',
654
+ blob: Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString('base64'),
655
+ };
656
+ else {
657
+ let text;
658
+ try {
659
+ text = JSON.stringify(value);
660
+ // No throw, no text either (a function, a symbol, a toJSON answering undefined): the
661
+ // INTERNAL answer below gets its error record all the same (D-0047, R5-MCP-2).
662
+ if (text === undefined)
663
+ this.#failed(new McpError('INVALID_RESULT', `${compiled.name}.read() returned a value JSON cannot write`), fields);
664
+ }
665
+ catch (error) {
666
+ this.#failed(new McpError('INVALID_RESULT', `${compiled.name}.read() returned a value that is not JSON`, { cause: error }), fields);
667
+ }
668
+ if (text === undefined)
669
+ return {
670
+ kind: 'error',
671
+ error: INTERNAL_ERROR,
672
+ message: INTERNAL_MESSAGE,
673
+ data: internalBody(),
674
+ code: 'INTERNAL',
675
+ };
676
+ entry = { uri, mimeType: compiled.mimeType ?? 'application/json', text };
677
+ }
678
+ return this.#sized({ contents: [entry] }, 'resource');
679
+ });
680
+ }
681
+ }
682
+ /**
683
+ * Whether a step's late rejection is its reaction to the abort: the signal's own reason (what
684
+ * an aborted `fetch` rejects with on Node 24) or an AbortError (what `timers/promises` rejects
685
+ * with, its cause the reason), never a failure of its own.
686
+ */
687
+ function endedByAbort(error, signal) {
688
+ if (signal.aborted && error === signal.reason)
689
+ return true;
690
+ return error?.name === 'AbortError';
691
+ }
692
+ /** The bytes of a result's pictures together. */
693
+ function total(images) {
694
+ let bytes = 0;
695
+ for (const image of images)
696
+ bytes += image.bytes;
697
+ return bytes;
698
+ }
699
+ /** A prompt's return value as `GetPromptResult`, or undefined when it has the wrong shape. */
700
+ function promptResult(value) {
701
+ const message = (one) => {
702
+ const entry = one;
703
+ if (!entry ||
704
+ (entry.role !== 'user' && entry.role !== 'assistant') ||
705
+ typeof entry.text !== 'string')
706
+ return undefined;
707
+ return { role: entry.role, content: { type: 'text', text: entry.text } };
708
+ };
709
+ const list = (items) => {
710
+ if (!Array.isArray(items) || items.length === 0)
711
+ return undefined;
712
+ const messages = items.map(message);
713
+ return messages.every((one) => one !== undefined) ? messages : undefined;
714
+ };
715
+ if (typeof value === 'string')
716
+ return { messages: [{ role: 'user', content: { type: 'text', text: value } }] };
717
+ if (Array.isArray(value)) {
718
+ const messages = list(value);
719
+ return messages && { messages };
720
+ }
721
+ if (isPlainObject(value)) {
722
+ const messages = list(value.messages);
723
+ if (!messages)
724
+ return undefined;
725
+ if (value.description !== undefined && typeof value.description !== 'string')
726
+ return undefined;
727
+ return {
728
+ ...(value.description === undefined ? {} : { description: value.description }),
729
+ messages,
730
+ };
731
+ }
732
+ return undefined;
733
+ }
734
+ /**
735
+ * The first of `scopes` the grant holds: the one that let the call through (the scope check ran
736
+ * before, so one is held; the first of the list otherwise).
737
+ */
738
+ function matchedScope(granted, scopes) {
739
+ return scopes.find((scope) => granted.includes(scope)) ?? scopes[0];
740
+ }