@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/AI.md +386 -0
- package/LICENSE +21 -0
- package/README.md +46 -2
- package/dist/calls.d.ts +36 -0
- package/dist/calls.js +103 -0
- package/dist/dependency.d.ts +4 -0
- package/dist/dependency.js +14 -0
- package/dist/envelope.d.ts +124 -0
- package/dist/envelope.js +299 -0
- package/dist/era.d.ts +17 -0
- package/dist/era.js +84 -0
- package/dist/errors.d.ts +57 -0
- package/dist/errors.js +10 -0
- package/dist/extension.d.ts +139 -0
- package/dist/extension.js +75 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +23 -0
- package/dist/install.d.ts +29 -0
- package/dist/install.js +35 -0
- package/dist/jsonrpc.d.ts +29 -0
- package/dist/jsonrpc.js +64 -0
- package/dist/options.d.ts +50 -0
- package/dist/options.js +161 -0
- package/dist/plugin.d.ts +32 -0
- package/dist/plugin.js +541 -0
- package/dist/protocol.d.ts +51 -0
- package/dist/protocol.js +67 -0
- package/dist/registry.d.ts +70 -0
- package/dist/registry.js +413 -0
- package/dist/runner.d.ts +96 -0
- package/dist/runner.js +740 -0
- package/dist/schema.d.ts +43 -0
- package/dist/schema.js +482 -0
- package/dist/server.d.ts +43 -0
- package/dist/server.js +349 -0
- package/dist/testing.d.ts +168 -0
- package/dist/testing.js +426 -0
- package/dist/tool.d.ts +176 -0
- package/dist/tool.js +66 -0
- package/options.schema.json +185 -0
- package/package.json +120 -4
- package/templates/extension.ts +30 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { performance } from 'node:perf_hooks';
|
|
2
|
+
import { diagnostics } from '@dokebi/core/libraries';
|
|
3
|
+
import { classify, headerOf, negotiate, unsupported } from './era.js';
|
|
4
|
+
import { isPlainObject, parseBody, readMessage, rpcError, rpcResult, } from './jsonrpc.js';
|
|
5
|
+
import { URI_MAX_CHARS } from './options.js';
|
|
6
|
+
import { BATCH_VERSION, HEADER_PROTOCOL_VERSION, IMPLICIT_VERSION, INSUFFICIENT_SCOPE, INVALID_PARAMS, INVALID_REQUEST, LIST_CACHE_SCOPE, LIST_TTL_MS, MAX_BATCH, META_SERVER_INFO, METHOD_NOT_FOUND, RESOURCE_NOT_FOUND, SUPPORTED_VERSIONS, } from './protocol.js';
|
|
7
|
+
import { matchResource, } from './registry.js';
|
|
8
|
+
const answer = (status, body) => ({ status, body });
|
|
9
|
+
export class McpServer {
|
|
10
|
+
#s;
|
|
11
|
+
#tools;
|
|
12
|
+
#prompts;
|
|
13
|
+
capabilities;
|
|
14
|
+
/** D-0154: the reloaded batch size, list cache time and URI limit, for the next request. */
|
|
15
|
+
update(next) {
|
|
16
|
+
this.#s = { ...this.#s, ...next };
|
|
17
|
+
}
|
|
18
|
+
constructor(settings) {
|
|
19
|
+
this.#s = settings;
|
|
20
|
+
this.#tools = new Map(settings.tools.map((tool) => [tool.name, tool]));
|
|
21
|
+
this.#prompts = new Map(settings.prompts.map((prompt) => [prompt.name, prompt]));
|
|
22
|
+
// listChanged: false — a stateless server has no stream to announce changes on (§1.12).
|
|
23
|
+
this.capabilities = Object.freeze({
|
|
24
|
+
...(settings.tools.length ? { tools: { listChanged: false } } : {}),
|
|
25
|
+
...(settings.prompts.length ? { prompts: { listChanged: false } } : {}),
|
|
26
|
+
...(settings.resources.length ? { resources: { listChanged: false, subscribe: false } } : {}),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/** One POST: the body bytes, the headers and who calls. */
|
|
30
|
+
async handle(raw, headers, call) {
|
|
31
|
+
const parsed = parseBody(raw);
|
|
32
|
+
if ('kind' in parsed)
|
|
33
|
+
return this.#refused(parsed.status, parsed.body, 'parse');
|
|
34
|
+
if (Array.isArray(parsed.value))
|
|
35
|
+
return this.#batch(parsed.value, headers, call);
|
|
36
|
+
const message = readMessage(parsed.value);
|
|
37
|
+
if (message.kind === 'refusal')
|
|
38
|
+
return this.#refused(message.status, message.body, 'message');
|
|
39
|
+
const version = classify(message, headers);
|
|
40
|
+
if (version.kind === 'refusal')
|
|
41
|
+
return this.#refused(version.status, version.body, message.method);
|
|
42
|
+
if (message.kind === 'notification') {
|
|
43
|
+
// Nothing to do: no session to initialize, no in-flight request of this process to cancel.
|
|
44
|
+
diagnostics.logDebug(this.#s.logger, 'MCP notification', () => ({
|
|
45
|
+
method: message.method,
|
|
46
|
+
version: version.version,
|
|
47
|
+
}));
|
|
48
|
+
return { status: 202, body: undefined };
|
|
49
|
+
}
|
|
50
|
+
const denied = this.#scope([message], call);
|
|
51
|
+
if (denied)
|
|
52
|
+
return denied;
|
|
53
|
+
const started = performance.now();
|
|
54
|
+
const result = await this.#dispatch(message, version.era, version.version, call);
|
|
55
|
+
if (message.method !== 'tools/call')
|
|
56
|
+
diagnostics.logDebug(this.#s.logger, 'MCP request', () => ({
|
|
57
|
+
method: message.method,
|
|
58
|
+
version: version.version,
|
|
59
|
+
status: result.status,
|
|
60
|
+
ms: Math.round(performance.now() - started),
|
|
61
|
+
}));
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
#refused(status, body, method) {
|
|
65
|
+
diagnostics.logDebug(this.#s.logger, 'MCP request refused', () => ({
|
|
66
|
+
method,
|
|
67
|
+
status,
|
|
68
|
+
error: body.error?.code,
|
|
69
|
+
}));
|
|
70
|
+
return { status, body };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The scope a request needs and the name of its target, by its method and target (undefined:
|
|
74
|
+
* none beyond the grant). The name is the registered one (a resource's, never the URI).
|
|
75
|
+
*/
|
|
76
|
+
#needed(message) {
|
|
77
|
+
const params = message.params;
|
|
78
|
+
if (message.method === 'tools/call' && typeof params.name === 'string')
|
|
79
|
+
return this.#tools.get(params.name);
|
|
80
|
+
if (message.method === 'prompts/get' && typeof params.name === 'string')
|
|
81
|
+
return this.#prompts.get(params.name);
|
|
82
|
+
if (message.method === 'resources/read' && typeof params.uri === 'string')
|
|
83
|
+
return this.#fits(params.uri)
|
|
84
|
+
? matchResource(this.#s.resources, params.uri)?.resource
|
|
85
|
+
: undefined;
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Whether a resource URI is short enough to be matched (`uriMaxChars`): matching is a pass over
|
|
90
|
+
* the URI per template, and the URI is client input of up to the body limit. A longer one
|
|
91
|
+
* matches nothing here and is refused by the dispatch.
|
|
92
|
+
*/
|
|
93
|
+
#fits(uri) {
|
|
94
|
+
return uri.length <= (this.#s.uriMaxChars ?? URI_MAX_CHARS);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* 403 insufficient_scope before anything runs (D-0094 §1.4), with every needed scope and the
|
|
98
|
+
* granted ones in the challenge, so a client's step-up keeps what already works. A tool whose
|
|
99
|
+
* scope is a list passes with any one of them; refused, the client is asked for the first.
|
|
100
|
+
*/
|
|
101
|
+
#scope(messages, call) {
|
|
102
|
+
const missing = new Set();
|
|
103
|
+
/** Each refused target with the scopes of which any one would do (R18: never mixed). */
|
|
104
|
+
const targets = [];
|
|
105
|
+
let first;
|
|
106
|
+
let firstName = '';
|
|
107
|
+
for (const message of messages) {
|
|
108
|
+
if (message.kind !== 'request')
|
|
109
|
+
continue;
|
|
110
|
+
const needed = this.#needed(message);
|
|
111
|
+
if (needed !== undefined && !holdsAny(call.grant.scopes, needed.scopes)) {
|
|
112
|
+
missing.add(needed.scope);
|
|
113
|
+
targets.push({ name: needed.name, anyOf: needed.scopes });
|
|
114
|
+
if (!first) {
|
|
115
|
+
first = message;
|
|
116
|
+
firstName = needed.name;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!first)
|
|
121
|
+
return undefined;
|
|
122
|
+
const scopes = [...new Set([...call.grant.scopes, ...missing])];
|
|
123
|
+
// One MCP_CALL line (and one onCall entry) for the refused request, or a batch's first.
|
|
124
|
+
this.#s.runner.report(call, first.method, firstName, 0, 0, 'INSUFFICIENT_SCOPE');
|
|
125
|
+
return {
|
|
126
|
+
status: 403,
|
|
127
|
+
challenge: `Bearer error="insufficient_scope", scope="${scopes.join(' ')}", resource_metadata="${this.#s.metadataUrl}"`,
|
|
128
|
+
body: rpcError(messages.length === 1 ? first.id : null, INSUFFICIENT_SCOPE, `This connection lacks a scope for ${targets
|
|
129
|
+
.map((target) => `${target.name} (${target.anyOf.join(' or ')})`)
|
|
130
|
+
.join(', ')}`, { code: 'INSUFFICIENT_SCOPE', scopes: [...missing], targets }),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/** A JSON-RPC batch: only for 2025-03-26 (later versions removed batches). */
|
|
134
|
+
async #batch(values, headers, call) {
|
|
135
|
+
const version = headerOf(headers, HEADER_PROTOCOL_VERSION) ?? IMPLICIT_VERSION;
|
|
136
|
+
if (!SUPPORTED_VERSIONS.includes(version) && version !== IMPLICIT_VERSION) {
|
|
137
|
+
const refused = unsupported(null, version);
|
|
138
|
+
return this.#refused(refused.status, refused.body, 'batch');
|
|
139
|
+
}
|
|
140
|
+
if (version !== BATCH_VERSION)
|
|
141
|
+
return this.#refused(400, rpcError(null, INVALID_REQUEST, `MCP ${version} has no JSON-RPC batches: send one message per POST`), 'batch');
|
|
142
|
+
if (values.length === 0)
|
|
143
|
+
return this.#refused(400, rpcError(null, INVALID_REQUEST, 'The batch is empty'), 'batch');
|
|
144
|
+
const maxBatch = this.#s.maxBatch ?? MAX_BATCH;
|
|
145
|
+
if (values.length > maxBatch)
|
|
146
|
+
return this.#refused(400, rpcError(null, INVALID_REQUEST, `A batch holds at most ${maxBatch} messages`), 'batch');
|
|
147
|
+
const messages = values.map(readMessage);
|
|
148
|
+
const valid = messages.filter((one) => one.kind !== 'refusal');
|
|
149
|
+
const denied = this.#scope(valid, call);
|
|
150
|
+
if (denied)
|
|
151
|
+
return denied;
|
|
152
|
+
const responses = [];
|
|
153
|
+
// One after the other: writes keep their order, and one batch cannot occupy every core.
|
|
154
|
+
for (const message of messages) {
|
|
155
|
+
// The client left (or the app stops): nothing more of the batch runs.
|
|
156
|
+
if (call.signal?.aborted) {
|
|
157
|
+
diagnostics.logDebug(this.#s.logger, 'MCP batch stopped', () => ({
|
|
158
|
+
answered: responses.length,
|
|
159
|
+
of: messages.length,
|
|
160
|
+
}));
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
if (message.kind === 'refusal') {
|
|
164
|
+
responses.push(message.body);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const classified = classify(message, headers);
|
|
168
|
+
if (classified.kind === 'refusal') {
|
|
169
|
+
if (message.kind === 'request')
|
|
170
|
+
responses.push(classified.body);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (message.kind === 'notification')
|
|
174
|
+
continue;
|
|
175
|
+
if (classified.era === 'modern' || message.method === 'initialize') {
|
|
176
|
+
responses.push(rpcError(message.id, INVALID_REQUEST, classified.era === 'modern'
|
|
177
|
+
? 'A batch carries 2025-03-26 messages only'
|
|
178
|
+
: 'initialize cannot be part of a batch'));
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
responses.push((await this.#dispatch(message, 'legacy', classified.version, call)).body);
|
|
182
|
+
}
|
|
183
|
+
if (responses.length === 0)
|
|
184
|
+
return { status: 202, body: undefined };
|
|
185
|
+
return { status: 200, body: responses };
|
|
186
|
+
}
|
|
187
|
+
#serverInfo() {
|
|
188
|
+
return { ...this.#s.serverInfo };
|
|
189
|
+
}
|
|
190
|
+
/** A result in the shape of its era (modern: resultType, the cache hint, _meta). */
|
|
191
|
+
#shape(era, body, cacheable = false) {
|
|
192
|
+
if (era === 'legacy')
|
|
193
|
+
return body;
|
|
194
|
+
return {
|
|
195
|
+
resultType: 'complete',
|
|
196
|
+
...body,
|
|
197
|
+
...(cacheable
|
|
198
|
+
? { ttlMs: this.#s.listTtlMs ?? LIST_TTL_MS, cacheScope: LIST_CACHE_SCOPE }
|
|
199
|
+
: {}),
|
|
200
|
+
_meta: { [META_SERVER_INFO]: this.#serverInfo() },
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
#result(id, era, body, cacheable = false) {
|
|
204
|
+
return answer(200, rpcResult(id, this.#shape(era, body, cacheable)));
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* An unknown method: 200 with -32601 for the 2025 versions (JSON-RPC over HTTP answers 200), 404
|
|
208
|
+
* for 2026-07-28 (its transport answers an unknown method with 404).
|
|
209
|
+
*/
|
|
210
|
+
#notFound(id, method, era) {
|
|
211
|
+
return answer(era === 'modern' ? 404 : 200, rpcError(id, METHOD_NOT_FOUND, `Unknown method ${method}`));
|
|
212
|
+
}
|
|
213
|
+
#invalid(id, message, data) {
|
|
214
|
+
return answer(200, rpcError(id, INVALID_PARAMS, message, data));
|
|
215
|
+
}
|
|
216
|
+
#rpc(id, era, outcome) {
|
|
217
|
+
if (outcome.kind === 'result')
|
|
218
|
+
return this.#result(id, era, outcome.result);
|
|
219
|
+
return answer(200, rpcError(id, outcome.error, outcome.message, outcome.data));
|
|
220
|
+
}
|
|
221
|
+
async #dispatch(message, era, version, call) {
|
|
222
|
+
const { id, method, params } = message;
|
|
223
|
+
const visible = (scopes) => holdsAny(call.grant.scopes, scopes);
|
|
224
|
+
if (era === 'legacy' && method === 'initialize')
|
|
225
|
+
return this.#result(id, era, {
|
|
226
|
+
protocolVersion: negotiate(params.protocolVersion),
|
|
227
|
+
capabilities: this.capabilities,
|
|
228
|
+
serverInfo: this.#serverInfo(),
|
|
229
|
+
...(this.#s.instructions === undefined ? {} : { instructions: this.#s.instructions }),
|
|
230
|
+
});
|
|
231
|
+
if (era === 'modern' && method === 'server/discover')
|
|
232
|
+
return this.#result(id, era, {
|
|
233
|
+
supportedVersions: [...SUPPORTED_VERSIONS],
|
|
234
|
+
capabilities: this.capabilities,
|
|
235
|
+
serverInfo: this.#serverInfo(),
|
|
236
|
+
...(this.#s.instructions === undefined ? {} : { instructions: this.#s.instructions }),
|
|
237
|
+
}, true);
|
|
238
|
+
const full = { ...call, version };
|
|
239
|
+
const started = performance.now();
|
|
240
|
+
/** A call the route answers itself (an unknown name, a missing one): its MCP_CALL line. */
|
|
241
|
+
const refused = (code, reply, name = '') => {
|
|
242
|
+
this.#s.runner.report(call, method, name, Math.round(performance.now() - started), 0, code);
|
|
243
|
+
return reply;
|
|
244
|
+
};
|
|
245
|
+
switch (method) {
|
|
246
|
+
case 'ping':
|
|
247
|
+
return this.#result(id, era, {});
|
|
248
|
+
case 'tools/list':
|
|
249
|
+
if (!this.#s.tools.length)
|
|
250
|
+
return this.#notFound(id, method, era);
|
|
251
|
+
return this.#result(id, era, {
|
|
252
|
+
tools: this.#s.tools.filter((tool) => visible(tool.scopes)).map((tool) => tool.listing),
|
|
253
|
+
}, true);
|
|
254
|
+
case 'tools/call': {
|
|
255
|
+
if (!this.#s.tools.length)
|
|
256
|
+
return refused('NOT_FOUND', this.#notFound(id, method, era));
|
|
257
|
+
if (typeof params.name !== 'string')
|
|
258
|
+
return refused('INVALID_INPUT', this.#invalid(id, 'tools/call needs params.name'));
|
|
259
|
+
const tool = this.#tools.get(params.name);
|
|
260
|
+
if (!tool)
|
|
261
|
+
return refused('NOT_FOUND', this.#invalid(id, `Unknown tool ${params.name}`, { name: params.name }));
|
|
262
|
+
const outcome = await this.#s.runner.callTool(tool, params.arguments, full);
|
|
263
|
+
return this.#result(id, era, outcome.result);
|
|
264
|
+
}
|
|
265
|
+
case 'prompts/list':
|
|
266
|
+
if (!this.#s.prompts.length)
|
|
267
|
+
return this.#notFound(id, method, era);
|
|
268
|
+
return this.#result(id, era, {
|
|
269
|
+
prompts: this.#s.prompts
|
|
270
|
+
.filter((prompt) => visible(prompt.scopes))
|
|
271
|
+
.map((prompt) => prompt.listing),
|
|
272
|
+
}, true);
|
|
273
|
+
case 'prompts/get': {
|
|
274
|
+
if (!this.#s.prompts.length)
|
|
275
|
+
return refused('NOT_FOUND', this.#notFound(id, method, era));
|
|
276
|
+
if (typeof params.name !== 'string')
|
|
277
|
+
return refused('INVALID_INPUT', this.#invalid(id, 'prompts/get needs params.name'));
|
|
278
|
+
const prompt = this.#prompts.get(params.name);
|
|
279
|
+
if (!prompt)
|
|
280
|
+
return refused('NOT_FOUND', this.#invalid(id, `Unknown prompt ${params.name}`, { name: params.name }));
|
|
281
|
+
const args = params.arguments ?? {};
|
|
282
|
+
const problems = [];
|
|
283
|
+
if (!isPlainObject(args))
|
|
284
|
+
problems.push({ path: 'arguments', message: 'must be an object of strings' });
|
|
285
|
+
else {
|
|
286
|
+
const declared = new Set(prompt.arguments.map((one) => one.name));
|
|
287
|
+
for (const [name, value] of Object.entries(args)) {
|
|
288
|
+
if (!declared.has(name))
|
|
289
|
+
problems.push({
|
|
290
|
+
path: `arguments.${name}`,
|
|
291
|
+
message: 'is not an argument of this prompt',
|
|
292
|
+
});
|
|
293
|
+
else if (typeof value !== 'string')
|
|
294
|
+
problems.push({ path: `arguments.${name}`, message: 'must be a string' });
|
|
295
|
+
}
|
|
296
|
+
for (const one of prompt.arguments)
|
|
297
|
+
if (one.required && !Object.hasOwn(args, one.name))
|
|
298
|
+
problems.push({ path: `arguments.${one.name}`, message: 'is required' });
|
|
299
|
+
}
|
|
300
|
+
if (problems.length)
|
|
301
|
+
return refused('INVALID_INPUT', this.#invalid(id, `The arguments do not match the prompt ${prompt.name}`, {
|
|
302
|
+
code: 'INVALID_INPUT',
|
|
303
|
+
problems,
|
|
304
|
+
}), prompt.name);
|
|
305
|
+
return this.#rpc(id, era, await this.#s.runner.getPrompt(prompt, args, full));
|
|
306
|
+
}
|
|
307
|
+
case 'resources/list':
|
|
308
|
+
if (!this.#s.resources.length)
|
|
309
|
+
return this.#notFound(id, method, era);
|
|
310
|
+
return this.#result(id, era, {
|
|
311
|
+
resources: this.#s.resources
|
|
312
|
+
.filter((one) => one.uri !== undefined && visible(one.scopes))
|
|
313
|
+
.map((one) => one.listing),
|
|
314
|
+
}, true);
|
|
315
|
+
case 'resources/templates/list':
|
|
316
|
+
if (!this.#s.resources.length)
|
|
317
|
+
return this.#notFound(id, method, era);
|
|
318
|
+
return this.#result(id, era, {
|
|
319
|
+
resourceTemplates: this.#s.resources
|
|
320
|
+
.filter((one) => one.template !== undefined && visible(one.scopes))
|
|
321
|
+
.map((one) => one.listing),
|
|
322
|
+
}, true);
|
|
323
|
+
case 'resources/read': {
|
|
324
|
+
if (!this.#s.resources.length)
|
|
325
|
+
return refused('NOT_FOUND', this.#notFound(id, method, era));
|
|
326
|
+
if (typeof params.uri !== 'string')
|
|
327
|
+
return refused('INVALID_INPUT', this.#invalid(id, 'resources/read needs params.uri'));
|
|
328
|
+
if (!this.#fits(params.uri)) {
|
|
329
|
+
// Refused before any template sees it, and never echoed back.
|
|
330
|
+
const max = this.#s.uriMaxChars ?? URI_MAX_CHARS;
|
|
331
|
+
return refused('INVALID_INPUT', this.#invalid(id, `The resource URI is longer than ${max} characters`, {
|
|
332
|
+
code: 'INVALID_INPUT',
|
|
333
|
+
problems: [{ path: 'uri', message: `must be at most ${max} characters` }],
|
|
334
|
+
}));
|
|
335
|
+
}
|
|
336
|
+
const found = matchResource(this.#s.resources, params.uri);
|
|
337
|
+
if (!found)
|
|
338
|
+
return refused('NOT_FOUND', answer(200, rpcError(id, RESOURCE_NOT_FOUND, 'There is no such resource', { uri: params.uri })));
|
|
339
|
+
return this.#rpc(id, era, await this.#s.runner.readResource(found.resource, found.params, params.uri, full));
|
|
340
|
+
}
|
|
341
|
+
default:
|
|
342
|
+
return this.#notFound(id, method, era);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/** Whether the grant holds any one of `scopes` (a tool's scope list: any one is enough). */
|
|
347
|
+
function holdsAny(granted, scopes) {
|
|
348
|
+
return scopes.some((scope) => granted.includes(scope));
|
|
349
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { type Logger } from '@dokebi/core';
|
|
2
|
+
import type { FastifyRequest } from 'fastify';
|
|
3
|
+
import { McpExtension } from './extension.js';
|
|
4
|
+
import { type Limiter } from './runner.js';
|
|
5
|
+
import type { McpGrant, McpTool } from './tool.js';
|
|
6
|
+
/** What `callTool` takes besides the tool and its input. */
|
|
7
|
+
export interface CallToolSettings {
|
|
8
|
+
/** The grant (default id `test-grant`, user `1`, client `test-client`, the tool's scope). */
|
|
9
|
+
readonly grant?: Partial<McpGrant>;
|
|
10
|
+
readonly logger?: Logger;
|
|
11
|
+
/**
|
|
12
|
+
* An extension class whose checkGrant and transformResult run as on the route; its onCall gets
|
|
13
|
+
* the call's entry one setImmediate after the result (D-0134).
|
|
14
|
+
*/
|
|
15
|
+
readonly extension?: typeof McpExtension;
|
|
16
|
+
/** Providers the extension's hooks read through `context.getDependency`, by name. */
|
|
17
|
+
readonly dependencies?: Readonly<Record<string, unknown>>;
|
|
18
|
+
readonly timeoutMs?: number;
|
|
19
|
+
readonly resultMaxChars?: number;
|
|
20
|
+
/** Largest picture in bytes (default 1048576, as `imageMaxBytes`; D-0143). */
|
|
21
|
+
readonly imageMaxBytes?: number;
|
|
22
|
+
/** Largest sum of the pictures of the result (default 4194304, as `imageTotalMaxBytes`). */
|
|
23
|
+
readonly imageTotalMaxBytes?: number;
|
|
24
|
+
/** Pictures of the result at most (default 8, as `maxImages`). */
|
|
25
|
+
readonly maxImages?: number;
|
|
26
|
+
/** The protocol version the tool sees (default 2025-11-25). */
|
|
27
|
+
readonly version?: string;
|
|
28
|
+
readonly signal?: AbortSignal;
|
|
29
|
+
readonly request?: FastifyRequest;
|
|
30
|
+
/**
|
|
31
|
+
* The limiter of the tool's bucket (a @dokebi/rate-limit `limiter()`, or anything with
|
|
32
|
+
* `consume`): the call takes one token of `<bucket>:<grant id>` as on the route, and the
|
|
33
|
+
* handler sees the decision as `context.rateLimit` (D-0131). Absent: no limit, `rateLimit`
|
|
34
|
+
* undefined.
|
|
35
|
+
*/
|
|
36
|
+
readonly limiter?: McpLimiter;
|
|
37
|
+
}
|
|
38
|
+
/** What `callTool` needs of a limiter: `consume` of @dokebi/rate-limit (by shape). */
|
|
39
|
+
export type McpLimiter = Limiter;
|
|
40
|
+
/** A tool result as a client receives it. */
|
|
41
|
+
export interface McpToolResult {
|
|
42
|
+
readonly isError: boolean;
|
|
43
|
+
/** `ok`, or the error code (`INVALID_INPUT`, an application code, …). */
|
|
44
|
+
readonly code: string;
|
|
45
|
+
/** The envelope, or the error object `{ code, message, hint?, next?, data? }`. */
|
|
46
|
+
readonly structuredContent: Record<string, unknown>;
|
|
47
|
+
/** `content[0].text`: the same JSON. */
|
|
48
|
+
readonly text: string;
|
|
49
|
+
/** The image blocks after the text block (D-0143): `{ type: 'image', data, mimeType }`. */
|
|
50
|
+
readonly images: readonly McpImageBlock[];
|
|
51
|
+
/** The whole `CallToolResult`. */
|
|
52
|
+
readonly result: Record<string, unknown>;
|
|
53
|
+
}
|
|
54
|
+
/** One MCP image content block of a tool result (D-0143): base64 `data` and its `mimeType`. */
|
|
55
|
+
export interface McpImageBlock {
|
|
56
|
+
readonly type: 'image';
|
|
57
|
+
readonly data: string;
|
|
58
|
+
readonly mimeType: string;
|
|
59
|
+
}
|
|
60
|
+
/** The grant `callTool` uses when none is given. */
|
|
61
|
+
export declare const TEST_GRANT: Omit<McpGrant, 'scopes'>;
|
|
62
|
+
/**
|
|
63
|
+
* Runs one tool as `tools/call` would, without HTTP or a token. A grant without the tool's scope
|
|
64
|
+
* rejects with INSUFFICIENT_SCOPE (on the route: the 403 that makes a client ask again).
|
|
65
|
+
*/
|
|
66
|
+
export declare function callTool(tool: McpTool, input: unknown, settings?: CallToolSettings): Promise<McpToolResult>;
|
|
67
|
+
/** An HTTP answer of the test helpers. */
|
|
68
|
+
export interface TestResponse {
|
|
69
|
+
readonly status: number;
|
|
70
|
+
readonly url: string;
|
|
71
|
+
readonly headers: Headers;
|
|
72
|
+
readonly text: string;
|
|
73
|
+
/** The `Location` header resolved against the request URL, when there is one. */
|
|
74
|
+
readonly location: string | undefined;
|
|
75
|
+
json<T = Record<string, unknown>>(): T;
|
|
76
|
+
}
|
|
77
|
+
/** A browser for tests: keeps cookies, never follows redirects, can rewrite hosts. */
|
|
78
|
+
export declare class TestBrowser {
|
|
79
|
+
#private;
|
|
80
|
+
readonly cookies: Map<string, string>;
|
|
81
|
+
/** `rewrite` changes a URL before it is fetched (`http://localhost:…` → `http://127.0.0.1:…`). */
|
|
82
|
+
constructor(rewrite?: (url: string) => string);
|
|
83
|
+
get(url: string): Promise<TestResponse>;
|
|
84
|
+
/** A form post (`application/x-www-form-urlencoded`; a list value repeats the name). */
|
|
85
|
+
post(url: string, form: Readonly<Record<string, string | readonly string[]>>): Promise<TestResponse>;
|
|
86
|
+
}
|
|
87
|
+
/** What `McpTestClient` takes. */
|
|
88
|
+
export interface McpTestClientSettings {
|
|
89
|
+
/** The access token (`Authorization: Bearer`); absent: no header. */
|
|
90
|
+
readonly token?: string;
|
|
91
|
+
/** The protocol version: a legacy one sends `MCP-Protocol-Version`, 2026-07-28 adds `_meta`. */
|
|
92
|
+
readonly version?: string;
|
|
93
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
94
|
+
/** Rewrites the URL before it is fetched. */
|
|
95
|
+
readonly rewrite?: (url: string) => string;
|
|
96
|
+
}
|
|
97
|
+
/** A JSON-RPC response with its HTTP status. */
|
|
98
|
+
export interface McpRpcResponse {
|
|
99
|
+
readonly status: number;
|
|
100
|
+
readonly headers: Headers;
|
|
101
|
+
/** The parsed body (an object, a batch array) or undefined for an empty body. */
|
|
102
|
+
readonly body: unknown;
|
|
103
|
+
/** `body.result` when there is one. */
|
|
104
|
+
readonly result: Record<string, unknown> | undefined;
|
|
105
|
+
/** `body.error` when there is one. */
|
|
106
|
+
readonly error: {
|
|
107
|
+
readonly code: number;
|
|
108
|
+
readonly message: string;
|
|
109
|
+
readonly data?: unknown;
|
|
110
|
+
} | undefined;
|
|
111
|
+
}
|
|
112
|
+
/** A minimal MCP client over real HTTP (JSON answers only, which is what this server sends). */
|
|
113
|
+
export declare class McpTestClient {
|
|
114
|
+
#private;
|
|
115
|
+
readonly url: string;
|
|
116
|
+
constructor(url: string, settings?: McpTestClientSettings);
|
|
117
|
+
get version(): string;
|
|
118
|
+
/** A raw POST: `body` as given (a string is sent as is), the headers of the settings added. */
|
|
119
|
+
post(body: unknown, headers?: Readonly<Record<string, string>>): Promise<McpRpcResponse>;
|
|
120
|
+
/** One request in the shape of this client's version (headers, `_meta`). */
|
|
121
|
+
request(method: string, params?: Record<string, unknown>): Promise<McpRpcResponse>;
|
|
122
|
+
initialize(): Promise<McpRpcResponse>;
|
|
123
|
+
listTools(): Promise<McpRpcResponse>;
|
|
124
|
+
callTool(name: string, args?: Record<string, unknown>): Promise<McpRpcResponse>;
|
|
125
|
+
}
|
|
126
|
+
/** What `pkceFlow` takes. */
|
|
127
|
+
export interface PkceFlowSettings {
|
|
128
|
+
/** The MCP endpoint as clients use it (`http://localhost:4000/mcp`). */
|
|
129
|
+
readonly resourceUrl: string;
|
|
130
|
+
readonly clientId: string;
|
|
131
|
+
/** A redirect URI registered for the client (a loopback one may use any port). */
|
|
132
|
+
readonly redirectUri: string;
|
|
133
|
+
/** The scopes to ask for; default the `scope` of the 401 challenge. */
|
|
134
|
+
readonly scopes?: readonly string[];
|
|
135
|
+
/** The browser (keeps the session between flows); default a new one. */
|
|
136
|
+
readonly browser?: TestBrowser;
|
|
137
|
+
/**
|
|
138
|
+
* Called when authorize sends the browser to a sign-in page: sign in through `browser` and
|
|
139
|
+
* return the URL to go back to (your login handler's redirect).
|
|
140
|
+
*/
|
|
141
|
+
readonly signIn?: (browser: TestBrowser, loginUrl: string) => Promise<string>;
|
|
142
|
+
/**
|
|
143
|
+
* The fields the consent page posts. Default: every hidden input of the page's HTML (the
|
|
144
|
+
* request id, the CSRF field) and `decision: allow`.
|
|
145
|
+
*/
|
|
146
|
+
readonly decide?: (page: TestResponse) => Readonly<Record<string, string | readonly string[]>>;
|
|
147
|
+
/** Rewrites a URL before it is fetched. */
|
|
148
|
+
readonly rewrite?: (url: string) => string;
|
|
149
|
+
}
|
|
150
|
+
/** What `pkceFlow` gives: the tokens and what the client learned on the way. */
|
|
151
|
+
export interface PkceTokens {
|
|
152
|
+
readonly accessToken: string;
|
|
153
|
+
readonly refreshToken: string;
|
|
154
|
+
readonly expiresIn: number;
|
|
155
|
+
readonly scope: string | undefined;
|
|
156
|
+
/** The resource of the protected resource metadata (what the token is for). */
|
|
157
|
+
readonly resource: string;
|
|
158
|
+
readonly issuer: string;
|
|
159
|
+
readonly metadataUrl: string;
|
|
160
|
+
readonly tokenEndpoint: string;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The hidden inputs of an HTML page (name → value): the `type`, `name` and `value` attributes
|
|
164
|
+
* themselves (never `data-type` or `data-name`), double-quoted, single-quoted or bare.
|
|
165
|
+
*/
|
|
166
|
+
export declare function hiddenInputs(html: string): Record<string, string>;
|
|
167
|
+
/** The OAuth flow of an MCP client against @dokebi/oauth, over real HTTP (see the top). */
|
|
168
|
+
export declare function pkceFlow(settings: PkceFlowSettings): Promise<PkceTokens>;
|