@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/plugin.js
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
import { performance } from 'node:perf_hooks';
|
|
2
|
+
import { Transform } from 'node:stream';
|
|
3
|
+
import { addExtensionDependencies, createExtension, definePlugin, definePoint, optionalDependency, providerDependency, readExtensionEntry, } from '@dokebi/core';
|
|
4
|
+
import { dependency as oauthDependency } from '@dokebi/oauth/dependency';
|
|
5
|
+
import { appUrlMatch, errorTranslators, pluginRouteIds } from '@dokebi/web/points';
|
|
6
|
+
import { McpCallReporter } from './calls.js';
|
|
7
|
+
import { McpError } from './errors.js';
|
|
8
|
+
import { EXTENSION_FILE, McpExtension, mcpHooks, } from './extension.js';
|
|
9
|
+
import { rpcError } from './jsonrpc.js';
|
|
10
|
+
import { options as declaration } from './options.js';
|
|
11
|
+
import { INVALID_REQUEST, METHOD_NOT_ALLOWED, SUPPORTED_VERSIONS } from './protocol.js';
|
|
12
|
+
import { compilePrompts, compileResources, compileTools } from './registry.js';
|
|
13
|
+
import { McpRunner } from './runner.js';
|
|
14
|
+
import { McpServer } from './server.js';
|
|
15
|
+
import { mcpPrompts, mcpResources, mcpTools } from './tool.js';
|
|
16
|
+
/** `core:describe` of @dokebi/core 0.4.2, declared by name so older cores keep working. */
|
|
17
|
+
const describePoint = definePoint('core:describe');
|
|
18
|
+
/** dokebi-web 0.4.3 or later: `register`, route `seal`, the error translator point. */
|
|
19
|
+
const web = providerDependency({
|
|
20
|
+
name: 'dokebi-web',
|
|
21
|
+
version: '^0.4.3',
|
|
22
|
+
packageName: '@dokebi/web',
|
|
23
|
+
load: () => import('@dokebi/web'),
|
|
24
|
+
});
|
|
25
|
+
const rateLimit = optionalDependency({
|
|
26
|
+
name: 'dokebi-rate-limit',
|
|
27
|
+
version: '^0.1.1',
|
|
28
|
+
});
|
|
29
|
+
/** How this plugin reads its entry module (core readExtensionEntry, D-0111 §7). */
|
|
30
|
+
const ENTRY = {
|
|
31
|
+
plugin: 'dokebi-mcp',
|
|
32
|
+
error: McpError,
|
|
33
|
+
file: EXTENSION_FILE,
|
|
34
|
+
base: 'McpExtension',
|
|
35
|
+
entry: 'The MCP entry',
|
|
36
|
+
noun: 'MCP',
|
|
37
|
+
};
|
|
38
|
+
/** The entry as given (optional: every hook has a default), resolved once. */
|
|
39
|
+
class McpEntryInput {
|
|
40
|
+
#extension;
|
|
41
|
+
#resolved;
|
|
42
|
+
constructor(input) {
|
|
43
|
+
this.#extension = readExtensionEntry(input, ENTRY).extension;
|
|
44
|
+
}
|
|
45
|
+
resolve() {
|
|
46
|
+
this.#resolved ??= this.#resolve();
|
|
47
|
+
return this.#resolved;
|
|
48
|
+
}
|
|
49
|
+
#resolve() {
|
|
50
|
+
const dependencies = new Map([
|
|
51
|
+
[web.name, web],
|
|
52
|
+
[oauthDependency.name, oauthDependency],
|
|
53
|
+
[rateLimit.name, rateLimit],
|
|
54
|
+
]);
|
|
55
|
+
if (this.#extension === undefined)
|
|
56
|
+
return Object.freeze({
|
|
57
|
+
hooks: undefined,
|
|
58
|
+
overridden: new Set(),
|
|
59
|
+
dependencies: Object.freeze([...dependencies.values()]),
|
|
60
|
+
});
|
|
61
|
+
const created = createExtension(this.#extension, McpExtension);
|
|
62
|
+
const overridden = new Set(created.overridden);
|
|
63
|
+
const hooks = mcpHooks(created.instance);
|
|
64
|
+
if (overridden.has('dependencies')) {
|
|
65
|
+
const declared = hooks.dependencies();
|
|
66
|
+
addExtensionDependencies(dependencies, declared, {
|
|
67
|
+
error: McpError,
|
|
68
|
+
code: 'INVALID_HOOK_RESULT',
|
|
69
|
+
file: EXTENSION_FILE,
|
|
70
|
+
data: { hook: 'dependencies' },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return Object.freeze({
|
|
74
|
+
hooks,
|
|
75
|
+
overridden,
|
|
76
|
+
dependencies: Object.freeze([...dependencies.values()]),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const JSON_TYPE = /^application\/(?:[\w.+-]+\+)?json\s*(?:;|$)/i;
|
|
81
|
+
/**
|
|
82
|
+
* An allowed origin as browsers send it (the serialized origin: lower-case host, no default port,
|
|
83
|
+
* the short IPv6 form): `https://App.example:443` matches the `Origin: https://app.example` it
|
|
84
|
+
* stands for. The options pattern admits only origins the URL parser reads.
|
|
85
|
+
*/
|
|
86
|
+
function originKey(origin) {
|
|
87
|
+
return new URL(origin).origin.toLowerCase();
|
|
88
|
+
}
|
|
89
|
+
/** Counts body bytes before Fastify reads them: the limit answers BODY_TOO_LARGE, not 400. */
|
|
90
|
+
class BodyLimit extends Transform {
|
|
91
|
+
#limit;
|
|
92
|
+
#seen = 0;
|
|
93
|
+
constructor(limit) {
|
|
94
|
+
super();
|
|
95
|
+
this.#limit = limit;
|
|
96
|
+
}
|
|
97
|
+
_transform(chunk, _encoding, done) {
|
|
98
|
+
this.#seen += chunk.length;
|
|
99
|
+
if (this.#seen > this.#limit)
|
|
100
|
+
done(tooLarge(this.#limit));
|
|
101
|
+
else
|
|
102
|
+
done(null, chunk);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function tooLarge(limit) {
|
|
106
|
+
return new McpError('BODY_TOO_LARGE', `The request body is larger than ${limit} bytes`, {
|
|
107
|
+
status: 413,
|
|
108
|
+
hint: `The request is too large: send tool arguments (attachments included) of at most ${limit} bytes, or split them over several calls.`,
|
|
109
|
+
fix: `Raise options.dokebi-mcp.bodyLimitBytes (now ${limit}) when tools must take larger arguments; it changes this route only. A proxy in front (nginx client_max_body_size) must allow it too.`,
|
|
110
|
+
data: { limit },
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
class McpRuntime {
|
|
114
|
+
#options;
|
|
115
|
+
#resolved;
|
|
116
|
+
/** What a reload changes (D-0154): set in setup. */
|
|
117
|
+
#live;
|
|
118
|
+
/**
|
|
119
|
+
* D-0154: the tool deadline, the result and picture limits, the batch size, the list cache time,
|
|
120
|
+
* the resource URI limit and the allowed origins, for the next request. The path, the resource, the route, the body
|
|
121
|
+
* limit, the rate limits and the server identity need a restart.
|
|
122
|
+
*/
|
|
123
|
+
reload({ options }) {
|
|
124
|
+
return {
|
|
125
|
+
apply: () => {
|
|
126
|
+
const live = this.#live;
|
|
127
|
+
if (!live)
|
|
128
|
+
return;
|
|
129
|
+
live.runner.update({
|
|
130
|
+
timeoutMs: options.toolTimeoutMs,
|
|
131
|
+
resultMaxChars: options.resultMaxChars,
|
|
132
|
+
images: Object.freeze({
|
|
133
|
+
imageMaxBytes: options.imageMaxBytes,
|
|
134
|
+
imageTotalMaxBytes: options.imageTotalMaxBytes,
|
|
135
|
+
maxImages: options.maxImages,
|
|
136
|
+
}),
|
|
137
|
+
});
|
|
138
|
+
live.server.update({
|
|
139
|
+
maxBatch: options.maxBatch,
|
|
140
|
+
listTtlMs: options.listTtlMs,
|
|
141
|
+
uriMaxChars: options.uriMaxChars,
|
|
142
|
+
});
|
|
143
|
+
live.origins.clear();
|
|
144
|
+
live.origins.add(live.own);
|
|
145
|
+
for (const origin of options.allowedOrigins)
|
|
146
|
+
live.origins.add(originKey(origin));
|
|
147
|
+
live.logger.log('debug', 'MCP options reloaded', {
|
|
148
|
+
code: 'MCP_RELOADED',
|
|
149
|
+
toolTimeoutMs: options.toolTimeoutMs,
|
|
150
|
+
resultMaxChars: options.resultMaxChars,
|
|
151
|
+
allowedOrigins: options.allowedOrigins.length,
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
constructor(options, resolved) {
|
|
157
|
+
this.#options = options;
|
|
158
|
+
this.#resolved = resolved;
|
|
159
|
+
}
|
|
160
|
+
#declared(dependency) {
|
|
161
|
+
const declared = this.#resolved.dependencies.find((item) => item.name === dependency?.name);
|
|
162
|
+
return declared ?? dependency;
|
|
163
|
+
}
|
|
164
|
+
/** Bucket → limiter, every tool bucket covered when limits are set. */
|
|
165
|
+
#limiters(context, buckets) {
|
|
166
|
+
const limits = this.#options.limits;
|
|
167
|
+
const result = new Map();
|
|
168
|
+
if (Object.keys(limits).length === 0)
|
|
169
|
+
return result;
|
|
170
|
+
const api = context.getOptionalDependency(rateLimit);
|
|
171
|
+
if (api === undefined)
|
|
172
|
+
throw new McpError('RATE_LIMIT_REQUIRED', 'dokebi-mcp.limits is set but @dokebi/rate-limit is not registered', {
|
|
173
|
+
fix: 'npx dokebi add @dokebi/rate-limit (with the policies), or remove options.dokebi-mcp.limits.',
|
|
174
|
+
});
|
|
175
|
+
for (const [bucket, policy] of Object.entries(limits)) {
|
|
176
|
+
if (!api.names.includes(policy))
|
|
177
|
+
throw new McpError('RATE_LIMIT_REQUIRED', `dokebi-mcp.limits.${bucket} names ${policy}, which is not a policy of @dokebi/rate-limit`, {
|
|
178
|
+
fix: `Add policies.${policy} to options.dokebi-rate-limit, or use one of: ${api.names.join(', ')}.`,
|
|
179
|
+
data: { bucket, policy },
|
|
180
|
+
});
|
|
181
|
+
result.set(bucket, api.limiter(policy));
|
|
182
|
+
}
|
|
183
|
+
for (const [user, bucket] of buckets)
|
|
184
|
+
if (!result.has(bucket))
|
|
185
|
+
throw new McpError('RATE_LIMIT_REQUIRED', `dokebi-mcp.limits has no policy for the bucket ${bucket} (${user})`, {
|
|
186
|
+
fix: `Add limits.${bucket}: <policy> to options.dokebi-mcp, or give the tool a bucket that has one (prompts and resources use read).`,
|
|
187
|
+
data: { user, bucket },
|
|
188
|
+
});
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
async setup(context) {
|
|
192
|
+
const started = performance.now();
|
|
193
|
+
const options = this.#options;
|
|
194
|
+
const logger = context.logger;
|
|
195
|
+
const server = context.getDependency(web);
|
|
196
|
+
const oauth = context.getDependency(oauthDependency);
|
|
197
|
+
let info;
|
|
198
|
+
try {
|
|
199
|
+
info = oauth.resourceInfo(options.resource);
|
|
200
|
+
}
|
|
201
|
+
catch (cause) {
|
|
202
|
+
throw new McpError('INVALID_OPTIONS', `dokebi-mcp.resource ${options.resource} is not a resource of dokebi-oauth`, {
|
|
203
|
+
cause,
|
|
204
|
+
fix: `Set options.dokebi-mcp.resource to one of: ${oauth.resources.join(', ')} (options.dokebi-oauth.resources).`,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
const uri = new URL(info.uri);
|
|
208
|
+
const path = options.path ?? (uri.pathname === '' ? '/' : uri.pathname);
|
|
209
|
+
if (options.path !== undefined && options.path !== uri.pathname)
|
|
210
|
+
logger.log('warn', 'The MCP route path differs from the path of its resource URI', {
|
|
211
|
+
code: 'MCP_PATH_DIFFERS',
|
|
212
|
+
path: options.path,
|
|
213
|
+
resource: info.uri,
|
|
214
|
+
fix: 'Leave options.dokebi-mcp.path out unless a proxy changes the path between clients and this app.',
|
|
215
|
+
});
|
|
216
|
+
const scopes = new Set(Object.keys(info.scopes));
|
|
217
|
+
const tools = compileTools(context.collect(mcpTools), scopes);
|
|
218
|
+
const prompts = compilePrompts(context.collect(mcpPrompts), scopes);
|
|
219
|
+
const resources = compileResources(context.collect(mcpResources), scopes);
|
|
220
|
+
const limiters = this.#limiters(context, new Map([
|
|
221
|
+
...tools.map((tool) => [`the tool ${tool.name}`, tool.bucket]),
|
|
222
|
+
// Prompts and resources take tokens of the read bucket.
|
|
223
|
+
...(prompts.length || resources.length ? [['prompts and resources', 'read']] : []),
|
|
224
|
+
]));
|
|
225
|
+
const translators = context.collect(errorTranslators);
|
|
226
|
+
const hookBase = Object.freeze({
|
|
227
|
+
app: context.app,
|
|
228
|
+
getDependency: (dependency) => context.getDependency(this.#declared(dependency)),
|
|
229
|
+
logger,
|
|
230
|
+
});
|
|
231
|
+
const { hooks, overridden } = this.#resolved;
|
|
232
|
+
let instructions = options.instructions;
|
|
233
|
+
if (hooks && overridden.has('instructions')) {
|
|
234
|
+
const given = await hooks.instructions(Object.freeze({ ...hookBase, signal: context.signal, request: undefined }));
|
|
235
|
+
if (given !== undefined &&
|
|
236
|
+
(typeof given !== 'string' || given === '' || given.length > 16_000))
|
|
237
|
+
throw new McpError('INVALID_HOOK_RESULT', 'instructions() must return a string of 1-16000 characters or undefined', {
|
|
238
|
+
fix: `Return the server instructions (or undefined) from instructions() in ${EXTENSION_FILE}.`,
|
|
239
|
+
data: { hook: 'instructions' },
|
|
240
|
+
});
|
|
241
|
+
if (given !== undefined)
|
|
242
|
+
instructions = given;
|
|
243
|
+
}
|
|
244
|
+
const runner = new McpRunner({
|
|
245
|
+
logger,
|
|
246
|
+
timeoutMs: options.toolTimeoutMs,
|
|
247
|
+
resultMaxChars: options.resultMaxChars,
|
|
248
|
+
images: Object.freeze({
|
|
249
|
+
imageMaxBytes: options.imageMaxBytes,
|
|
250
|
+
imageTotalMaxBytes: options.imageTotalMaxBytes,
|
|
251
|
+
maxImages: options.maxImages,
|
|
252
|
+
}),
|
|
253
|
+
limiters,
|
|
254
|
+
hooks,
|
|
255
|
+
overridden,
|
|
256
|
+
hookContext: hookBase,
|
|
257
|
+
translators,
|
|
258
|
+
appSignal: context.signal,
|
|
259
|
+
logRepeatWindowMs: options.logRepeatWindowMs,
|
|
260
|
+
});
|
|
261
|
+
context.defer(() => runner.flush());
|
|
262
|
+
// D-0134: the onCall hook, after each response; never awaited, at most onCallMaxPending.
|
|
263
|
+
const reporter = hooks && overridden.has('onCall')
|
|
264
|
+
? new McpCallReporter({
|
|
265
|
+
hooks,
|
|
266
|
+
logger,
|
|
267
|
+
hookContext: hookBase,
|
|
268
|
+
appSignal: context.signal,
|
|
269
|
+
maxPending: options.onCallMaxPending,
|
|
270
|
+
logRepeatWindowMs: options.logRepeatWindowMs,
|
|
271
|
+
})
|
|
272
|
+
: undefined;
|
|
273
|
+
if (reporter)
|
|
274
|
+
context.defer(() => reporter.close());
|
|
275
|
+
const serverInfo = Object.freeze({
|
|
276
|
+
name: options.name ?? info.name,
|
|
277
|
+
version: options.version,
|
|
278
|
+
...(options.title === undefined ? {} : { title: options.title }),
|
|
279
|
+
});
|
|
280
|
+
const mcp = new McpServer({
|
|
281
|
+
runner,
|
|
282
|
+
tools,
|
|
283
|
+
prompts,
|
|
284
|
+
resources,
|
|
285
|
+
serverInfo,
|
|
286
|
+
instructions,
|
|
287
|
+
metadataUrl: info.metadataUrl,
|
|
288
|
+
logger,
|
|
289
|
+
maxBatch: options.maxBatch,
|
|
290
|
+
listTtlMs: options.listTtlMs,
|
|
291
|
+
uriMaxChars: options.uriMaxChars,
|
|
292
|
+
});
|
|
293
|
+
const origins = new Set([uri.origin.toLowerCase(), ...options.allowedOrigins.map(originKey)]);
|
|
294
|
+
this.#live = { runner, server: mcp, origins, own: uri.origin.toLowerCase(), logger };
|
|
295
|
+
const resource = options.resource;
|
|
296
|
+
const limit = options.bodyLimitBytes;
|
|
297
|
+
// D-0142: the route id (`mcp.endpoint`, or options.dokebi-mcp.routeIds.endpoint).
|
|
298
|
+
const routeIds = pluginRouteIds('dokebi-mcp', ['endpoint'], options.routeIds);
|
|
299
|
+
const routeId = routeIds.ids.endpoint;
|
|
300
|
+
// 1. Origin (when a browser sent one), 2. the token, before the body is read (D-0094 §1.4).
|
|
301
|
+
const checkOrigin = async (request) => {
|
|
302
|
+
const origin = request.headers.origin;
|
|
303
|
+
if (origin === undefined || origins.has(origin.toLowerCase()))
|
|
304
|
+
return;
|
|
305
|
+
debug(logger, 'MCP origin refused', { origin, routeId });
|
|
306
|
+
throw new McpError('ORIGIN_REFUSED', 'This origin may not call this MCP server', {
|
|
307
|
+
status: 403,
|
|
308
|
+
hint: 'Call the server from its own site or from a server-side client.',
|
|
309
|
+
fix: 'Add the origin to options.dokebi-mcp.allowedOrigins when a browser page on it must call the server.',
|
|
310
|
+
});
|
|
311
|
+
};
|
|
312
|
+
const authenticate = async (request) => {
|
|
313
|
+
await oauth.authenticate(request, { resource });
|
|
314
|
+
};
|
|
315
|
+
// 3. The body size of this route only, counted before Fastify buffers it.
|
|
316
|
+
const bodyLimit = async (request, _reply, payload) => {
|
|
317
|
+
const declared = Number(request.headers['content-length']);
|
|
318
|
+
if (Number.isFinite(declared) && declared > limit)
|
|
319
|
+
throw tooLarge(limit);
|
|
320
|
+
const counter = new BodyLimit(limit);
|
|
321
|
+
// A source error (a client that went away) reaches Fastify's body reader through the
|
|
322
|
+
// counter, which answers it. (pipeline() would also destroy the request on the limit error,
|
|
323
|
+
// and the 413 could not be sent.)
|
|
324
|
+
payload.on('error', (error) => counter.destroy(error));
|
|
325
|
+
payload.pipe(counter);
|
|
326
|
+
return counter;
|
|
327
|
+
};
|
|
328
|
+
const handler = async (request, reply) => {
|
|
329
|
+
const state = oauth.of(request);
|
|
330
|
+
const grant = state.grant;
|
|
331
|
+
if (grant === undefined)
|
|
332
|
+
throw new McpError('INTERNAL', 'The MCP route ran without a grant', {
|
|
333
|
+
fix: 'Report this: the token check of dokebi-mcp did not run before the handler.',
|
|
334
|
+
});
|
|
335
|
+
const contentType = request.headers['content-type'];
|
|
336
|
+
if (typeof contentType !== 'string' || !JSON_TYPE.test(contentType))
|
|
337
|
+
return reply
|
|
338
|
+
.code(415)
|
|
339
|
+
.header('cache-control', 'no-store')
|
|
340
|
+
.send(rpcError(null, INVALID_REQUEST, 'Content-Type must be application/json'));
|
|
341
|
+
const connection = Object.freeze({
|
|
342
|
+
id: grant.id,
|
|
343
|
+
userId: grant.userId,
|
|
344
|
+
clientId: grant.clientId,
|
|
345
|
+
clientName: grant.clientName,
|
|
346
|
+
scopes: state.scopes,
|
|
347
|
+
kind: grant.kind,
|
|
348
|
+
binding: grant.binding,
|
|
349
|
+
});
|
|
350
|
+
const controller = new AbortController();
|
|
351
|
+
// D-0134: the calls this request ends reach onCall once its response is written (closed).
|
|
352
|
+
const recorded = reporter ? [] : undefined;
|
|
353
|
+
let closed = false;
|
|
354
|
+
let afterClose;
|
|
355
|
+
reply.raw.once('close', () => {
|
|
356
|
+
closed = true;
|
|
357
|
+
if (!reply.raw.writableEnded)
|
|
358
|
+
controller.abort(new McpError('CANCELLED', 'The client closed the request'));
|
|
359
|
+
afterClose?.();
|
|
360
|
+
});
|
|
361
|
+
try {
|
|
362
|
+
const answer = await mcp.handle(request.body, request.headers, {
|
|
363
|
+
grant: connection,
|
|
364
|
+
request,
|
|
365
|
+
signal: controller.signal,
|
|
366
|
+
...(recorded ? { record: (entry) => void recorded.push(entry) } : {}),
|
|
367
|
+
});
|
|
368
|
+
reply.code(answer.status).header('cache-control', 'no-store');
|
|
369
|
+
if (answer.challenge)
|
|
370
|
+
reply.header('www-authenticate', answer.challenge);
|
|
371
|
+
if (answer.body === undefined)
|
|
372
|
+
return reply.send();
|
|
373
|
+
return reply.type('application/json; charset=utf-8').send(JSON.stringify(answer.body));
|
|
374
|
+
}
|
|
375
|
+
finally {
|
|
376
|
+
if (reporter && recorded?.length) {
|
|
377
|
+
const deliver = () => reporter.later(recorded, request);
|
|
378
|
+
if (closed)
|
|
379
|
+
deliver();
|
|
380
|
+
else
|
|
381
|
+
afterClose = deliver;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
const notAllowed = async (_request, reply) => reply
|
|
386
|
+
.code(405)
|
|
387
|
+
.header('allow', 'POST')
|
|
388
|
+
.header('cache-control', 'no-store')
|
|
389
|
+
.send(rpcError(null, METHOD_NOT_ALLOWED, 'This MCP server answers POST only: there is no stream and no session'));
|
|
390
|
+
// D-0132: the hosts that answer the route (dokebi-mcp.app); unset, every host.
|
|
391
|
+
const app = options.app;
|
|
392
|
+
const apps = app === undefined ? undefined : typeof app === 'string' ? [app] : [...app];
|
|
393
|
+
const routes = async (scope) => {
|
|
394
|
+
// At boot the app routing of @dokebi/app is final: the resource URI must be a host of
|
|
395
|
+
// these apps, or clients would reach a 404 (a route bound without it fails in dokebi-web).
|
|
396
|
+
const match = appUrlMatch(server.apps, info.uri, apps);
|
|
397
|
+
if (match.kind === 'other-app')
|
|
398
|
+
throw new McpError('INVALID_OPTIONS', `The MCP resource URI ${info.uri} is on ${match.host}, a domain of app ${match.app}, but dokebi-mcp.app is ${apps?.join(', ')}`, {
|
|
399
|
+
fix: `Set options.dokebi-mcp.app to ${match.app} (the app of the resource host), or give options.dokebi-oauth.resources.${resource}.uri a host of ${apps?.join(' or ')}.`,
|
|
400
|
+
data: { uri: info.uri, host: match.host, hostApp: match.app, apps },
|
|
401
|
+
});
|
|
402
|
+
if (match.kind === 'unconfigured')
|
|
403
|
+
logger.log('warn', 'The MCP resource host is no domain of dokebi-app', {
|
|
404
|
+
code: 'MCP_HOST_UNMAPPED',
|
|
405
|
+
uri: info.uri,
|
|
406
|
+
host: match.host,
|
|
407
|
+
apps,
|
|
408
|
+
unknownDomain: match.fallback ?? null,
|
|
409
|
+
fix: `Point ${match.host} at ${apps?.[0]} in options.dokebi-app.domains (npx dokebi app link ${apps?.[0]} ${match.host}), unless a proxy sends another Host.`,
|
|
410
|
+
});
|
|
411
|
+
// The body is read here as bytes: the JSON-RPC answer to broken JSON (-32700) and to a
|
|
412
|
+
// batch is the route's own, never a framework 400.
|
|
413
|
+
scope.removeAllContentTypeParsers();
|
|
414
|
+
scope.addContentTypeParser('*', { parseAs: 'buffer', bodyLimit: limit }, (_request, body, done) => done(null, body));
|
|
415
|
+
// MCP clients know nothing of seals and sessions: plain on seal hosts, no CSRF, no cookies.
|
|
416
|
+
const config = () => routeIds.config('endpoint', { auth: false, csrf: false });
|
|
417
|
+
scope.route({
|
|
418
|
+
method: 'POST',
|
|
419
|
+
url: path,
|
|
420
|
+
config: config(),
|
|
421
|
+
seal: false,
|
|
422
|
+
...(app === undefined ? {} : { app }),
|
|
423
|
+
bodyLimit: limit,
|
|
424
|
+
onRequest: [checkOrigin, authenticate],
|
|
425
|
+
preParsing: bodyLimit,
|
|
426
|
+
handler,
|
|
427
|
+
});
|
|
428
|
+
scope.route({
|
|
429
|
+
method: ['GET', 'DELETE'],
|
|
430
|
+
url: path,
|
|
431
|
+
config: config(),
|
|
432
|
+
seal: false,
|
|
433
|
+
...(app === undefined ? {} : { app }),
|
|
434
|
+
handler: notAllowed,
|
|
435
|
+
});
|
|
436
|
+
};
|
|
437
|
+
server.register(routes, {});
|
|
438
|
+
const buckets = Object.fromEntries([...new Set(tools.map((tool) => tool.bucket))].map((bucket) => [
|
|
439
|
+
bucket,
|
|
440
|
+
options.limits[bucket] ?? null,
|
|
441
|
+
]));
|
|
442
|
+
logger.log('debug', 'MCP setup finished', {
|
|
443
|
+
path,
|
|
444
|
+
routeId,
|
|
445
|
+
app: apps ?? null,
|
|
446
|
+
resource,
|
|
447
|
+
uri: info.uri,
|
|
448
|
+
tools: tools.map((tool) => tool.name),
|
|
449
|
+
prompts: prompts.map((prompt) => prompt.name),
|
|
450
|
+
resources: resources.map((one) => one.name),
|
|
451
|
+
limits: buckets,
|
|
452
|
+
bodyLimitBytes: limit,
|
|
453
|
+
resultMaxChars: options.resultMaxChars,
|
|
454
|
+
uriMaxChars: options.uriMaxChars,
|
|
455
|
+
imageMaxBytes: options.imageMaxBytes,
|
|
456
|
+
imageTotalMaxBytes: options.imageTotalMaxBytes,
|
|
457
|
+
maxImages: options.maxImages,
|
|
458
|
+
toolTimeoutMs: options.toolTimeoutMs,
|
|
459
|
+
onCallMaxPending: options.onCallMaxPending,
|
|
460
|
+
allowedOrigins: [...options.allowedOrigins],
|
|
461
|
+
translators: translators.map((one) => one.plugin),
|
|
462
|
+
extension: [...overridden],
|
|
463
|
+
ms: Math.round(performance.now() - started),
|
|
464
|
+
});
|
|
465
|
+
// D-0077 B: only `dokebi describe` asks. Names, scopes and settings; never a token.
|
|
466
|
+
if (context.runtime.describing)
|
|
467
|
+
context.contribute(describePoint, {
|
|
468
|
+
section: 'mcp',
|
|
469
|
+
describe: () => ({
|
|
470
|
+
path,
|
|
471
|
+
app: apps ?? null,
|
|
472
|
+
resource,
|
|
473
|
+
uri: info.uri,
|
|
474
|
+
metadata: info.metadataUrl,
|
|
475
|
+
versions: [...SUPPORTED_VERSIONS],
|
|
476
|
+
server: { ...serverInfo },
|
|
477
|
+
tools: tools.map((tool) => ({
|
|
478
|
+
name: tool.name,
|
|
479
|
+
scope: tool.scope,
|
|
480
|
+
scopes: tool.scopes,
|
|
481
|
+
bucket: tool.bucket,
|
|
482
|
+
readOnly: tool.readOnly,
|
|
483
|
+
plugin: tool.plugin,
|
|
484
|
+
})),
|
|
485
|
+
prompts: prompts.map((prompt) => ({
|
|
486
|
+
name: prompt.name,
|
|
487
|
+
scope: prompt.scope,
|
|
488
|
+
scopes: prompt.scopes,
|
|
489
|
+
plugin: prompt.plugin,
|
|
490
|
+
})),
|
|
491
|
+
resources: resources.map((one) => ({
|
|
492
|
+
name: one.name,
|
|
493
|
+
scope: one.scope,
|
|
494
|
+
scopes: one.scopes,
|
|
495
|
+
...(one.uri === undefined ? { template: one.template } : { uri: one.uri }),
|
|
496
|
+
plugin: one.plugin,
|
|
497
|
+
})),
|
|
498
|
+
limits: { ...options.limits },
|
|
499
|
+
bodyLimitBytes: limit,
|
|
500
|
+
resultMaxChars: options.resultMaxChars,
|
|
501
|
+
uriMaxChars: options.uriMaxChars,
|
|
502
|
+
imageMaxBytes: options.imageMaxBytes,
|
|
503
|
+
imageTotalMaxBytes: options.imageTotalMaxBytes,
|
|
504
|
+
maxImages: options.maxImages,
|
|
505
|
+
toolTimeoutMs: options.toolTimeoutMs,
|
|
506
|
+
onCall: reporter !== undefined,
|
|
507
|
+
onCallMaxPending: options.onCallMaxPending,
|
|
508
|
+
allowedOrigins: [...options.allowedOrigins],
|
|
509
|
+
}),
|
|
510
|
+
});
|
|
511
|
+
return Object.freeze({
|
|
512
|
+
path,
|
|
513
|
+
resource,
|
|
514
|
+
uri: info.uri,
|
|
515
|
+
tools: Object.freeze(tools.map((tool) => tool.name)),
|
|
516
|
+
prompts: Object.freeze(prompts.map((prompt) => prompt.name)),
|
|
517
|
+
resources: Object.freeze(resources.map((one) => one.name)),
|
|
518
|
+
versions: SUPPORTED_VERSIONS,
|
|
519
|
+
callStats: () => reporter?.stats() ?? Object.freeze({ delivered: 0, pending: 0, failed: 0, dropped: 0 }),
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
function debug(logger, message, fields) {
|
|
524
|
+
if (logger.isEnabled('debug'))
|
|
525
|
+
logger.log('debug', message, fields);
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Factory input: `{ app, options?, entry? }`. Options default to `app.config['dokebi-mcp']`;
|
|
529
|
+
* `entry` is the module of src/infra/mcp/extension.ts (D-0064), optional.
|
|
530
|
+
*/
|
|
531
|
+
export const createPlugin = definePlugin({
|
|
532
|
+
name: 'dokebi-mcp',
|
|
533
|
+
version: '0.1.0',
|
|
534
|
+
core: '^0.4.2',
|
|
535
|
+
options: declaration,
|
|
536
|
+
contributes: [describePoint],
|
|
537
|
+
collects: [mcpTools, mcpPrompts, mcpResources, errorTranslators],
|
|
538
|
+
entry: (input) => new McpEntryInput(input),
|
|
539
|
+
dependencies: ({ entry }) => entry.resolve().dependencies,
|
|
540
|
+
instance: ({ options, entry }) => new McpRuntime(options, entry.resolve()),
|
|
541
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Versions opened with `initialize` and the `MCP-Protocol-Version` header, newest first. The first
|
|
3
|
+
* one answers an `initialize` that asks for a version this server does not know. claude.ai probes
|
|
4
|
+
* with 2025-11-25 before it has a token.
|
|
5
|
+
*/
|
|
6
|
+
export declare const LEGACY_VERSIONS: readonly string[];
|
|
7
|
+
/**
|
|
8
|
+
* Versions without `initialize`: every request carries its version in
|
|
9
|
+
* `params._meta["io.modelcontextprotocol/protocolVersion"]` and the `MCP-Protocol-Version` and
|
|
10
|
+
* `Mcp-Method` headers (2026-07-28: claude.ai after sign-in, Claude Code first).
|
|
11
|
+
*/
|
|
12
|
+
export declare const MODERN_VERSIONS: readonly string[];
|
|
13
|
+
/** Every supported version: the `data.supported` of an unsupported-version refusal, in full. */
|
|
14
|
+
export declare const SUPPORTED_VERSIONS: readonly string[];
|
|
15
|
+
/** A request without `MCP-Protocol-Version` (and without `_meta`) is read as this version. */
|
|
16
|
+
export declare const IMPLICIT_VERSION = "2025-03-26";
|
|
17
|
+
/** The only version that accepts JSON-RPC batches (2025-06-18 removed them). */
|
|
18
|
+
export declare const BATCH_VERSION = "2025-03-26";
|
|
19
|
+
/** Most messages in one batch. */
|
|
20
|
+
export declare const MAX_BATCH = 32;
|
|
21
|
+
export declare const META_PROTOCOL_VERSION = "io.modelcontextprotocol/protocolVersion";
|
|
22
|
+
export declare const META_SERVER_INFO = "io.modelcontextprotocol/serverInfo";
|
|
23
|
+
/** Request headers (Fastify gives them in lower case). */
|
|
24
|
+
export declare const HEADER_PROTOCOL_VERSION = "mcp-protocol-version";
|
|
25
|
+
export declare const HEADER_METHOD = "mcp-method";
|
|
26
|
+
export declare const HEADER_NAME = "mcp-name";
|
|
27
|
+
/** A header value that is not plain ASCII arrives as `=?base64?<base64 of UTF-8>?=`. */
|
|
28
|
+
export declare const BASE64_PREFIX = "=?base64?";
|
|
29
|
+
export declare const BASE64_SUFFIX = "?=";
|
|
30
|
+
/** The body field `Mcp-Name` mirrors, per method (modern versions). */
|
|
31
|
+
export declare const NAME_SOURCE: Readonly<Record<string, string>>;
|
|
32
|
+
export declare const PARSE_ERROR = -32700;
|
|
33
|
+
export declare const INVALID_REQUEST = -32600;
|
|
34
|
+
export declare const METHOD_NOT_FOUND = -32601;
|
|
35
|
+
export declare const INVALID_PARAMS = -32602;
|
|
36
|
+
export declare const INTERNAL_ERROR = -32603;
|
|
37
|
+
/** `resources/read` of an unknown URI. */
|
|
38
|
+
export declare const RESOURCE_NOT_FOUND = -32002;
|
|
39
|
+
/** The body of a 403 insufficient_scope answer (the header is what clients act on). */
|
|
40
|
+
export declare const INSUFFICIENT_SCOPE = -32003;
|
|
41
|
+
/** A header disagrees with the body (modern versions). */
|
|
42
|
+
export declare const HEADER_MISMATCH = -32020;
|
|
43
|
+
/** The body of the 405 to GET and DELETE (no stream, no session). */
|
|
44
|
+
export declare const METHOD_NOT_ALLOWED = -32000;
|
|
45
|
+
/** A protocol version this server does not support (`data: { supported, requested }`). */
|
|
46
|
+
export declare const UNSUPPORTED_VERSION = -32022;
|
|
47
|
+
/** Cache hint of modern list results: the same answer for this grant until a deployment. */
|
|
48
|
+
export declare const LIST_TTL_MS = 300000;
|
|
49
|
+
export declare const LIST_CACHE_SCOPE = "private";
|
|
50
|
+
/** A tool name (MCP 2025-11-25): 1-128 of letters, digits, `_`, `-` and `.`. */
|
|
51
|
+
export declare const TOOL_NAME: RegExp;
|
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* MCP protocol constants (D-0094 §1.2), in one place: when the specification moves, the version
|
|
3
|
+
* lists, the error numbers and the header names change here and nowhere else.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Versions opened with `initialize` and the `MCP-Protocol-Version` header, newest first. The first
|
|
7
|
+
* one answers an `initialize` that asks for a version this server does not know. claude.ai probes
|
|
8
|
+
* with 2025-11-25 before it has a token.
|
|
9
|
+
*/
|
|
10
|
+
export const LEGACY_VERSIONS = Object.freeze([
|
|
11
|
+
'2025-11-25',
|
|
12
|
+
'2025-06-18',
|
|
13
|
+
'2025-03-26',
|
|
14
|
+
]);
|
|
15
|
+
/**
|
|
16
|
+
* Versions without `initialize`: every request carries its version in
|
|
17
|
+
* `params._meta["io.modelcontextprotocol/protocolVersion"]` and the `MCP-Protocol-Version` and
|
|
18
|
+
* `Mcp-Method` headers (2026-07-28: claude.ai after sign-in, Claude Code first).
|
|
19
|
+
*/
|
|
20
|
+
export const MODERN_VERSIONS = Object.freeze(['2026-07-28']);
|
|
21
|
+
/** Every supported version: the `data.supported` of an unsupported-version refusal, in full. */
|
|
22
|
+
export const SUPPORTED_VERSIONS = Object.freeze([
|
|
23
|
+
...MODERN_VERSIONS,
|
|
24
|
+
...LEGACY_VERSIONS,
|
|
25
|
+
]);
|
|
26
|
+
/** A request without `MCP-Protocol-Version` (and without `_meta`) is read as this version. */
|
|
27
|
+
export const IMPLICIT_VERSION = '2025-03-26';
|
|
28
|
+
/** The only version that accepts JSON-RPC batches (2025-06-18 removed them). */
|
|
29
|
+
export const BATCH_VERSION = '2025-03-26';
|
|
30
|
+
/** Most messages in one batch. */
|
|
31
|
+
export const MAX_BATCH = 32;
|
|
32
|
+
export const META_PROTOCOL_VERSION = 'io.modelcontextprotocol/protocolVersion';
|
|
33
|
+
export const META_SERVER_INFO = 'io.modelcontextprotocol/serverInfo';
|
|
34
|
+
/** Request headers (Fastify gives them in lower case). */
|
|
35
|
+
export const HEADER_PROTOCOL_VERSION = 'mcp-protocol-version';
|
|
36
|
+
export const HEADER_METHOD = 'mcp-method';
|
|
37
|
+
export const HEADER_NAME = 'mcp-name';
|
|
38
|
+
/** A header value that is not plain ASCII arrives as `=?base64?<base64 of UTF-8>?=`. */
|
|
39
|
+
export const BASE64_PREFIX = '=?base64?';
|
|
40
|
+
export const BASE64_SUFFIX = '?=';
|
|
41
|
+
/** The body field `Mcp-Name` mirrors, per method (modern versions). */
|
|
42
|
+
export const NAME_SOURCE = Object.freeze({
|
|
43
|
+
'tools/call': 'name',
|
|
44
|
+
'prompts/get': 'name',
|
|
45
|
+
'resources/read': 'uri',
|
|
46
|
+
});
|
|
47
|
+
// JSON-RPC error numbers (never mixed with tool errors, which are results with isError).
|
|
48
|
+
export const PARSE_ERROR = -32700;
|
|
49
|
+
export const INVALID_REQUEST = -32600;
|
|
50
|
+
export const METHOD_NOT_FOUND = -32601;
|
|
51
|
+
export const INVALID_PARAMS = -32602;
|
|
52
|
+
export const INTERNAL_ERROR = -32603;
|
|
53
|
+
/** `resources/read` of an unknown URI. */
|
|
54
|
+
export const RESOURCE_NOT_FOUND = -32002;
|
|
55
|
+
/** The body of a 403 insufficient_scope answer (the header is what clients act on). */
|
|
56
|
+
export const INSUFFICIENT_SCOPE = -32003;
|
|
57
|
+
/** A header disagrees with the body (modern versions). */
|
|
58
|
+
export const HEADER_MISMATCH = -32020;
|
|
59
|
+
/** The body of the 405 to GET and DELETE (no stream, no session). */
|
|
60
|
+
export const METHOD_NOT_ALLOWED = -32000;
|
|
61
|
+
/** A protocol version this server does not support (`data: { supported, requested }`). */
|
|
62
|
+
export const UNSUPPORTED_VERSION = -32022;
|
|
63
|
+
/** Cache hint of modern list results: the same answer for this grant until a deployment. */
|
|
64
|
+
export const LIST_TTL_MS = 300_000;
|
|
65
|
+
export const LIST_CACHE_SCOPE = 'private';
|
|
66
|
+
/** A tool name (MCP 2025-11-25): 1-128 of letters, digits, `_`, `-` and `.`. */
|
|
67
|
+
export const TOOL_NAME = /^[A-Za-z0-9_.-]{1,128}$/;
|