@dokebi/mcp 0.0.0-stage → 0.1.1
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 +390 -0
- package/LICENSE +21 -0
- package/README.md +46 -2
- package/dist/actions.d.ts +19 -0
- package/dist/actions.js +88 -0
- 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 +128 -4
- package/templates/extension.ts +30 -0
package/dist/calls.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Logger } from '@dokebi/core';
|
|
2
|
+
import type { FastifyRequest } from 'fastify';
|
|
3
|
+
import type { McpCallEntry, McpHookContext, McpHooks } from './extension.js';
|
|
4
|
+
/** What the reporter has done so far (`McpApi.callStats()`). */
|
|
5
|
+
export interface McpCallStats {
|
|
6
|
+
/** Entries handed to `onCall` (a throw counts too). */
|
|
7
|
+
readonly delivered: number;
|
|
8
|
+
/** Promises returned by `onCall` that have not settled yet. */
|
|
9
|
+
readonly pending: number;
|
|
10
|
+
/** Throws and rejections of `onCall` (each logged at warn, MCP_ON_CALL_FAILED). */
|
|
11
|
+
readonly failed: number;
|
|
12
|
+
/** Entries dropped because `onCallMaxPending` promises were pending (MCP_ON_CALL_DROPPED). */
|
|
13
|
+
readonly dropped: number;
|
|
14
|
+
}
|
|
15
|
+
export interface CallReporterSettings {
|
|
16
|
+
readonly hooks: McpHooks;
|
|
17
|
+
readonly logger: Logger;
|
|
18
|
+
readonly hookContext: Pick<McpHookContext, 'app' | 'getDependency' | 'logger'>;
|
|
19
|
+
/** Aborted when the app stops (the hook's `context.signal`); absent: never aborted. */
|
|
20
|
+
readonly appSignal: AbortSignal | undefined;
|
|
21
|
+
/** `onCallMaxPending`. */
|
|
22
|
+
readonly maxPending: number;
|
|
23
|
+
/** Window of the repeated warn lines (`logRepeatWindowMs`). */
|
|
24
|
+
readonly logRepeatWindowMs: number;
|
|
25
|
+
}
|
|
26
|
+
export declare class McpCallReporter {
|
|
27
|
+
#private;
|
|
28
|
+
constructor(settings: CallReporterSettings);
|
|
29
|
+
stats(): McpCallStats;
|
|
30
|
+
/** The entries of one answered request: delivered one setImmediate later, in their order. */
|
|
31
|
+
later(entries: readonly McpCallEntry[], request: FastifyRequest | undefined): void;
|
|
32
|
+
/** Hands each entry to `onCall` once (the route calls `later`; tests may call this). */
|
|
33
|
+
deliver(entries: readonly McpCallEntry[], request: FastifyRequest | undefined): void;
|
|
34
|
+
/** At stop: the counted repeats, and a warn when hook promises are still pending. */
|
|
35
|
+
close(): void;
|
|
36
|
+
}
|
package/dist/calls.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { diagnostics } from '@dokebi/core/libraries';
|
|
2
|
+
const never = new AbortController().signal;
|
|
3
|
+
function thenable(value) {
|
|
4
|
+
return (value !== null &&
|
|
5
|
+
(typeof value === 'object' || typeof value === 'function') &&
|
|
6
|
+
typeof value.then === 'function');
|
|
7
|
+
}
|
|
8
|
+
export class McpCallReporter {
|
|
9
|
+
#s;
|
|
10
|
+
#repeats;
|
|
11
|
+
#delivered = 0;
|
|
12
|
+
#pending = 0;
|
|
13
|
+
#failed = 0;
|
|
14
|
+
#dropped = 0;
|
|
15
|
+
constructor(settings) {
|
|
16
|
+
this.#s = settings;
|
|
17
|
+
this.#repeats = new diagnostics.Repeats({
|
|
18
|
+
logger: settings.logger,
|
|
19
|
+
intervalMs: settings.logRepeatWindowMs,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
stats() {
|
|
23
|
+
return Object.freeze({
|
|
24
|
+
delivered: this.#delivered,
|
|
25
|
+
pending: this.#pending,
|
|
26
|
+
failed: this.#failed,
|
|
27
|
+
dropped: this.#dropped,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/** The entries of one answered request: delivered one setImmediate later, in their order. */
|
|
31
|
+
later(entries, request) {
|
|
32
|
+
if (entries.length === 0)
|
|
33
|
+
return;
|
|
34
|
+
setImmediate(() => this.deliver(entries, request));
|
|
35
|
+
}
|
|
36
|
+
/** Hands each entry to `onCall` once (the route calls `later`; tests may call this). */
|
|
37
|
+
deliver(entries, request) {
|
|
38
|
+
const context = Object.freeze({
|
|
39
|
+
...this.#s.hookContext,
|
|
40
|
+
signal: this.#s.appSignal ?? never,
|
|
41
|
+
request,
|
|
42
|
+
});
|
|
43
|
+
for (const entry of entries)
|
|
44
|
+
this.#one(entry, context);
|
|
45
|
+
}
|
|
46
|
+
#one(entry, context) {
|
|
47
|
+
if (this.#pending >= this.#s.maxPending) {
|
|
48
|
+
this.#dropped++;
|
|
49
|
+
this.#repeats.log('warn', 'MCP onCall entry dropped', 'MCP_ON_CALL_DROPPED', undefined, {
|
|
50
|
+
method: entry.method,
|
|
51
|
+
name: entry.name,
|
|
52
|
+
grant: entry.grant,
|
|
53
|
+
result: entry.result,
|
|
54
|
+
pending: this.#pending,
|
|
55
|
+
maxPending: this.#s.maxPending,
|
|
56
|
+
dropped: this.#dropped,
|
|
57
|
+
fix: 'onCall returns promises faster than they settle: make it cheaper (batch the writes) or raise options.dokebi-mcp.onCallMaxPending.',
|
|
58
|
+
}, 'dropped');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
this.#delivered++;
|
|
62
|
+
let returned;
|
|
63
|
+
try {
|
|
64
|
+
returned = this.#s.hooks.onCall(entry, context);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
this.#failure(error, entry, 'threw');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (!thenable(returned))
|
|
71
|
+
return;
|
|
72
|
+
this.#pending++;
|
|
73
|
+
Promise.resolve(returned).then(() => {
|
|
74
|
+
this.#pending--;
|
|
75
|
+
}, (error) => {
|
|
76
|
+
this.#pending--;
|
|
77
|
+
this.#failure(error, entry, 'rejected');
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
#failure(error, entry, how) {
|
|
81
|
+
this.#failed++;
|
|
82
|
+
this.#repeats.log('warn', 'MCP onCall hook failed', 'MCP_ON_CALL_FAILED', error, {
|
|
83
|
+
hook: 'onCall',
|
|
84
|
+
how,
|
|
85
|
+
method: entry.method,
|
|
86
|
+
name: entry.name,
|
|
87
|
+
grant: entry.grant,
|
|
88
|
+
result: entry.result,
|
|
89
|
+
failed: this.#failed,
|
|
90
|
+
}, `${entry.method} ${entry.name}`);
|
|
91
|
+
}
|
|
92
|
+
/** At stop: the counted repeats, and a warn when hook promises are still pending. */
|
|
93
|
+
close() {
|
|
94
|
+
this.#repeats.flush();
|
|
95
|
+
if (this.#pending > 0)
|
|
96
|
+
this.#s.logger.log('warn', 'MCP onCall promises still pending at stop', {
|
|
97
|
+
code: 'MCP_ON_CALL_PENDING',
|
|
98
|
+
pending: this.#pending,
|
|
99
|
+
fix: 'onCall is not awaited at stop: listen to context.signal (aborted at stop) to finish or give up a write.',
|
|
100
|
+
});
|
|
101
|
+
diagnostics.logDebug(this.#s.logger, 'MCP onCall closed', () => ({ ...this.stats() }));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { McpApi } from './plugin.js';
|
|
2
|
+
export declare const provider: import("@dokebi/core").ProviderDescriptor<McpApi>;
|
|
3
|
+
/** Required: created from @dokebi/mcp when no `dokebi-mcp` plugin is registered. */
|
|
4
|
+
export declare const dependency: import("@dokebi/core").PluginDependency<McpApi>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineProvider } from '@dokebi/core';
|
|
2
|
+
/*
|
|
3
|
+
* The one place dokebi-mcp states its name, package and range (D-0063 §6). A plugin that needs
|
|
4
|
+
* the MCP plugin's API imports `dependency` from '@dokebi/mcp/dependency' (a small module: it
|
|
5
|
+
* loads nothing else). Contributing tools needs no dependency: declare the point instead.
|
|
6
|
+
*/
|
|
7
|
+
export const provider = defineProvider({
|
|
8
|
+
name: 'dokebi-mcp',
|
|
9
|
+
version: '^0.1.0',
|
|
10
|
+
packageName: '@dokebi/mcp',
|
|
11
|
+
load: () => import('./index.js'),
|
|
12
|
+
});
|
|
13
|
+
/** Required: created from @dokebi/mcp when no `dokebi-mcp` plugin is registered. */
|
|
14
|
+
export const dependency = provider.dependency;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/** Who wrote a piece of stored content. */
|
|
2
|
+
export type McpWrittenBy = 'human' | 'ai' | 'tool';
|
|
3
|
+
/** Where a piece of stored content comes from. */
|
|
4
|
+
export interface McpSource {
|
|
5
|
+
/** What it is, in the application's words (`document`, `note`, `item`). 1-64 characters. */
|
|
6
|
+
readonly kind: string;
|
|
7
|
+
readonly id: string | number;
|
|
8
|
+
/** A human-facing code of the record (`DOC-12`), or null. */
|
|
9
|
+
readonly code?: string | null;
|
|
10
|
+
readonly written_by: McpWrittenBy;
|
|
11
|
+
/** True for text from outside the application's users (an imported page, a guest's report). */
|
|
12
|
+
readonly outside: boolean;
|
|
13
|
+
}
|
|
14
|
+
/** One piece of stored content. `data` is any JSON value. */
|
|
15
|
+
export interface McpContentItem {
|
|
16
|
+
readonly data: unknown;
|
|
17
|
+
readonly source: McpSource;
|
|
18
|
+
}
|
|
19
|
+
/** A step only a person can take: the place on the screen and the button, as they read there. */
|
|
20
|
+
export interface McpHumanTurn {
|
|
21
|
+
readonly where: string;
|
|
22
|
+
readonly button: string;
|
|
23
|
+
}
|
|
24
|
+
/** A value hidden before the answer left (`transformResult`). */
|
|
25
|
+
export interface McpMasked {
|
|
26
|
+
/** Where in the envelope (`content[3].data.token`). */
|
|
27
|
+
readonly path: string;
|
|
28
|
+
readonly reason: string;
|
|
29
|
+
}
|
|
30
|
+
/** The image types a result may carry (D-0143): what MCP clients show a model. */
|
|
31
|
+
export type McpImageMimeType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif';
|
|
32
|
+
/** The image types a result may carry, in the order of `McpImageMimeType`. */
|
|
33
|
+
export declare const IMAGE_MIME_TYPES: readonly McpImageMimeType[];
|
|
34
|
+
/**
|
|
35
|
+
* One picture for `envelope({ images })` (D-0143). Send a reduced copy (a @dokebi/image thumbnail,
|
|
36
|
+
* a storage variant), not the original: every byte travels as base64 to the model.
|
|
37
|
+
*/
|
|
38
|
+
export interface McpImageInput {
|
|
39
|
+
/** The bytes (a Uint8Array or Buffer, not copied: do not change them afterwards), or base64. */
|
|
40
|
+
readonly data: Uint8Array | string;
|
|
41
|
+
/** The type the bytes must be (checked from their first bytes, never trusted). */
|
|
42
|
+
readonly mimeType: McpImageMimeType;
|
|
43
|
+
/** What the picture shows, in words (1-1000 characters): for the model and text-only clients. */
|
|
44
|
+
readonly alt?: string;
|
|
45
|
+
/** Where the picture comes from: an upload is stored content like `content` items. */
|
|
46
|
+
readonly source?: McpSource;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The placeholder of a picture in an envelope (D-0143): what `structuredContent`, the text block,
|
|
50
|
+
* `transformResult` and `onCall` see. Never the bytes.
|
|
51
|
+
*/
|
|
52
|
+
export interface McpImage {
|
|
53
|
+
/** Its place among the result's images; its content block is `content[index + 1]`. */
|
|
54
|
+
readonly index: number;
|
|
55
|
+
readonly mimeType: McpImageMimeType;
|
|
56
|
+
/** The size of the picture in bytes (its base64 is about 4/3 of it). */
|
|
57
|
+
readonly bytes: number;
|
|
58
|
+
readonly alt?: string;
|
|
59
|
+
readonly source?: McpSource;
|
|
60
|
+
}
|
|
61
|
+
/** What `envelope()` takes. */
|
|
62
|
+
export interface McpEnvelopeInput {
|
|
63
|
+
readonly content?: readonly McpContentItem[];
|
|
64
|
+
readonly note?: string;
|
|
65
|
+
readonly next?: string | readonly string[];
|
|
66
|
+
readonly human_turns?: readonly McpHumanTurn[];
|
|
67
|
+
readonly masked?: readonly McpMasked[];
|
|
68
|
+
readonly next_cursor?: string | null;
|
|
69
|
+
/**
|
|
70
|
+
* Other values the server computed (ids, counts, codes, a list of skipped rows), merged at the
|
|
71
|
+
* top level. Never stored text: that belongs in `content`. The envelope's own names are refused.
|
|
72
|
+
*/
|
|
73
|
+
readonly fields?: Readonly<Record<string, unknown>>;
|
|
74
|
+
/**
|
|
75
|
+
* Pictures the model sees (D-0143): new ones (`{ data, mimeType, alt?, source? }`) or
|
|
76
|
+
* placeholders of `result.images` (in `amend`, to keep, reorder or drop some). At most
|
|
77
|
+
* `maxImages` per result, `imageMaxBytes` each and `imageTotalMaxBytes` in all (the route checks).
|
|
78
|
+
*/
|
|
79
|
+
readonly images?: readonly (McpImageInput | McpImage)[];
|
|
80
|
+
}
|
|
81
|
+
/** A tool result made by `envelope()`: frozen, the only thing `handle` may return. */
|
|
82
|
+
export interface McpEnvelope {
|
|
83
|
+
readonly content: readonly McpContentItem[];
|
|
84
|
+
readonly note?: string;
|
|
85
|
+
readonly next?: string | readonly string[];
|
|
86
|
+
readonly human_turns?: readonly McpHumanTurn[];
|
|
87
|
+
readonly masked: readonly McpMasked[];
|
|
88
|
+
readonly next_cursor?: string | null;
|
|
89
|
+
/** The placeholders of the result's pictures (D-0143); absent when there are none. */
|
|
90
|
+
readonly images?: readonly McpImage[];
|
|
91
|
+
readonly [field: string]: unknown;
|
|
92
|
+
}
|
|
93
|
+
/** One `{ data, source }` checked (the source in full; data is any JSON value). */
|
|
94
|
+
export declare function item(data: unknown, source: McpSource): McpContentItem;
|
|
95
|
+
/** The result envelope (see the top of this file). Throws INVALID_ARGUMENT on a wrong shape. */
|
|
96
|
+
export declare function envelope(input?: McpEnvelopeInput): McpEnvelope;
|
|
97
|
+
/**
|
|
98
|
+
* A changed copy of an envelope (for `transformResult`): `changes` replace the envelope's own
|
|
99
|
+
* slots (`content`, `note`, `next`, `human_turns`, `masked`, `next_cursor`, `images`) and add
|
|
100
|
+
* `fields`; the other fields of `result` stay. Its pictures stay unless `images` is given:
|
|
101
|
+
* `images: []` drops them, `images: result.images.filter(…)` keeps some (D-0143).
|
|
102
|
+
*/
|
|
103
|
+
export declare function amend(result: McpEnvelope, changes: McpEnvelopeInput): McpEnvelope;
|
|
104
|
+
/** The bytes of a placeholder `envelope()` made (the route's, never exported to applications). */
|
|
105
|
+
export declare function imageBytes(image: McpImage): Uint8Array | undefined;
|
|
106
|
+
/** Whether `value` was made by `envelope()`. */
|
|
107
|
+
export declare function isEnvelope(value: unknown): value is McpEnvelope;
|
|
108
|
+
/** What `page()` returns: pass both to `envelope()`. */
|
|
109
|
+
export interface McpPage {
|
|
110
|
+
readonly content: readonly McpContentItem[];
|
|
111
|
+
readonly next_cursor: string | null;
|
|
112
|
+
}
|
|
113
|
+
/** The default page size in characters (below the default `resultMaxChars` of 60,000). */
|
|
114
|
+
export declare const PAGE_MAX_CHARS = 50000;
|
|
115
|
+
/**
|
|
116
|
+
* One page of `items` that fits `maxChars` (the JSON of the items), from `cursor` on, with the
|
|
117
|
+
* cursor of the next page (null at the end). The cursor is an offset: for a list that changes
|
|
118
|
+
* between calls, page with a key of your own and pass it as `next_cursor`. A first item larger
|
|
119
|
+
* than `maxChars` is returned alone (the result size check may then refuse it).
|
|
120
|
+
*/
|
|
121
|
+
export declare function page(items: readonly McpContentItem[], settings?: {
|
|
122
|
+
readonly cursor?: string | undefined;
|
|
123
|
+
readonly maxChars?: number;
|
|
124
|
+
}): McpPage;
|
package/dist/envelope.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { media } from '@dokebi/core/libraries';
|
|
2
|
+
import { McpError } from './errors.js';
|
|
3
|
+
/** The image types a result may carry, in the order of `McpImageMimeType`. */
|
|
4
|
+
export const IMAGE_MIME_TYPES = Object.freeze([
|
|
5
|
+
'image/png',
|
|
6
|
+
'image/jpeg',
|
|
7
|
+
'image/webp',
|
|
8
|
+
'image/gif',
|
|
9
|
+
]);
|
|
10
|
+
const RESERVED = new Set([
|
|
11
|
+
'content',
|
|
12
|
+
'note',
|
|
13
|
+
'next',
|
|
14
|
+
'human_turns',
|
|
15
|
+
'masked',
|
|
16
|
+
'next_cursor',
|
|
17
|
+
'fields',
|
|
18
|
+
'images',
|
|
19
|
+
// The error shape (code, message, hint, data) and the limit answer: a success never looks like one.
|
|
20
|
+
'code',
|
|
21
|
+
'message',
|
|
22
|
+
'hint',
|
|
23
|
+
'retry_after',
|
|
24
|
+
// Would set the prototype instead of a field: refused, never dropped silently.
|
|
25
|
+
'__proto__',
|
|
26
|
+
]);
|
|
27
|
+
const WRITTEN_BY = new Set(['human', 'ai', 'tool']);
|
|
28
|
+
const KIND = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/;
|
|
29
|
+
const FIELD = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
|
30
|
+
const MADE = new WeakSet();
|
|
31
|
+
/** The bytes of each placeholder `envelope()` made (D-0143): kept out of the envelope itself. */
|
|
32
|
+
const BYTES = new WeakMap();
|
|
33
|
+
const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
34
|
+
const MIME = new Set(IMAGE_MIME_TYPES);
|
|
35
|
+
function invalid(message, fix) {
|
|
36
|
+
return new McpError('INVALID_ARGUMENT', message, { fix });
|
|
37
|
+
}
|
|
38
|
+
function plain(value) {
|
|
39
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
40
|
+
}
|
|
41
|
+
function text(value, where, max) {
|
|
42
|
+
if (typeof value !== 'string' || value === '' || value.length > max)
|
|
43
|
+
throw invalid(`${where} must be a non-empty string of at most ${max} characters`, `Pass a string to ${where}.`);
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
/** One `{ data, source }` checked (the source in full; data is any JSON value). */
|
|
47
|
+
export function item(data, source) {
|
|
48
|
+
if (data === undefined || typeof data === 'function' || typeof data === 'bigint')
|
|
49
|
+
throw invalid('item data must be a JSON value', 'Pass the stored value (text, an object) as data.');
|
|
50
|
+
if (!plain(source))
|
|
51
|
+
throw invalid('item source must be { kind, id, written_by, outside }', 'Describe where the data comes from.');
|
|
52
|
+
if (typeof source.kind !== 'string' || !KIND.test(source.kind))
|
|
53
|
+
throw invalid('source.kind must be 1-64 letters, digits, _, ., : or - (starting with a letter or digit)', "Name what the data is, for example 'note'.");
|
|
54
|
+
if (!(typeof source.id === 'string' ? source.id !== '' : Number.isFinite(source.id)))
|
|
55
|
+
throw invalid('source.id must be a non-empty string or a number', 'Pass the record id.');
|
|
56
|
+
if (source.code !== undefined && source.code !== null && typeof source.code !== 'string')
|
|
57
|
+
throw invalid('source.code must be a string or null', 'Pass the record code or leave it out.');
|
|
58
|
+
if (!WRITTEN_BY.has(source.written_by))
|
|
59
|
+
throw invalid("source.written_by must be 'human', 'ai' or 'tool'", 'Say who wrote the data.');
|
|
60
|
+
if (typeof source.outside !== 'boolean')
|
|
61
|
+
throw invalid('source.outside must be a boolean', 'true for text from outside the users of the app.');
|
|
62
|
+
return Object.freeze({
|
|
63
|
+
data,
|
|
64
|
+
source: Object.freeze({
|
|
65
|
+
kind: source.kind,
|
|
66
|
+
id: source.id,
|
|
67
|
+
...(source.code === undefined ? {} : { code: source.code }),
|
|
68
|
+
written_by: source.written_by,
|
|
69
|
+
outside: source.outside,
|
|
70
|
+
}),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/** The result envelope (see the top of this file). Throws INVALID_ARGUMENT on a wrong shape. */
|
|
74
|
+
export function envelope(input = {}) {
|
|
75
|
+
if (!plain(input))
|
|
76
|
+
throw invalid('envelope() takes { content, note, next, … }', 'Pass an object to envelope().');
|
|
77
|
+
for (const key of Object.keys(input))
|
|
78
|
+
if (![
|
|
79
|
+
'content',
|
|
80
|
+
'note',
|
|
81
|
+
'next',
|
|
82
|
+
'human_turns',
|
|
83
|
+
'masked',
|
|
84
|
+
'next_cursor',
|
|
85
|
+
'fields',
|
|
86
|
+
'images',
|
|
87
|
+
].includes(key))
|
|
88
|
+
throw invalid(`envelope() has no ${key}`, 'Put stored text in content (item(data, source)) and other computed values in fields.');
|
|
89
|
+
const content = input.content ?? [];
|
|
90
|
+
if (!Array.isArray(content))
|
|
91
|
+
throw invalid('envelope content must be a list of { data, source }', 'Use item(data, source).');
|
|
92
|
+
const result = {
|
|
93
|
+
content: Object.freeze(content.map((one) => {
|
|
94
|
+
if (!plain(one))
|
|
95
|
+
throw invalid('a content entry must be { data, source }', 'Use item(data, source).');
|
|
96
|
+
return item(one.data, one.source);
|
|
97
|
+
})),
|
|
98
|
+
};
|
|
99
|
+
if (input.note !== undefined)
|
|
100
|
+
result.note = text(input.note, 'note', 4_096);
|
|
101
|
+
if (input.next !== undefined) {
|
|
102
|
+
const next = input.next;
|
|
103
|
+
result.next = Array.isArray(next)
|
|
104
|
+
? Object.freeze(next.map((one, index) => text(one, `next[${index}]`, 1_024)))
|
|
105
|
+
: text(next, 'next', 1_024);
|
|
106
|
+
}
|
|
107
|
+
if (input.human_turns !== undefined) {
|
|
108
|
+
if (!Array.isArray(input.human_turns))
|
|
109
|
+
throw invalid('human_turns must be a list of { where, button }', 'Name the place and the button.');
|
|
110
|
+
result.human_turns = Object.freeze(input.human_turns.map((turn, index) => Object.freeze({
|
|
111
|
+
where: text(plain(turn) ? turn.where : undefined, `human_turns[${index}].where`, 200),
|
|
112
|
+
button: text(plain(turn) ? turn.button : undefined, `human_turns[${index}].button`, 200),
|
|
113
|
+
})));
|
|
114
|
+
}
|
|
115
|
+
const masked = input.masked ?? [];
|
|
116
|
+
if (!Array.isArray(masked))
|
|
117
|
+
throw invalid('masked must be a list of { path, reason }', 'Say what was hidden and why.');
|
|
118
|
+
result.masked = Object.freeze(masked.map((entry, index) => Object.freeze({
|
|
119
|
+
path: text(plain(entry) ? entry.path : undefined, `masked[${index}].path`, 512),
|
|
120
|
+
reason: text(plain(entry) ? entry.reason : undefined, `masked[${index}].reason`, 200),
|
|
121
|
+
})));
|
|
122
|
+
if (input.next_cursor !== undefined) {
|
|
123
|
+
if (input.next_cursor !== null)
|
|
124
|
+
text(input.next_cursor, 'next_cursor', 1_024);
|
|
125
|
+
result.next_cursor = input.next_cursor;
|
|
126
|
+
}
|
|
127
|
+
if (input.fields !== undefined) {
|
|
128
|
+
if (!plain(input.fields))
|
|
129
|
+
throw invalid('fields must be an object', 'Pass computed values as { name: value }.');
|
|
130
|
+
for (const [key, value] of Object.entries(input.fields)) {
|
|
131
|
+
if (RESERVED.has(key) || !FIELD.test(key))
|
|
132
|
+
throw invalid(`fields.${key} is not allowed (reserved or not a plain name)`, 'Use a name of letters, digits and _ that the envelope does not use itself.');
|
|
133
|
+
if (value !== undefined)
|
|
134
|
+
result[key] = value;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (input.images !== undefined) {
|
|
138
|
+
if (!Array.isArray(input.images))
|
|
139
|
+
throw invalid('images must be a list of { data, mimeType, alt?, source? }', 'Pass the pictures as a list, or leave images out.');
|
|
140
|
+
const images = Object.freeze(input.images.map((one, index) => image(one, index)));
|
|
141
|
+
if (images.length)
|
|
142
|
+
result.images = images;
|
|
143
|
+
}
|
|
144
|
+
const made = Object.freeze(result);
|
|
145
|
+
MADE.add(made);
|
|
146
|
+
return made;
|
|
147
|
+
}
|
|
148
|
+
const OWN = [
|
|
149
|
+
'content',
|
|
150
|
+
'note',
|
|
151
|
+
'next',
|
|
152
|
+
'human_turns',
|
|
153
|
+
'masked',
|
|
154
|
+
'next_cursor',
|
|
155
|
+
'images',
|
|
156
|
+
];
|
|
157
|
+
/**
|
|
158
|
+
* A changed copy of an envelope (for `transformResult`): `changes` replace the envelope's own
|
|
159
|
+
* slots (`content`, `note`, `next`, `human_turns`, `masked`, `next_cursor`, `images`) and add
|
|
160
|
+
* `fields`; the other fields of `result` stay. Its pictures stay unless `images` is given:
|
|
161
|
+
* `images: []` drops them, `images: result.images.filter(…)` keeps some (D-0143).
|
|
162
|
+
*/
|
|
163
|
+
export function amend(result, changes) {
|
|
164
|
+
if (!isEnvelope(result))
|
|
165
|
+
throw invalid('amend() takes an envelope', 'Pass the result the hook received.');
|
|
166
|
+
// Without a prototype: a key __proto__ of the changes stays a key (and envelope() refuses it).
|
|
167
|
+
const input = Object.create(null);
|
|
168
|
+
const fields = Object.create(null);
|
|
169
|
+
for (const [key, value] of Object.entries(result))
|
|
170
|
+
if (OWN.includes(key))
|
|
171
|
+
input[key] = value;
|
|
172
|
+
else
|
|
173
|
+
fields[key] = value;
|
|
174
|
+
if (changes !== undefined && !plain(changes))
|
|
175
|
+
throw invalid('amend() takes the changes as { content, masked, fields, … }', 'Pass an object of the slots to change.');
|
|
176
|
+
for (const [key, value] of Object.entries(changes ?? {}))
|
|
177
|
+
if (key === 'fields') {
|
|
178
|
+
if (!plain(value))
|
|
179
|
+
throw invalid('amend() fields must be an object', 'Pass computed values as { name: value }.');
|
|
180
|
+
for (const [name, one] of Object.entries(value))
|
|
181
|
+
Object.defineProperty(fields, name, {
|
|
182
|
+
value: one,
|
|
183
|
+
enumerable: true,
|
|
184
|
+
writable: true,
|
|
185
|
+
configurable: true,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
else
|
|
189
|
+
input[key] = value;
|
|
190
|
+
return envelope({ ...input, fields });
|
|
191
|
+
}
|
|
192
|
+
/** One picture of `envelope({ images })` checked: its type from its own first bytes (D-0143). */
|
|
193
|
+
function image(given, index) {
|
|
194
|
+
const where = `images[${index}]`;
|
|
195
|
+
if (!plain(given))
|
|
196
|
+
throw invalid(`${where} must be { data, mimeType, alt?, source? }`, 'Describe the picture.');
|
|
197
|
+
const kept = BYTES.get(given);
|
|
198
|
+
if (kept) {
|
|
199
|
+
// A placeholder of result.images (amend): the same picture at its new place.
|
|
200
|
+
const old = given;
|
|
201
|
+
return placeholder(kept, old.mimeType, index, old.alt, old.source);
|
|
202
|
+
}
|
|
203
|
+
const input = given;
|
|
204
|
+
if (!MIME.has(input.mimeType))
|
|
205
|
+
throw new McpError('IMAGE_TYPE_REFUSED', `${where} has the type ${typeof input.mimeType === 'string' ? input.mimeType : typeof input.mimeType}; a result carries ${IMAGE_MIME_TYPES.join(', ')} only`, {
|
|
206
|
+
hint: 'Describe the picture in words instead; it cannot be shown.',
|
|
207
|
+
fix: 'Send a PNG, JPEG, WebP or GIF copy (a @dokebi/image thumbnail is WebP).',
|
|
208
|
+
data: { index, allowed: [...IMAGE_MIME_TYPES] },
|
|
209
|
+
});
|
|
210
|
+
let bytes;
|
|
211
|
+
if (input.data instanceof Uint8Array)
|
|
212
|
+
bytes = input.data;
|
|
213
|
+
else if (typeof input.data === 'string') {
|
|
214
|
+
if (input.data.length % 4 !== 0 || !BASE64.test(input.data))
|
|
215
|
+
throw invalid(`${where}.data is not base64`, 'Pass the bytes as a Uint8Array (or Buffer), or their standard base64 with padding.');
|
|
216
|
+
bytes = Buffer.from(input.data, 'base64');
|
|
217
|
+
}
|
|
218
|
+
else
|
|
219
|
+
throw invalid(`${where}.data must be a Uint8Array or a base64 string`, 'Pass the bytes of the picture (a Buffer works).');
|
|
220
|
+
if (bytes.byteLength === 0)
|
|
221
|
+
throw invalid(`${where}.data is empty`, 'Pass the bytes of the picture.');
|
|
222
|
+
const format = media.sniffImage(bytes.subarray(0, media.IMAGE_SIGNATURE_BYTES));
|
|
223
|
+
const detected = format === undefined ? null : `image/${format}`;
|
|
224
|
+
if (detected !== input.mimeType)
|
|
225
|
+
throw new McpError('IMAGE_TYPE_MISMATCH', `${where} is declared ${input.mimeType} but its bytes are ${detected ?? 'no image of a known type'}`, {
|
|
226
|
+
hint: 'The picture could not be sent; tell the user it may be damaged.',
|
|
227
|
+
fix: 'Declare the type the bytes are (a @dokebi/image thumbnail is image/webp), or convert the file.',
|
|
228
|
+
data: { index, declared: input.mimeType, detected },
|
|
229
|
+
});
|
|
230
|
+
if (input.alt !== undefined)
|
|
231
|
+
text(input.alt, `${where}.alt`, 1_000);
|
|
232
|
+
const source = input.source === undefined ? undefined : item(null, input.source).source;
|
|
233
|
+
return placeholder(bytes, input.mimeType, index, input.alt, source);
|
|
234
|
+
}
|
|
235
|
+
function placeholder(bytes, mimeType, index, alt, source) {
|
|
236
|
+
const made = Object.freeze({
|
|
237
|
+
index,
|
|
238
|
+
mimeType,
|
|
239
|
+
bytes: bytes.byteLength,
|
|
240
|
+
...(alt === undefined ? {} : { alt }),
|
|
241
|
+
...(source === undefined ? {} : { source }),
|
|
242
|
+
});
|
|
243
|
+
BYTES.set(made, bytes);
|
|
244
|
+
return made;
|
|
245
|
+
}
|
|
246
|
+
/** The bytes of a placeholder `envelope()` made (the route's, never exported to applications). */
|
|
247
|
+
export function imageBytes(image) {
|
|
248
|
+
return BYTES.get(image);
|
|
249
|
+
}
|
|
250
|
+
/** Whether `value` was made by `envelope()`. */
|
|
251
|
+
export function isEnvelope(value) {
|
|
252
|
+
return value !== null && typeof value === 'object' && MADE.has(value);
|
|
253
|
+
}
|
|
254
|
+
/** The default page size in characters (below the default `resultMaxChars` of 60,000). */
|
|
255
|
+
export const PAGE_MAX_CHARS = 50_000;
|
|
256
|
+
const CURSOR = /^p1\.([0-9]{1,9})$/;
|
|
257
|
+
function decodeCursor(cursor) {
|
|
258
|
+
// base64url decoding never throws; a cursor that is not ours fails the pattern below.
|
|
259
|
+
const decoded = Buffer.from(cursor, 'base64url').toString('utf8');
|
|
260
|
+
const match = CURSOR.exec(decoded);
|
|
261
|
+
if (!match)
|
|
262
|
+
throw new McpError('INVALID_CURSOR', 'The cursor is not one of this list', {
|
|
263
|
+
hint: 'Call the tool again without cursor to start from the first page.',
|
|
264
|
+
});
|
|
265
|
+
return Number(match[1]);
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* One page of `items` that fits `maxChars` (the JSON of the items), from `cursor` on, with the
|
|
269
|
+
* cursor of the next page (null at the end). The cursor is an offset: for a list that changes
|
|
270
|
+
* between calls, page with a key of your own and pass it as `next_cursor`. A first item larger
|
|
271
|
+
* than `maxChars` is returned alone (the result size check may then refuse it).
|
|
272
|
+
*/
|
|
273
|
+
export function page(items, settings = {}) {
|
|
274
|
+
if (!Array.isArray(items))
|
|
275
|
+
throw invalid('page() takes the list of items', 'Pass the content items to page.');
|
|
276
|
+
const maxChars = settings.maxChars ?? PAGE_MAX_CHARS;
|
|
277
|
+
if (!Number.isSafeInteger(maxChars) || maxChars < 1)
|
|
278
|
+
throw invalid('page() maxChars must be a whole number above 0', 'Pass a size in characters.');
|
|
279
|
+
const start = settings.cursor === undefined || settings.cursor === '' ? 0 : decodeCursor(settings.cursor);
|
|
280
|
+
if (start > items.length)
|
|
281
|
+
throw new McpError('INVALID_CURSOR', 'The cursor is past the end of this list', {
|
|
282
|
+
hint: 'Call the tool again without cursor to start from the first page.',
|
|
283
|
+
});
|
|
284
|
+
const content = [];
|
|
285
|
+
let used = 2;
|
|
286
|
+
let index = start;
|
|
287
|
+
for (; index < items.length; index++) {
|
|
288
|
+
const one = items[index];
|
|
289
|
+
const size = (JSON.stringify(one)?.length ?? 4) + 1;
|
|
290
|
+
if (content.length > 0 && used + size > maxChars)
|
|
291
|
+
break;
|
|
292
|
+
content.push(one);
|
|
293
|
+
used += size;
|
|
294
|
+
}
|
|
295
|
+
return Object.freeze({
|
|
296
|
+
content: Object.freeze(content),
|
|
297
|
+
next_cursor: index < items.length ? Buffer.from(`p1.${index}`, 'utf8').toString('base64url') : null,
|
|
298
|
+
});
|
|
299
|
+
}
|
package/dist/era.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type JsonRpcId, type JsonRpcMessage, type JsonRpcRefusal } from './jsonrpc.js';
|
|
2
|
+
export type Era = 'modern' | 'legacy';
|
|
3
|
+
export interface Version {
|
|
4
|
+
readonly kind: 'version';
|
|
5
|
+
readonly era: Era;
|
|
6
|
+
readonly version: string;
|
|
7
|
+
}
|
|
8
|
+
/** Fastify's request headers (lower-case names). */
|
|
9
|
+
export type Headers = Readonly<Record<string, string | readonly string[] | undefined>>;
|
|
10
|
+
export declare function headerOf(headers: Headers, name: string): string | undefined;
|
|
11
|
+
/** `=?base64?…?=` decoded (other values as they are); undefined when the marker is broken. */
|
|
12
|
+
export declare function decodeHeaderValue(raw: string): string | undefined;
|
|
13
|
+
export declare function unsupported(id: JsonRpcId | null, requested: string): JsonRpcRefusal;
|
|
14
|
+
/** The version of a message and the header checks of modern versions. */
|
|
15
|
+
export declare function classify(message: JsonRpcMessage, headers: Headers): Version | JsonRpcRefusal;
|
|
16
|
+
/** The version an `initialize` is answered with: the one asked for when known, else the newest. */
|
|
17
|
+
export declare function negotiate(requested: unknown): string;
|