@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.
@@ -0,0 +1,70 @@
1
+ import { InputSchema } from './schema.js';
2
+ import { type McpPrompt, type McpPromptArgument, type McpResource, type McpTool } from './tool.js';
3
+ export interface CompiledTool {
4
+ readonly tool: McpTool;
5
+ readonly name: string;
6
+ /** The first of `scopes` (the one a refused client is asked for). */
7
+ readonly scope: string;
8
+ /** The scopes of which any one lets a grant use it. */
9
+ readonly scopes: readonly string[];
10
+ readonly bucket: string;
11
+ readonly readOnly: boolean;
12
+ readonly input: InputSchema;
13
+ /** The `tools/list` entry (frozen). */
14
+ readonly listing: Readonly<Record<string, unknown>>;
15
+ /** The contributing plugin. */
16
+ readonly plugin: string;
17
+ }
18
+ export interface CompiledPrompt {
19
+ readonly prompt: McpPrompt;
20
+ readonly name: string;
21
+ /** The first of `scopes` (the one a refused client is asked for). */
22
+ readonly scope: string;
23
+ /** The scopes of which any one lets a grant use it. */
24
+ readonly scopes: readonly string[];
25
+ readonly arguments: readonly McpPromptArgument[];
26
+ readonly listing: Readonly<Record<string, unknown>>;
27
+ readonly plugin: string;
28
+ }
29
+ export interface CompiledResource {
30
+ readonly resource: McpResource;
31
+ readonly name: string;
32
+ /** The first of `scopes` (the one a refused client is asked for). */
33
+ readonly scope: string;
34
+ /** The scopes of which any one lets a grant use it. */
35
+ readonly scopes: readonly string[];
36
+ readonly uri: string | undefined;
37
+ readonly template: string | undefined;
38
+ readonly mimeType: string | undefined;
39
+ /**
40
+ * For a template: the literal text around its variables (one more than the variables) and
41
+ * their names. Matched by `matchTemplate` in linear time, never by a backtracking regex.
42
+ */
43
+ readonly literals: readonly string[] | undefined;
44
+ readonly variables: readonly string[];
45
+ readonly listing: Readonly<Record<string, unknown>>;
46
+ readonly plugin: string;
47
+ }
48
+ /** Each contribution with its plugin, as `context.collect` gives them. */
49
+ export interface Contributed<T> {
50
+ readonly plugin: string;
51
+ readonly value: T;
52
+ }
53
+ /** The tools, checked. `scopes`: the resource's declared scopes (undefined: not checked). */
54
+ export declare function compileTools(contributed: readonly Contributed<unknown>[], scopes: ReadonlySet<string> | undefined): CompiledTool[];
55
+ export declare function compilePrompts(contributed: readonly Contributed<unknown>[], scopes: ReadonlySet<string> | undefined): CompiledPrompt[];
56
+ /**
57
+ * The raw values of a template's variables in `uri`, or undefined: `literals[0] {v1} literals[1]
58
+ * … {vk} literals[k]`, each variable one or more characters other than `/`, `?` and `#`. The
59
+ * values are the ones a greedy regex would capture (each variable as long as the rest still
60
+ * matches), found in time linear in the URI: a regex with two variables between a literal backtracks
61
+ * quadratically (`ticket://{project}-{number}` and 32 KB of `a-`: 0.3 s; three variables: cubic),
62
+ * and the URI is client input of up to `bodyLimitBytes`.
63
+ */
64
+ export declare function matchTemplate(literals: readonly string[], uri: string): string[] | undefined;
65
+ export declare function compileResources(contributed: readonly Contributed<unknown>[], scopes: ReadonlySet<string> | undefined): CompiledResource[];
66
+ /** The resource that serves `uri` and its template values (percent-decoded), or undefined. */
67
+ export declare function matchResource(resources: readonly CompiledResource[], uri: string): {
68
+ resource: CompiledResource;
69
+ params: Readonly<Record<string, string>>;
70
+ } | undefined;
@@ -0,0 +1,413 @@
1
+ import { McpError } from './errors.js';
2
+ import { BUCKET } from './options.js';
3
+ import { TOOL_NAME } from './protocol.js';
4
+ import { frozenJson, InputSchema } from './schema.js';
5
+ import { kindOf, } from './tool.js';
6
+ const ANNOTATION_KEYS = new Set([
7
+ 'title',
8
+ 'readOnlyHint',
9
+ 'destructiveHint',
10
+ 'idempotentHint',
11
+ 'openWorldHint',
12
+ ]);
13
+ const ARGUMENT = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/;
14
+ const VARIABLE = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
15
+ const ABSOLUTE_URI = /^[A-Za-z][A-Za-z0-9+.-]*:\S+$/;
16
+ const MAX_DESCRIPTION = 8_192;
17
+ /** Reads a member of a contributed object; a getter that throws is the object's problem. */
18
+ function member(target, key, problems, where) {
19
+ try {
20
+ return target[key];
21
+ }
22
+ catch (error) {
23
+ problems.push(`${where}.${key} threw when read (${error.message})`);
24
+ return undefined;
25
+ }
26
+ }
27
+ function describe(value, where, problems) {
28
+ if (typeof value !== 'string' || value.trim() === '' || value.length > MAX_DESCRIPTION) {
29
+ problems.push(`${where}.description must be a non-empty string of at most ${MAX_DESCRIPTION} characters`);
30
+ return undefined;
31
+ }
32
+ return value;
33
+ }
34
+ function title(value, where, problems) {
35
+ if (value === undefined)
36
+ return undefined;
37
+ if (typeof value !== 'string' || value === '' || value.length > 200) {
38
+ problems.push(`${where}.title must be a non-empty string of at most 200 characters`);
39
+ return undefined;
40
+ }
41
+ return value;
42
+ }
43
+ function scopeOf(value, where, scopes, problems) {
44
+ const list = typeof value === 'string' ? [value] : Array.isArray(value) ? value : undefined;
45
+ if (list === undefined ||
46
+ list.length === 0 ||
47
+ list.length > 32 ||
48
+ list.some((item) => typeof item !== 'string' || item === '')) {
49
+ problems.push(`${where}.scope is required: one scope of the resource, or a list of 1-32 of which any one is enough`);
50
+ return undefined;
51
+ }
52
+ const unique = [...new Set(list)];
53
+ const unknown = scopes ? unique.filter((item) => !scopes.has(item)) : [];
54
+ if (unknown.length) {
55
+ problems.push(`${where}.scope ${unknown.join(', ')} is not declared by the oauth resource (${[...(scopes ?? [])].join(', ') || 'no scopes'})`);
56
+ return undefined;
57
+ }
58
+ return Object.freeze(unique);
59
+ }
60
+ function fail(code, noun, problems) {
61
+ return new McpError(code, `${problems.length} problem(s) in the contributed ${noun}s: ${problems[0]}`, {
62
+ fix: `Correct the ${noun} classes contributed to dokebi-mcp:${noun}s (details.problems lists every problem).`,
63
+ details: { problems },
64
+ });
65
+ }
66
+ /** The tools, checked. `scopes`: the resource's declared scopes (undefined: not checked). */
67
+ export function compileTools(contributed, scopes) {
68
+ const problems = [];
69
+ const result = [];
70
+ const names = new Map();
71
+ for (const { plugin, value } of contributed) {
72
+ const before = problems.length;
73
+ const label = `tool of ${plugin}`;
74
+ if (kindOf(value) !== 'tool') {
75
+ problems.push(`${label} is not an instance of a McpTool subclass`);
76
+ continue;
77
+ }
78
+ const tool = value;
79
+ const name = member(tool, 'name', problems, label);
80
+ const where = typeof name === 'string' ? `tool ${name}` : label;
81
+ if (typeof name !== 'string' || !TOOL_NAME.test(name))
82
+ problems.push(`${where}: name must be 1-128 letters, digits, _, - or .`);
83
+ else if (names.has(name))
84
+ problems.push(`${where} of ${plugin}: the name is used by another tool (of ${names.get(name)}); tool names are unique across every plugin and module`);
85
+ else
86
+ names.set(name, plugin);
87
+ const description = describe(member(tool, 'description', problems, where), where, problems);
88
+ const shown = title(member(tool, 'title', problems, where), where, problems);
89
+ const scope = scopeOf(member(tool, 'scope', problems, where), where, scopes, problems);
90
+ const annotations = member(tool, 'annotations', problems, where);
91
+ let readOnly = false;
92
+ if (annotations !== undefined) {
93
+ if (annotations === null || typeof annotations !== 'object' || Array.isArray(annotations))
94
+ problems.push(`${where}.annotations must be an object`);
95
+ else
96
+ for (const [key, hint] of Object.entries(annotations)) {
97
+ if (!ANNOTATION_KEYS.has(key))
98
+ problems.push(`${where}.annotations.${key} is not an MCP tool annotation`);
99
+ else if (key === 'title' ? typeof hint !== 'string' : typeof hint !== 'boolean')
100
+ problems.push(`${where}.annotations.${key} has the wrong type`);
101
+ }
102
+ readOnly = annotations?.readOnlyHint === true;
103
+ }
104
+ const bucket = member(tool, 'bucket', problems, where) ?? (readOnly ? 'read' : 'write');
105
+ if (typeof bucket !== 'string' || !BUCKET.test(bucket))
106
+ problems.push(`${where}.bucket must be 1-32 lower-case letters, digits or - (a limit bucket)`);
107
+ if (typeof member(tool, 'handle', problems, where) !== 'function')
108
+ problems.push(`${where}.handle must be a method`);
109
+ const input = InputSchema.check(member(tool, 'inputSchema', problems, where), 'input');
110
+ for (const problem of input.problems)
111
+ problems.push(`${where}: ${problem}`);
112
+ const outputValue = member(tool, 'outputSchema', problems, where);
113
+ const output = outputValue === undefined ? undefined : InputSchema.check(outputValue, 'output');
114
+ for (const problem of output?.problems ?? [])
115
+ problems.push(`${where}: ${problem}`);
116
+ if (problems.length > before || !input.schema)
117
+ continue;
118
+ result.push(Object.freeze({
119
+ tool,
120
+ name: name,
121
+ scope: scope[0],
122
+ scopes: scope,
123
+ bucket: bucket,
124
+ readOnly,
125
+ input: input.schema,
126
+ plugin,
127
+ listing: Object.freeze({
128
+ name,
129
+ ...(shown === undefined ? {} : { title: shown }),
130
+ description,
131
+ inputSchema: input.schema.schema,
132
+ ...(output?.schema ? { outputSchema: output.schema.schema } : {}),
133
+ ...(annotations === undefined ? {} : { annotations: frozenJson(annotations) }),
134
+ }),
135
+ }));
136
+ }
137
+ if (problems.length)
138
+ throw fail('INVALID_TOOL', 'tool', problems);
139
+ return result;
140
+ }
141
+ export function compilePrompts(contributed, scopes) {
142
+ const problems = [];
143
+ const result = [];
144
+ const names = new Map();
145
+ for (const { plugin, value } of contributed) {
146
+ const before = problems.length;
147
+ const label = `prompt of ${plugin}`;
148
+ if (kindOf(value) !== 'prompt') {
149
+ problems.push(`${label} is not an instance of a McpPrompt subclass`);
150
+ continue;
151
+ }
152
+ const prompt = value;
153
+ const name = member(prompt, 'name', problems, label);
154
+ const where = typeof name === 'string' ? `prompt ${name}` : label;
155
+ if (typeof name !== 'string' || !TOOL_NAME.test(name))
156
+ problems.push(`${where}: name must be 1-128 letters, digits, _, - or .`);
157
+ else if (names.has(name))
158
+ problems.push(`${where} of ${plugin}: the name is used by another prompt (of ${names.get(name)}); prompt names are unique across every plugin and module`);
159
+ else
160
+ names.set(name, plugin);
161
+ const description = describe(member(prompt, 'description', problems, where), where, problems);
162
+ const shown = title(member(prompt, 'title', problems, where), where, problems);
163
+ const scope = scopeOf(member(prompt, 'scope', problems, where), where, scopes, problems);
164
+ if (typeof member(prompt, 'handle', problems, where) !== 'function')
165
+ problems.push(`${where}.handle must be a method`);
166
+ const declared = member(prompt, 'arguments', problems, where) ?? [];
167
+ const args = [];
168
+ if (!Array.isArray(declared))
169
+ problems.push(`${where}.arguments must be a list`);
170
+ else {
171
+ const seen = new Set();
172
+ for (const [index, argument] of declared.entries()) {
173
+ const at = `${where}.arguments[${index}]`;
174
+ const one = argument;
175
+ if (typeof one?.name !== 'string' || !ARGUMENT.test(one.name)) {
176
+ problems.push(`${at}.name must be 1-64 letters, digits, _ or - (starting with a letter)`);
177
+ continue;
178
+ }
179
+ if (seen.has(one.name))
180
+ problems.push(`${at}: ${one.name} is listed twice`);
181
+ seen.add(one.name);
182
+ if (typeof one.description !== 'string' || one.description.trim() === '')
183
+ problems.push(`${at} has no description (the model reads only that about it)`);
184
+ if (one.required !== undefined && typeof one.required !== 'boolean')
185
+ problems.push(`${at}.required must be a boolean`);
186
+ args.push(Object.freeze({
187
+ name: one.name,
188
+ description: one.description,
189
+ ...(one.required === undefined ? {} : { required: one.required }),
190
+ }));
191
+ }
192
+ }
193
+ if (problems.length > before)
194
+ continue;
195
+ result.push(Object.freeze({
196
+ prompt,
197
+ name: name,
198
+ scope: scope[0],
199
+ scopes: scope,
200
+ arguments: Object.freeze(args),
201
+ plugin,
202
+ listing: Object.freeze({
203
+ name,
204
+ ...(shown === undefined ? {} : { title: shown }),
205
+ description,
206
+ arguments: Object.freeze(args),
207
+ }),
208
+ }));
209
+ }
210
+ if (problems.length)
211
+ throw fail('INVALID_PROMPT', 'prompt', problems);
212
+ return result;
213
+ }
214
+ /** Where `literal` occurs in `text` (KMP: linear in both, whatever the literal repeats). */
215
+ function occurrences(text, literal) {
216
+ const at = new Uint8Array(text.length + 1);
217
+ if (literal === '')
218
+ return at.fill(1);
219
+ const fail = new Int32Array(literal.length);
220
+ for (let index = 1, known = 0; index < literal.length; index++) {
221
+ while (known > 0 && literal[index] !== literal[known])
222
+ known = fail[known - 1];
223
+ if (literal[index] === literal[known])
224
+ known++;
225
+ fail[index] = known;
226
+ }
227
+ for (let index = 0, known = 0; index < text.length; index++) {
228
+ while (known > 0 && text[index] !== literal[known])
229
+ known = fail[known - 1];
230
+ if (text[index] === literal[known])
231
+ known++;
232
+ if (known === literal.length) {
233
+ at[index - literal.length + 1] = 1;
234
+ known = fail[known - 1];
235
+ }
236
+ }
237
+ return at;
238
+ }
239
+ const DELIMITER = new Set(['/', '?', '#']);
240
+ /**
241
+ * The raw values of a template's variables in `uri`, or undefined: `literals[0] {v1} literals[1]
242
+ * … {vk} literals[k]`, each variable one or more characters other than `/`, `?` and `#`. The
243
+ * values are the ones a greedy regex would capture (each variable as long as the rest still
244
+ * matches), found in time linear in the URI: a regex with two variables between a literal backtracks
245
+ * quadratically (`ticket://{project}-{number}` and 32 KB of `a-`: 0.3 s; three variables: cubic),
246
+ * and the URI is client input of up to `bodyLimitBytes`.
247
+ */
248
+ export function matchTemplate(literals, uri) {
249
+ const count = literals.length - 1;
250
+ const first = literals[0];
251
+ const last = literals[count];
252
+ const size = uri.length;
253
+ if (!uri.startsWith(first) || !uri.endsWith(last) || size < first.length + last.length + count)
254
+ return undefined;
255
+ // stop[p]: the first delimiter at or after p (a variable that starts at p ends at most there).
256
+ const stop = new Int32Array(size + 1);
257
+ stop[size] = size;
258
+ for (let index = size - 1; index >= 0; index--)
259
+ stop[index] = DELIMITER.has(uri[index]) ? index : stop[index + 1];
260
+ // latest[j][x]: the largest q <= x where variable j may end so that the rest matches (-1: none).
261
+ const latest = new Array(count + 1);
262
+ const fits = (variable, start) => start < size && latest[variable][stop[start]] > start;
263
+ for (let variable = count; variable >= 1; variable--) {
264
+ const after = literals[variable];
265
+ const at = variable === count ? undefined : occurrences(uri, after);
266
+ const table = new Int32Array(size + 1);
267
+ let best = -1;
268
+ for (let end = 0; end <= size; end++) {
269
+ const ok = at === undefined
270
+ ? end === size - after.length
271
+ : at[end] === 1 && fits(variable + 1, end + after.length);
272
+ if (ok)
273
+ best = end;
274
+ table[end] = best;
275
+ }
276
+ latest[variable] = table;
277
+ }
278
+ if (!fits(1, first.length))
279
+ return undefined;
280
+ const values = [];
281
+ let start = first.length;
282
+ for (let variable = 1; variable <= count; variable++) {
283
+ const end = latest[variable][stop[start]];
284
+ values.push(uri.slice(start, end));
285
+ start = end + literals[variable].length;
286
+ }
287
+ return values;
288
+ }
289
+ export function compileResources(contributed, scopes) {
290
+ const problems = [];
291
+ const result = [];
292
+ const names = new Map();
293
+ const uris = new Set();
294
+ for (const { plugin, value } of contributed) {
295
+ const before = problems.length;
296
+ const label = `resource of ${plugin}`;
297
+ if (kindOf(value) !== 'resource') {
298
+ problems.push(`${label} is not an instance of a McpResource subclass`);
299
+ continue;
300
+ }
301
+ const resource = value;
302
+ const name = member(resource, 'name', problems, label);
303
+ const where = typeof name === 'string' ? `resource ${name}` : label;
304
+ if (typeof name !== 'string' || !TOOL_NAME.test(name))
305
+ problems.push(`${where}: name must be 1-128 letters, digits, _, - or .`);
306
+ else if (names.has(name))
307
+ problems.push(`${where} of ${plugin}: the name is used by another resource (of ${names.get(name)}); resource names are unique across every plugin and module`);
308
+ else
309
+ names.set(name, plugin);
310
+ const description = describe(member(resource, 'description', problems, where), where, problems);
311
+ const shown = title(member(resource, 'title', problems, where), where, problems);
312
+ const scope = scopeOf(member(resource, 'scope', problems, where), where, scopes, problems);
313
+ if (typeof member(resource, 'read', problems, where) !== 'function')
314
+ problems.push(`${where}.read must be a method`);
315
+ const uri = member(resource, 'uri', problems, where);
316
+ const template = member(resource, 'template', problems, where);
317
+ const mimeType = member(resource, 'mimeType', problems, where);
318
+ if ((uri === undefined) === (template === undefined))
319
+ problems.push(`${where} needs exactly one of uri and template`);
320
+ let literals;
321
+ const variables = [];
322
+ for (const [key, text] of [
323
+ ['uri', uri],
324
+ ['template', template],
325
+ ]) {
326
+ if (text === undefined)
327
+ continue;
328
+ if (typeof text !== 'string' || !ABSOLUTE_URI.test(text) || text.length > 2_048) {
329
+ problems.push(`${where}.${key} must be an absolute URI (scheme:…) of at most 2048 characters`);
330
+ continue;
331
+ }
332
+ if (uris.has(text))
333
+ problems.push(`${where}.${key} ${text} is used by another resource`);
334
+ uris.add(text);
335
+ if (key === 'uri' && text.includes('{'))
336
+ problems.push(`${where}.uri has a {variable}: use template`);
337
+ if (key === 'template') {
338
+ const around = [];
339
+ let last = 0;
340
+ for (const match of text.matchAll(VARIABLE)) {
341
+ around.push(text.slice(last, match.index));
342
+ variables.push(match[1]);
343
+ last = match.index + match[0].length;
344
+ }
345
+ around.push(text.slice(last));
346
+ if (variables.length === 0)
347
+ problems.push(`${where}.template has no {variable}: use uri`);
348
+ if (/[{}]/.test(text.replace(VARIABLE, '')))
349
+ problems.push(`${where}.template has a brace that is not a {name} variable`);
350
+ if (new Set(variables).size !== variables.length)
351
+ problems.push(`${where}.template repeats a variable`);
352
+ literals = around;
353
+ }
354
+ }
355
+ if (mimeType !== undefined &&
356
+ (typeof mimeType !== 'string' || !/^[\w.+-]+\/[\w.+-]+/.test(mimeType)))
357
+ problems.push(`${where}.mimeType must be a media type (text/markdown)`);
358
+ if (problems.length > before)
359
+ continue;
360
+ result.push(Object.freeze({
361
+ resource,
362
+ name: name,
363
+ scope: scope[0],
364
+ scopes: scope,
365
+ uri: uri,
366
+ template: template,
367
+ mimeType: mimeType,
368
+ literals: literals === undefined ? undefined : Object.freeze(literals),
369
+ variables: Object.freeze(variables),
370
+ plugin,
371
+ listing: Object.freeze({
372
+ ...(uri === undefined ? { uriTemplate: template } : { uri }),
373
+ name,
374
+ ...(shown === undefined ? {} : { title: shown }),
375
+ description,
376
+ ...(mimeType === undefined ? {} : { mimeType }),
377
+ }),
378
+ }));
379
+ }
380
+ if (problems.length)
381
+ throw fail('INVALID_RESOURCE', 'resource', problems);
382
+ return result;
383
+ }
384
+ /** The resource that serves `uri` and its template values (percent-decoded), or undefined. */
385
+ export function matchResource(resources, uri) {
386
+ for (const resource of resources) {
387
+ if (resource.uri !== undefined) {
388
+ if (resource.uri === uri)
389
+ return { resource, params: Object.freeze({}) };
390
+ continue;
391
+ }
392
+ const match = resource.literals && matchTemplate(resource.literals, uri);
393
+ if (!match)
394
+ continue;
395
+ const params = {};
396
+ let decoded = true;
397
+ resource.variables.forEach((name, index) => {
398
+ const raw = match[index];
399
+ try {
400
+ params[name] = decodeURIComponent(raw);
401
+ }
402
+ catch (error) {
403
+ // A broken escape is a URI no template serves (the caller answers "not found").
404
+ if (!(error instanceof URIError))
405
+ throw error;
406
+ decoded = false;
407
+ }
408
+ });
409
+ if (decoded)
410
+ return { resource, params: Object.freeze(params) };
411
+ }
412
+ return undefined;
413
+ }
@@ -0,0 +1,96 @@
1
+ import { type Awaitable, type DokebiErrorLike, type Logger } from '@dokebi/core';
2
+ import type { WebErrorTranslator } from '@dokebi/web/points';
3
+ import type { FastifyRequest } from 'fastify';
4
+ import { type McpEnvelope } from './envelope.js';
5
+ import type { McpCallEntry, McpCallMethod, McpHookContext, McpHooks } from './extension.js';
6
+ import type { CompiledPrompt, CompiledResource, CompiledTool } from './registry.js';
7
+ import type { McpGrant, McpImageLimits } from './tool.js';
8
+ /** What `consume` of @dokebi/rate-limit answers (by shape; every field since 0.1.0). */
9
+ export interface LimiterDecision {
10
+ readonly allowed: boolean;
11
+ readonly limit: number;
12
+ readonly remaining: number;
13
+ readonly retryAfterMs: number;
14
+ readonly resetAfterMs: number;
15
+ }
16
+ /** The part of @dokebi/rate-limit this file uses (by shape). */
17
+ export interface Limiter {
18
+ consume(key: string): Awaitable<LimiterDecision>;
19
+ }
20
+ export interface RunnerSettings {
21
+ readonly logger: Logger;
22
+ readonly timeoutMs: number;
23
+ readonly resultMaxChars: number;
24
+ /** The picture limits of a tool result (D-0143). */
25
+ readonly images: McpImageLimits;
26
+ /** Limit bucket → limiter (empty: no limit). */
27
+ readonly limiters: ReadonlyMap<string, Limiter>;
28
+ readonly hooks: McpHooks | undefined;
29
+ readonly overridden: ReadonlySet<string>;
30
+ readonly hookContext: Pick<McpHookContext, 'app' | 'getDependency' | 'logger'>;
31
+ readonly translators: readonly {
32
+ readonly plugin: string;
33
+ readonly value: WebErrorTranslator;
34
+ }[];
35
+ /** Aborted when the app stops. */
36
+ readonly appSignal: AbortSignal | undefined;
37
+ /** Window of the repeated failure logs (`logRepeatWindowMs`; absent: 10000, D-0114). */
38
+ readonly logRepeatWindowMs?: number;
39
+ }
40
+ /** Who calls and how: the grant, the HTTP request, the protocol version, the client's signal. */
41
+ export interface CallSettings {
42
+ readonly grant: McpGrant;
43
+ readonly request: FastifyRequest | undefined;
44
+ readonly version: string;
45
+ /** Aborted when the client closes the request. */
46
+ readonly signal: AbortSignal | undefined;
47
+ /**
48
+ * Takes the entry of each call this request ends (D-0134): set when the extension has
49
+ * `onCall`; the route hands them to the hook after its response.
50
+ */
51
+ readonly record?: (entry: McpCallEntry) => void;
52
+ }
53
+ /** A tool call's wire result (`CallToolResult`) and its result code for the log. */
54
+ export interface ToolOutcome {
55
+ readonly result: Record<string, unknown>;
56
+ readonly code: string;
57
+ }
58
+ /** A prompt or resource answer: a result, or a JSON-RPC error. */
59
+ export type RpcOutcome = {
60
+ readonly kind: 'result';
61
+ readonly result: Record<string, unknown>;
62
+ readonly code: 'ok';
63
+ } | {
64
+ readonly kind: 'error';
65
+ readonly error: number;
66
+ readonly message: string;
67
+ readonly data: Record<string, unknown>;
68
+ readonly code: string;
69
+ };
70
+ /** The body of a tool error (D-0074 shape plus `next`): code, message, hint, next, data. */
71
+ export declare function errorBody(error: DokebiErrorLike): Record<string, unknown>;
72
+ /** A tool result with `isError`: the error object as structuredContent and as JSON text. */
73
+ export declare function toolError(body: Record<string, unknown>): Record<string, unknown>;
74
+ export declare class McpRunner {
75
+ #private;
76
+ constructor(settings: RunnerSettings);
77
+ /**
78
+ * D-0154: reloaded limits of the next call (the tool deadline, the result size, the picture
79
+ * limits); a call in progress keeps the ones it started with.
80
+ */
81
+ update(next: Pick<RunnerSettings, 'timeoutMs' | 'resultMaxChars' | 'images'>): void;
82
+ /** Writes the counted repeats of error logs (at close). */
83
+ flush(): void;
84
+ /**
85
+ * The end of one call: its MCP_CALL info line and, when the request records them, its frozen
86
+ * `onCall` entry with the same values (D-0134). The route's own refusals (the scope, an unknown
87
+ * name) come here too, so the log and the hook see the same calls.
88
+ */
89
+ report(call: Pick<CallSettings, 'grant' | 'record'>, method: McpCallMethod, name: string, ms: number, bytes: number, code: string, envelope?: McpEnvelope): void;
90
+ /** One `tools/call`, after the scope check. */
91
+ callTool(compiled: CompiledTool, args: unknown, call: CallSettings): Promise<ToolOutcome>;
92
+ /** One `prompts/get` (arguments already checked against the declaration). */
93
+ getPrompt(compiled: CompiledPrompt, args: Readonly<Record<string, string>>, call: CallSettings): Promise<RpcOutcome>;
94
+ /** One `resources/read` of a matched resource. */
95
+ readResource(compiled: CompiledResource, params: Readonly<Record<string, string>>, uri: string, call: CallSettings): Promise<RpcOutcome>;
96
+ }