@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/era.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { refusal } from './jsonrpc.js';
|
|
2
|
+
import { BASE64_PREFIX, BASE64_SUFFIX, HEADER_METHOD, HEADER_MISMATCH, HEADER_NAME, HEADER_PROTOCOL_VERSION, IMPLICIT_VERSION, LEGACY_VERSIONS, META_PROTOCOL_VERSION, MODERN_VERSIONS, NAME_SOURCE, SUPPORTED_VERSIONS, UNSUPPORTED_VERSION, } from './protocol.js';
|
|
3
|
+
export function headerOf(headers, name) {
|
|
4
|
+
const value = headers[name];
|
|
5
|
+
if (value === undefined)
|
|
6
|
+
return undefined;
|
|
7
|
+
// Two copies of one header are joined: the comparison then fails instead of picking one.
|
|
8
|
+
return typeof value === 'string' ? value : value.join(',');
|
|
9
|
+
}
|
|
10
|
+
const utf8 = new TextDecoder('utf-8', { fatal: true });
|
|
11
|
+
/** `=?base64?…?=` decoded (other values as they are); undefined when the marker is broken. */
|
|
12
|
+
export function decodeHeaderValue(raw) {
|
|
13
|
+
if (!(raw.startsWith(BASE64_PREFIX) && raw.endsWith(BASE64_SUFFIX)))
|
|
14
|
+
return raw;
|
|
15
|
+
const inner = raw.slice(BASE64_PREFIX.length, raw.length - BASE64_SUFFIX.length);
|
|
16
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(inner) || inner.length % 4 !== 0)
|
|
17
|
+
return undefined;
|
|
18
|
+
try {
|
|
19
|
+
return utf8.decode(Buffer.from(inner, 'base64'));
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
// Not UTF-8: the header cannot match the body, which is what the caller reports.
|
|
23
|
+
if (!(error instanceof TypeError))
|
|
24
|
+
throw error;
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function unsupported(id, requested) {
|
|
29
|
+
return refusal(400, id, UNSUPPORTED_VERSION, `Unsupported MCP protocol version ${requested}; supported: ${SUPPORTED_VERSIONS.join(', ')}`, { supported: [...SUPPORTED_VERSIONS], requested });
|
|
30
|
+
}
|
|
31
|
+
/** The version of a message and the header checks of modern versions. */
|
|
32
|
+
export function classify(message, headers) {
|
|
33
|
+
const id = message.kind === 'request' ? message.id : null;
|
|
34
|
+
const header = headerOf(headers, HEADER_PROTOCOL_VERSION);
|
|
35
|
+
const meta = message.params._meta;
|
|
36
|
+
const claimed = meta !== null && typeof meta === 'object' && !Array.isArray(meta)
|
|
37
|
+
? meta[META_PROTOCOL_VERSION]
|
|
38
|
+
: undefined;
|
|
39
|
+
if (claimed !== undefined) {
|
|
40
|
+
const requested = typeof claimed === 'string' ? claimed : JSON.stringify(claimed);
|
|
41
|
+
if (typeof claimed !== 'string' || !MODERN_VERSIONS.includes(claimed))
|
|
42
|
+
return unsupported(id, requested);
|
|
43
|
+
if (header !== undefined && header !== claimed)
|
|
44
|
+
return refusal(400, id, HEADER_MISMATCH, `MCP-Protocol-Version ${header} differs from the version in params._meta (${claimed})`);
|
|
45
|
+
const method = headerOf(headers, HEADER_METHOD);
|
|
46
|
+
// Notifications may come without the headers; when present they must agree.
|
|
47
|
+
if (message.kind === 'request') {
|
|
48
|
+
if (header === undefined)
|
|
49
|
+
return refusal(400, id, HEADER_MISMATCH, `MCP-Protocol-Version is missing: a ${claimed} request carries its version in the header and in params._meta`);
|
|
50
|
+
if (method === undefined)
|
|
51
|
+
return refusal(400, id, HEADER_MISMATCH, `Mcp-Method is missing: a ${claimed} request repeats its method (${message.method}) in the header`);
|
|
52
|
+
}
|
|
53
|
+
if (method !== undefined && method !== message.method)
|
|
54
|
+
return refusal(400, id, HEADER_MISMATCH, `Mcp-Method ${method} differs from the method of the body (${message.method})`);
|
|
55
|
+
const source = Object.hasOwn(NAME_SOURCE, message.method)
|
|
56
|
+
? NAME_SOURCE[message.method]
|
|
57
|
+
: undefined;
|
|
58
|
+
if (source !== undefined) {
|
|
59
|
+
const value = message.params[source];
|
|
60
|
+
const name = headerOf(headers, HEADER_NAME);
|
|
61
|
+
if (typeof value === 'string') {
|
|
62
|
+
if (name === undefined && message.kind === 'request')
|
|
63
|
+
return refusal(400, id, HEADER_MISMATCH, `Mcp-Name is missing: ${message.method} repeats params.${source} in the header`);
|
|
64
|
+
if (name !== undefined && decodeHeaderValue(name) !== value)
|
|
65
|
+
return refusal(400, id, HEADER_MISMATCH, `Mcp-Name differs from params.${source} of the body`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { kind: 'version', era: 'modern', version: claimed };
|
|
69
|
+
}
|
|
70
|
+
if (header === undefined)
|
|
71
|
+
return { kind: 'version', era: 'legacy', version: IMPLICIT_VERSION };
|
|
72
|
+
if (MODERN_VERSIONS.includes(header))
|
|
73
|
+
// A modern header without the envelope is not quietly read as a legacy request.
|
|
74
|
+
return refusal(400, id, HEADER_MISMATCH, `MCP-Protocol-Version is ${header} but params._meta has no ${META_PROTOCOL_VERSION}: ${header} requests carry it in every message`);
|
|
75
|
+
if (LEGACY_VERSIONS.includes(header))
|
|
76
|
+
return { kind: 'version', era: 'legacy', version: header };
|
|
77
|
+
return unsupported(id, header);
|
|
78
|
+
}
|
|
79
|
+
/** The version an `initialize` is answered with: the one asked for when known, else the newest. */
|
|
80
|
+
export function negotiate(requested) {
|
|
81
|
+
if (typeof requested === 'string' && LEGACY_VERSIONS.includes(requested))
|
|
82
|
+
return requested;
|
|
83
|
+
return LEGACY_VERSIONS[0];
|
|
84
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { DokebiError, type DokebiErrorOptions } from '@dokebi/core';
|
|
2
|
+
export type McpErrorCode =
|
|
3
|
+
/** Start: options the schema cannot judge (an unknown resource, a bucket without a policy). */
|
|
4
|
+
'INVALID_OPTIONS'
|
|
5
|
+
/** The entry module (src/infra/mcp/extension.ts) has the wrong shape. */
|
|
6
|
+
| 'INVALID_ENTRY'
|
|
7
|
+
/** Start: a contributed tool breaks the contract (`data.tool`, `details.problems`). */
|
|
8
|
+
| 'INVALID_TOOL'
|
|
9
|
+
/** Start: a contributed prompt breaks the contract (`data.prompt`, `details.problems`). */
|
|
10
|
+
| 'INVALID_PROMPT'
|
|
11
|
+
/** Start: a contributed resource breaks the contract (`data.resource`, `details.problems`). */
|
|
12
|
+
| 'INVALID_RESOURCE'
|
|
13
|
+
/** Start: `limits` without @dokebi/rate-limit, or with a policy it does not have. */
|
|
14
|
+
| 'RATE_LIMIT_REQUIRED'
|
|
15
|
+
/** A hook returned something the plugin cannot use (`data.hook`). */
|
|
16
|
+
| 'INVALID_HOOK_RESULT'
|
|
17
|
+
/** A helper (`envelope`, `page`, `item`) or the API got a wrong argument. */
|
|
18
|
+
| 'INVALID_ARGUMENT'
|
|
19
|
+
/** A tool returned something other than an `envelope()` (the cause of an INTERNAL answer). */
|
|
20
|
+
| 'INVALID_RESULT'
|
|
21
|
+
/** 413: the request body is larger than `bodyLimitBytes` (before it is read). */
|
|
22
|
+
| 'BODY_TOO_LARGE'
|
|
23
|
+
/** 403: the request has an `Origin` that is neither the resource's nor in `allowedOrigins`. */
|
|
24
|
+
| 'ORIGIN_REFUSED'
|
|
25
|
+
/** 403 with `WWW-Authenticate: Bearer error="insufficient_scope"`: the grant lacks the scope. */
|
|
26
|
+
| 'INSUFFICIENT_SCOPE'
|
|
27
|
+
/** Tool error: the arguments do not match the tool's input schema (`data.problems`). */
|
|
28
|
+
| 'INVALID_INPUT'
|
|
29
|
+
/** Tool error: the grant's bucket is empty (`retry_after` seconds). */
|
|
30
|
+
| 'LIMIT_EXCEEDED'
|
|
31
|
+
/** Tool error: `checkGrant` said no (the connection may not reach this any more). */
|
|
32
|
+
| 'GRANT_REFUSED'
|
|
33
|
+
/** Tool error: the result is larger than `resultMaxChars` (never cut silently). */
|
|
34
|
+
| 'RESULT_TOO_LARGE'
|
|
35
|
+
/** Tool error: the tool ran longer than `toolTimeoutMs`; its signal was aborted. */
|
|
36
|
+
| 'TOOL_TIMEOUT'
|
|
37
|
+
/** Tool error: a `page()` cursor that is not one of this list. */
|
|
38
|
+
| 'INVALID_CURSOR'
|
|
39
|
+
/** `envelope({ images })`: a type other than image/png, image/jpeg, image/webp, image/gif. */
|
|
40
|
+
| 'IMAGE_TYPE_REFUSED'
|
|
41
|
+
/** `envelope({ images })`: the bytes are not the declared type (`data.declared`, `detected`). */
|
|
42
|
+
| 'IMAGE_TYPE_MISMATCH'
|
|
43
|
+
/** Tool error: one picture is larger than `imageMaxBytes` (`data.index`, `bytes`, `max`). */
|
|
44
|
+
| 'IMAGE_TOO_LARGE'
|
|
45
|
+
/** Tool error: a result has more pictures than `maxImages` (`data.count`, `max`). */
|
|
46
|
+
| 'TOO_MANY_IMAGES'
|
|
47
|
+
/** Tool error: the pictures of a result are larger than `imageTotalMaxBytes` together. */
|
|
48
|
+
| 'IMAGE_TOTAL_TOO_LARGE'
|
|
49
|
+
/** The client closed the request or the app stopped before the call ended (the log's result). */
|
|
50
|
+
| 'CANCELLED'
|
|
51
|
+
/** Tool error: an unexpected failure (no details; the cause is in the error log). */
|
|
52
|
+
| 'INTERNAL';
|
|
53
|
+
export declare class McpError extends DokebiError<McpErrorCode> {
|
|
54
|
+
/** Fastify reads `statusCode` of a body stream error; it matches `status`. */
|
|
55
|
+
readonly statusCode: number | undefined;
|
|
56
|
+
constructor(code: McpErrorCode, message: string, options?: DokebiErrorOptions);
|
|
57
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { DokebiError } from '@dokebi/core';
|
|
2
|
+
export class McpError extends DokebiError {
|
|
3
|
+
/** Fastify reads `statusCode` of a body stream error; it matches `status`. */
|
|
4
|
+
statusCode;
|
|
5
|
+
constructor(code, message, options = {}) {
|
|
6
|
+
super(code, message, { ...options, plugin: options.plugin ?? 'dokebi-mcp' });
|
|
7
|
+
this.name = 'McpError';
|
|
8
|
+
this.statusCode = options.status;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { ApplicationView, Awaitable, Logger, PluginDependency } from '@dokebi/core';
|
|
2
|
+
import type { FastifyRequest } from 'fastify';
|
|
3
|
+
import type { McpEnvelope } from './envelope.js';
|
|
4
|
+
import type { McpGrant, McpPrompt, McpResource, McpTool } from './tool.js';
|
|
5
|
+
/** What hooks receive besides their input. */
|
|
6
|
+
export interface McpHookContext {
|
|
7
|
+
readonly app: ApplicationView;
|
|
8
|
+
/** Providers declared by `dependencies()`. */
|
|
9
|
+
getDependency<TApi>(dependency: PluginDependency<TApi>): TApi;
|
|
10
|
+
/** The plugin's logger. */
|
|
11
|
+
readonly logger: Logger;
|
|
12
|
+
/**
|
|
13
|
+
* Aborted when the call's budget is over, its client left or the app stops; in `onCall` (the
|
|
14
|
+
* call is over) when the app stops.
|
|
15
|
+
*/
|
|
16
|
+
readonly signal: AbortSignal;
|
|
17
|
+
/** The HTTP request of the call; undefined at setup and in @dokebi/mcp/testing. */
|
|
18
|
+
readonly request: FastifyRequest | undefined;
|
|
19
|
+
}
|
|
20
|
+
/** What a call is about, for `checkGrant`. */
|
|
21
|
+
export type McpTarget = {
|
|
22
|
+
readonly kind: 'tool';
|
|
23
|
+
readonly name: string;
|
|
24
|
+
/** The first scope of the target (the one asked for). */
|
|
25
|
+
readonly scope: string;
|
|
26
|
+
/** Every scope of which any one lets the grant use it. */
|
|
27
|
+
readonly scopes: readonly string[];
|
|
28
|
+
/** The first of `scopes` the grant holds: the one that let this call through. */
|
|
29
|
+
readonly matched: string;
|
|
30
|
+
readonly value: McpTool;
|
|
31
|
+
} | {
|
|
32
|
+
readonly kind: 'prompt';
|
|
33
|
+
readonly name: string;
|
|
34
|
+
/** The first scope of the target (the one asked for). */
|
|
35
|
+
readonly scope: string;
|
|
36
|
+
/** Every scope of which any one lets the grant use it. */
|
|
37
|
+
readonly scopes: readonly string[];
|
|
38
|
+
/** The first of `scopes` the grant holds: the one that let this call through. */
|
|
39
|
+
readonly matched: string;
|
|
40
|
+
readonly value: McpPrompt;
|
|
41
|
+
} | {
|
|
42
|
+
readonly kind: 'resource';
|
|
43
|
+
readonly name: string;
|
|
44
|
+
/** The first scope of the target (the one asked for). */
|
|
45
|
+
readonly scope: string;
|
|
46
|
+
/** Every scope of which any one lets the grant use it. */
|
|
47
|
+
readonly scopes: readonly string[];
|
|
48
|
+
/** The first of `scopes` the grant holds: the one that let this call through. */
|
|
49
|
+
readonly matched: string;
|
|
50
|
+
readonly value: McpResource;
|
|
51
|
+
};
|
|
52
|
+
/** The requests `onCall` hears of (D-0134): the calls of a tool, a prompt or a resource. */
|
|
53
|
+
export type McpCallMethod = 'tools/call' | 'prompts/get' | 'resources/read';
|
|
54
|
+
/**
|
|
55
|
+
* One ended call, as `onCall` receives it (D-0134): the values of its `MCP_CALL` info line, frozen.
|
|
56
|
+
* Never a token, the input, prompt arguments, a resource URI or its content.
|
|
57
|
+
*/
|
|
58
|
+
export interface McpCallEntry {
|
|
59
|
+
readonly method: McpCallMethod;
|
|
60
|
+
/**
|
|
61
|
+
* The tool, prompt or resource name (a resource's `name`, never its URI); '' when the request
|
|
62
|
+
* named nothing this server has (the requested name is client input and never leaves).
|
|
63
|
+
*/
|
|
64
|
+
readonly name: string;
|
|
65
|
+
/** The grant id (the connection). */
|
|
66
|
+
readonly grant: string;
|
|
67
|
+
/** The OAuth client id of the grant. */
|
|
68
|
+
readonly client: string;
|
|
69
|
+
/** The grant's user id. */
|
|
70
|
+
readonly userId: string;
|
|
71
|
+
/** Milliseconds from the start of the call to its answer. */
|
|
72
|
+
readonly ms: number;
|
|
73
|
+
/** Bytes of the answer's JSON (a tool error's JSON; 0 for a JSON-RPC error). */
|
|
74
|
+
readonly bytes: number;
|
|
75
|
+
/** `ok`, or the code the call ended with (`LIMIT_EXCEEDED`, `INVALID_INPUT`, an app code, …). */
|
|
76
|
+
readonly result: string;
|
|
77
|
+
/**
|
|
78
|
+
* Only for `tools/call` with `result` `ok`: the envelope the client received, after
|
|
79
|
+
* `transformResult` (what it masked is masked here too). Count `content`, read `fields`, and
|
|
80
|
+
* drop the text you must not keep: it is what the tool returned.
|
|
81
|
+
*/
|
|
82
|
+
readonly envelope?: McpEnvelope;
|
|
83
|
+
}
|
|
84
|
+
/** The hooks the plugin calls, as plain methods (internal: the runtime's view of a subclass). */
|
|
85
|
+
export interface McpHooks {
|
|
86
|
+
dependencies(): readonly PluginDependency[];
|
|
87
|
+
instructions(context: McpHookContext): Awaitable<unknown>;
|
|
88
|
+
checkGrant(grant: McpGrant, target: McpTarget, context: McpHookContext): Awaitable<unknown>;
|
|
89
|
+
transformResult(result: McpEnvelope, call: {
|
|
90
|
+
readonly tool: McpTool;
|
|
91
|
+
readonly grant: McpGrant;
|
|
92
|
+
}, context: McpHookContext): Awaitable<unknown>;
|
|
93
|
+
onCall(entry: McpCallEntry, context: McpHookContext): Awaitable<unknown>;
|
|
94
|
+
}
|
|
95
|
+
export declare const EXTENSION_FILE = "src/infra/mcp/extension.ts";
|
|
96
|
+
declare const McpExtension_base: import("@dokebi/core").ExtensionRoot;
|
|
97
|
+
/**
|
|
98
|
+
* Subclass this in `src/infra/mcp/extension.ts` (`export default class AppMcp extends
|
|
99
|
+
* McpExtension { … }`) and override hooks with `protected override`. Hooks run with their inputs
|
|
100
|
+
* only: no constructor parameters, and hooks never call each other.
|
|
101
|
+
*/
|
|
102
|
+
export declare class McpExtension extends McpExtension_base {
|
|
103
|
+
/** Providers the other hooks use through `context.getDependency`. Called once. Default: none. */
|
|
104
|
+
protected dependencies(): readonly PluginDependency[];
|
|
105
|
+
/**
|
|
106
|
+
* The server instructions (`initialize` and `server/discover`), called once at setup; a string
|
|
107
|
+
* of at most 16,000 characters or undefined (then the `instructions` option). Default: undefined.
|
|
108
|
+
*/
|
|
109
|
+
protected instructions(context: McpHookContext): Awaitable<string | undefined>;
|
|
110
|
+
/**
|
|
111
|
+
* Called before every tool call, prompt and resource read, after the scope and limit checks:
|
|
112
|
+
* may this grant still reach `target`? (The connection's binding names a project the user has
|
|
113
|
+
* left: false.) False answers GRANT_REFUSED; a thrown DokebiError answers its own code.
|
|
114
|
+
* Default: true.
|
|
115
|
+
*/
|
|
116
|
+
protected checkGrant(grant: McpGrant, target: McpTarget, context: McpHookContext): Awaitable<boolean>;
|
|
117
|
+
/**
|
|
118
|
+
* Changes a tool result once before it leaves (hide values, list them in `masked`). Return an
|
|
119
|
+
* envelope: `amend(result, { content, masked: [...] })`. Runs before the size check. Default: the
|
|
120
|
+
* result unchanged.
|
|
121
|
+
*/
|
|
122
|
+
protected transformResult(result: McpEnvelope, call: {
|
|
123
|
+
readonly tool: McpTool;
|
|
124
|
+
readonly grant: McpGrant;
|
|
125
|
+
}, context: McpHookContext): Awaitable<McpEnvelope>;
|
|
126
|
+
/**
|
|
127
|
+
* Hears once that a call ended (D-0134): every `tools/call`, `prompts/get` and `resources/read`
|
|
128
|
+
* the route dispatched, whatever the outcome (ok, a refusal before the handler such as
|
|
129
|
+
* LIMIT_EXCEEDED, INSUFFICIENT_SCOPE, INVALID_INPUT or GRANT_REFUSED, an error, TOOL_TIMEOUT,
|
|
130
|
+
* CANCELLED). It runs after the response was written and is never awaited: it cannot delay
|
|
131
|
+
* or change an answer. A throw or a rejection is logged at warn (MCP_ON_CALL_FAILED) and
|
|
132
|
+
* counted; at most `onCallMaxPending` returned promises are pending at once, and an entry over
|
|
133
|
+
* that is dropped with warn MCP_ON_CALL_DROPPED. Default: nothing.
|
|
134
|
+
*/
|
|
135
|
+
protected onCall(entry: McpCallEntry, context: McpHookContext): Awaitable<void>;
|
|
136
|
+
}
|
|
137
|
+
/** The plugin's handle on a subclass instance (not exported from the package). */
|
|
138
|
+
export declare function mcpHooks(extension: McpExtension): McpHooks;
|
|
139
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { defineExtension } from '@dokebi/core';
|
|
2
|
+
export const EXTENSION_FILE = 'src/infra/mcp/extension.ts';
|
|
3
|
+
let bridge;
|
|
4
|
+
/**
|
|
5
|
+
* Subclass this in `src/infra/mcp/extension.ts` (`export default class AppMcp extends
|
|
6
|
+
* McpExtension { … }`) and override hooks with `protected override`. Hooks run with their inputs
|
|
7
|
+
* only: no constructor parameters, and hooks never call each other.
|
|
8
|
+
*/
|
|
9
|
+
export class McpExtension extends defineExtension({
|
|
10
|
+
plugin: 'dokebi-mcp',
|
|
11
|
+
api: 1,
|
|
12
|
+
hooks: ['dependencies', 'instructions', 'checkGrant', 'transformResult', 'onCall'],
|
|
13
|
+
file: EXTENSION_FILE,
|
|
14
|
+
}) {
|
|
15
|
+
static {
|
|
16
|
+
bridge = (extension) => Object.freeze({
|
|
17
|
+
dependencies: () => extension.dependencies(),
|
|
18
|
+
instructions: (context) => extension.instructions(context),
|
|
19
|
+
checkGrant: (grant, target, context) => extension.checkGrant(grant, target, context),
|
|
20
|
+
transformResult: (result, call, context) => extension.transformResult(result, call, context),
|
|
21
|
+
onCall: (entry, context) => extension.onCall(entry, context),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
/** Providers the other hooks use through `context.getDependency`. Called once. Default: none. */
|
|
25
|
+
dependencies() {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The server instructions (`initialize` and `server/discover`), called once at setup; a string
|
|
30
|
+
* of at most 16,000 characters or undefined (then the `instructions` option). Default: undefined.
|
|
31
|
+
*/
|
|
32
|
+
instructions(context) {
|
|
33
|
+
void context;
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Called before every tool call, prompt and resource read, after the scope and limit checks:
|
|
38
|
+
* may this grant still reach `target`? (The connection's binding names a project the user has
|
|
39
|
+
* left: false.) False answers GRANT_REFUSED; a thrown DokebiError answers its own code.
|
|
40
|
+
* Default: true.
|
|
41
|
+
*/
|
|
42
|
+
checkGrant(grant, target, context) {
|
|
43
|
+
void grant;
|
|
44
|
+
void target;
|
|
45
|
+
void context;
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Changes a tool result once before it leaves (hide values, list them in `masked`). Return an
|
|
50
|
+
* envelope: `amend(result, { content, masked: [...] })`. Runs before the size check. Default: the
|
|
51
|
+
* result unchanged.
|
|
52
|
+
*/
|
|
53
|
+
transformResult(result, call, context) {
|
|
54
|
+
void call;
|
|
55
|
+
void context;
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Hears once that a call ended (D-0134): every `tools/call`, `prompts/get` and `resources/read`
|
|
60
|
+
* the route dispatched, whatever the outcome (ok, a refusal before the handler such as
|
|
61
|
+
* LIMIT_EXCEEDED, INSUFFICIENT_SCOPE, INVALID_INPUT or GRANT_REFUSED, an error, TOOL_TIMEOUT,
|
|
62
|
+
* CANCELLED). It runs after the response was written and is never awaited: it cannot delay
|
|
63
|
+
* or change an answer. A throw or a rejection is logged at warn (MCP_ON_CALL_FAILED) and
|
|
64
|
+
* counted; at most `onCallMaxPending` returned promises are pending at once, and an entry over
|
|
65
|
+
* that is dropped with warn MCP_ON_CALL_DROPPED. Default: nothing.
|
|
66
|
+
*/
|
|
67
|
+
onCall(entry, context) {
|
|
68
|
+
void entry;
|
|
69
|
+
void context;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** The plugin's handle on a subclass instance (not exported from the package). */
|
|
73
|
+
export function mcpHooks(extension) {
|
|
74
|
+
return bridge(extension);
|
|
75
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type { McpCallStats } from './calls.js';
|
|
2
|
+
/** Use with `context.getDependency(dependency)`; a registered `dokebi-mcp` is reused. */
|
|
3
|
+
export { dependency, provider } from './dependency.js';
|
|
4
|
+
export { amend, envelope, IMAGE_MIME_TYPES, isEnvelope, item, type McpContentItem, type McpEnvelope, type McpEnvelopeInput, type McpHumanTurn, type McpImage, type McpImageInput, type McpImageMimeType, type McpMasked, type McpPage, type McpSource, type McpWrittenBy, PAGE_MAX_CHARS, page, } from './envelope.js';
|
|
5
|
+
export { McpError, type McpErrorCode } from './errors.js';
|
|
6
|
+
/**
|
|
7
|
+
* The extension base (D-0064): `export default class AppMcp extends McpExtension {}` in
|
|
8
|
+
* src/infra/mcp/extension.ts. Hooks: dependencies, instructions, checkGrant, transformResult,
|
|
9
|
+
* onCall (D-0134).
|
|
10
|
+
*/
|
|
11
|
+
export { type McpCallEntry, type McpCallMethod, McpExtension, type McpHookContext, type McpTarget, } from './extension.js';
|
|
12
|
+
export type { McpOptions } from './options.js';
|
|
13
|
+
/** The options declaration (D-0063): `options.parse(input)`, `options.schema`. */
|
|
14
|
+
export { options } from './options.js';
|
|
15
|
+
export type { McpApi, McpEntry } from './plugin.js';
|
|
16
|
+
/** Factory input: `{ app, options?, entry? }`. */
|
|
17
|
+
export { createPlugin } from './plugin.js';
|
|
18
|
+
export { LEGACY_VERSIONS, MODERN_VERSIONS, SUPPORTED_VERSIONS } from './protocol.js';
|
|
19
|
+
export type { JsonSchema, SchemaProblem } from './schema.js';
|
|
20
|
+
export { MAX_INPUT_DEPTH } from './schema.js';
|
|
21
|
+
export { type McpContext, type McpGrant, type McpImageLimits, McpPrompt, type McpPromptArgument, type McpPromptMessage, type McpPromptResult, type McpRateLimit, McpResource, type McpResourceContent, type McpResourceContext, McpTool, type McpToolAnnotations, type McpToolContext, mcpPrompts, mcpResources, mcpTools, } from './tool.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* @dokebi/mcp (D-0094): an MCP server on @dokebi/web and @dokebi/oauth — stateless Streamable
|
|
3
|
+
* HTTP on one route, tool classes with checked input schemas and one scope each, a forced result
|
|
4
|
+
* envelope with paging, pictures (D-0143) and size limits, per grant and bucket limits on @dokebi/rate-limit, coded
|
|
5
|
+
* tool errors, and the hooks checkGrant, transformResult and onCall.
|
|
6
|
+
*/
|
|
7
|
+
/** Use with `context.getDependency(dependency)`; a registered `dokebi-mcp` is reused. */
|
|
8
|
+
export { dependency, provider } from './dependency.js';
|
|
9
|
+
export { amend, envelope, IMAGE_MIME_TYPES, isEnvelope, item, PAGE_MAX_CHARS, page, } from './envelope.js';
|
|
10
|
+
export { McpError } from './errors.js';
|
|
11
|
+
/**
|
|
12
|
+
* The extension base (D-0064): `export default class AppMcp extends McpExtension {}` in
|
|
13
|
+
* src/infra/mcp/extension.ts. Hooks: dependencies, instructions, checkGrant, transformResult,
|
|
14
|
+
* onCall (D-0134).
|
|
15
|
+
*/
|
|
16
|
+
export { McpExtension, } from './extension.js';
|
|
17
|
+
/** The options declaration (D-0063): `options.parse(input)`, `options.schema`. */
|
|
18
|
+
export { options } from './options.js';
|
|
19
|
+
/** Factory input: `{ app, options?, entry? }`. */
|
|
20
|
+
export { createPlugin } from './plugin.js';
|
|
21
|
+
export { LEGACY_VERSIONS, MODERN_VERSIONS, SUPPORTED_VERSIONS } from './protocol.js';
|
|
22
|
+
export { MAX_INPUT_DEPTH } from './schema.js';
|
|
23
|
+
export { McpPrompt, McpResource, McpTool, mcpPrompts, mcpResources, mcpTools, } from './tool.js';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
interface InstallContext {
|
|
2
|
+
readonly options: Readonly<Record<string, unknown>>;
|
|
3
|
+
}
|
|
4
|
+
interface Diagnostic {
|
|
5
|
+
readonly code: string;
|
|
6
|
+
readonly severity: 'error' | 'warning';
|
|
7
|
+
readonly optionPath?: readonly string[];
|
|
8
|
+
readonly hint?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function planInstall(context: InstallContext): {
|
|
11
|
+
requires: string[];
|
|
12
|
+
entry: {
|
|
13
|
+
source: string;
|
|
14
|
+
target: string;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
/** An existing entry file is never replaced. */
|
|
18
|
+
export declare function planUpgrade(context: InstallContext): {
|
|
19
|
+
requires: string[];
|
|
20
|
+
entry: {
|
|
21
|
+
source: string;
|
|
22
|
+
target: string;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
export declare function check(context: InstallContext): {
|
|
26
|
+
requires: string[];
|
|
27
|
+
diagnostics: Diagnostic[];
|
|
28
|
+
};
|
|
29
|
+
export {};
|
package/dist/install.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Install lifecycle module (`dokebi.installModule`). It runs isolated in the CLI, so it imports
|
|
2
|
+
// nothing and only reads the plain options it is given. Invalid options are left to the schema.
|
|
3
|
+
const RATE_LIMIT_PROVIDER = 'dokebi-rate-limit';
|
|
4
|
+
/** The user-owned extension entry (D-0064): optional hooks (checkGrant, transformResult, …). */
|
|
5
|
+
const ENTRY = Object.freeze({
|
|
6
|
+
source: 'templates/extension.ts',
|
|
7
|
+
target: 'src/infra/mcp/extension.ts',
|
|
8
|
+
});
|
|
9
|
+
function plain(value) {
|
|
10
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
/** Limits need @dokebi/rate-limit (its policies count per grant and bucket). */
|
|
13
|
+
function requires(context) {
|
|
14
|
+
const limits = plain(context.options) ? context.options.limits : undefined;
|
|
15
|
+
return plain(limits) && Object.keys(limits).length ? [RATE_LIMIT_PROVIDER] : [];
|
|
16
|
+
}
|
|
17
|
+
export function planInstall(context) {
|
|
18
|
+
return { requires: requires(context), entry: ENTRY };
|
|
19
|
+
}
|
|
20
|
+
/** An existing entry file is never replaced. */
|
|
21
|
+
export function planUpgrade(context) {
|
|
22
|
+
return { requires: requires(context), entry: ENTRY };
|
|
23
|
+
}
|
|
24
|
+
export function check(context) {
|
|
25
|
+
const diagnostics = [];
|
|
26
|
+
const limits = plain(context.options) ? context.options.limits : undefined;
|
|
27
|
+
if (!plain(limits) || Object.keys(limits).length === 0)
|
|
28
|
+
diagnostics.push({
|
|
29
|
+
code: 'MCP_NO_LIMITS',
|
|
30
|
+
severity: 'warning',
|
|
31
|
+
optionPath: ['limits'],
|
|
32
|
+
hint: 'no per-connection limits: set limits.read and limits.write to @dokebi/rate-limit policies (a redis driver shares them between workers)',
|
|
33
|
+
});
|
|
34
|
+
return { requires: requires(context), diagnostics };
|
|
35
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type JsonRpcId = string | number;
|
|
2
|
+
export interface JsonRpcRequest {
|
|
3
|
+
readonly kind: 'request';
|
|
4
|
+
readonly id: JsonRpcId;
|
|
5
|
+
readonly method: string;
|
|
6
|
+
readonly params: Readonly<Record<string, unknown>>;
|
|
7
|
+
}
|
|
8
|
+
export interface JsonRpcNotification {
|
|
9
|
+
readonly kind: 'notification';
|
|
10
|
+
readonly method: string;
|
|
11
|
+
readonly params: Readonly<Record<string, unknown>>;
|
|
12
|
+
}
|
|
13
|
+
export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification;
|
|
14
|
+
/** A refusal before dispatch: an HTTP status and a JSON-RPC error body. */
|
|
15
|
+
export interface JsonRpcRefusal {
|
|
16
|
+
readonly kind: 'refusal';
|
|
17
|
+
readonly status: number;
|
|
18
|
+
readonly body: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
export declare function rpcResult(id: JsonRpcId, result: Record<string, unknown>): Record<string, unknown>;
|
|
21
|
+
export declare function rpcError(id: JsonRpcId | null, code: number, message: string, data?: Record<string, unknown>): Record<string, unknown>;
|
|
22
|
+
export declare function refusal(status: number, id: JsonRpcId | null, code: number, message: string, data?: Record<string, unknown>): JsonRpcRefusal;
|
|
23
|
+
export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
|
|
24
|
+
/** The body as JSON: a value, or a -32700 refusal (empty, not UTF-8, not JSON). */
|
|
25
|
+
export declare function parseBody(raw: Uint8Array | undefined): {
|
|
26
|
+
value: unknown;
|
|
27
|
+
} | JsonRpcRefusal;
|
|
28
|
+
/** One message of the body (or of a batch): a request, a notification or a -32600 refusal. */
|
|
29
|
+
export declare function readMessage(value: unknown): JsonRpcMessage | JsonRpcRefusal;
|
package/dist/jsonrpc.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { INVALID_REQUEST, PARSE_ERROR } from './protocol.js';
|
|
2
|
+
export function rpcResult(id, result) {
|
|
3
|
+
return { jsonrpc: '2.0', id, result };
|
|
4
|
+
}
|
|
5
|
+
export function rpcError(id, code, message, data) {
|
|
6
|
+
return {
|
|
7
|
+
jsonrpc: '2.0',
|
|
8
|
+
id,
|
|
9
|
+
error: data === undefined ? { code, message } : { code, message, data },
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function refusal(status, id, code, message, data) {
|
|
13
|
+
return { kind: 'refusal', status, body: rpcError(id, code, message, data) };
|
|
14
|
+
}
|
|
15
|
+
export function isPlainObject(value) {
|
|
16
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
/** MCP ids are strings or integers, never null. */
|
|
19
|
+
function validId(value) {
|
|
20
|
+
return typeof value === 'string' || (typeof value === 'number' && Number.isSafeInteger(value));
|
|
21
|
+
}
|
|
22
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
23
|
+
/** The body as JSON: a value, or a -32700 refusal (empty, not UTF-8, not JSON). */
|
|
24
|
+
export function parseBody(raw) {
|
|
25
|
+
let text;
|
|
26
|
+
try {
|
|
27
|
+
text = raw === undefined ? '' : decoder.decode(raw);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
// A decoding failure is the client's broken body: answered as a parse error, nothing lost.
|
|
31
|
+
if (!(error instanceof TypeError))
|
|
32
|
+
throw error;
|
|
33
|
+
return refusal(400, null, PARSE_ERROR, 'The body is not UTF-8: send one JSON-RPC message');
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
return { value: JSON.parse(text) };
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (!(error instanceof SyntaxError))
|
|
40
|
+
throw error;
|
|
41
|
+
return refusal(400, null, PARSE_ERROR, 'The body is not JSON: send one JSON-RPC message');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** One message of the body (or of a batch): a request, a notification or a -32600 refusal. */
|
|
45
|
+
export function readMessage(value) {
|
|
46
|
+
if (!isPlainObject(value))
|
|
47
|
+
return refusal(400, null, INVALID_REQUEST, 'A JSON-RPC message is an object');
|
|
48
|
+
const hasId = Object.hasOwn(value, 'id');
|
|
49
|
+
const id = hasId && validId(value.id) ? value.id : null;
|
|
50
|
+
if (value.jsonrpc !== '2.0')
|
|
51
|
+
return refusal(400, id, INVALID_REQUEST, 'jsonrpc must be "2.0"');
|
|
52
|
+
if (typeof value.method !== 'string' || value.method === '')
|
|
53
|
+
// A posted response lands here too: this server sends no requests, so it expects none.
|
|
54
|
+
return refusal(400, id, INVALID_REQUEST, 'method is missing: send a request or a notification');
|
|
55
|
+
const params = value.params;
|
|
56
|
+
if (params !== undefined && !isPlainObject(params))
|
|
57
|
+
return refusal(400, id, INVALID_REQUEST, 'params must be an object (MCP has no positional params)');
|
|
58
|
+
const method = value.method;
|
|
59
|
+
if (!hasId)
|
|
60
|
+
return { kind: 'notification', method, params: params ?? {} };
|
|
61
|
+
if (!validId(value.id))
|
|
62
|
+
return refusal(400, null, INVALID_REQUEST, 'id must be a string or an integer (not null)');
|
|
63
|
+
return { kind: 'request', id: value.id, method, params: params ?? {} };
|
|
64
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type OptionsDeclaration } from '@dokebi/core';
|
|
2
|
+
/** A limit bucket (`read`, `write`, `trash`). */
|
|
3
|
+
export declare const BUCKET: RegExp;
|
|
4
|
+
/** Default of `uriMaxChars`: the longest resource URI of `resources/read` (R4 audit, 2026-10-26). */
|
|
5
|
+
export declare const URI_MAX_CHARS = 8192;
|
|
6
|
+
/** The defaults of the image limits (D-0143), for `callTool` of @dokebi/mcp/testing too. */
|
|
7
|
+
export declare const IMAGE_LIMITS: Readonly<{
|
|
8
|
+
imageMaxBytes: 1048576;
|
|
9
|
+
imageTotalMaxBytes: 4194304;
|
|
10
|
+
maxImages: 8;
|
|
11
|
+
}>;
|
|
12
|
+
export interface McpOptions {
|
|
13
|
+
readonly resource: string;
|
|
14
|
+
/** The apps whose hosts answer the route (D-0132); absent: every host. */
|
|
15
|
+
readonly app?: string | readonly string[];
|
|
16
|
+
/** Absent: the path of the resource URI. */
|
|
17
|
+
readonly path?: string;
|
|
18
|
+
/** Route id overrides (D-0142); absent: `mcp.endpoint`. */
|
|
19
|
+
readonly routeIds?: {
|
|
20
|
+
readonly endpoint?: string;
|
|
21
|
+
};
|
|
22
|
+
/** Absent: the resource name. */
|
|
23
|
+
readonly name?: string;
|
|
24
|
+
readonly title?: string;
|
|
25
|
+
readonly version: string;
|
|
26
|
+
readonly instructions?: string;
|
|
27
|
+
readonly allowedOrigins: readonly string[];
|
|
28
|
+
readonly bodyLimitBytes: number;
|
|
29
|
+
readonly resultMaxChars: number;
|
|
30
|
+
/** Longest resource URI of resources/read in characters (default 8192). */
|
|
31
|
+
readonly uriMaxChars: number;
|
|
32
|
+
/** Largest picture of a tool result in bytes (default 1048576, D-0143). */
|
|
33
|
+
readonly imageMaxBytes: number;
|
|
34
|
+
/** Largest sum of the pictures of one tool result in bytes (default 4194304, D-0143). */
|
|
35
|
+
readonly imageTotalMaxBytes: number;
|
|
36
|
+
/** Pictures of one tool result at most (default 8, D-0143). */
|
|
37
|
+
readonly maxImages: number;
|
|
38
|
+
readonly toolTimeoutMs: number;
|
|
39
|
+
/** Messages of one JSON-RPC batch at most (default 32, D-0114). */
|
|
40
|
+
readonly maxBatch: number;
|
|
41
|
+
/** ttlMs of the modern list results (default 300000, D-0114). */
|
|
42
|
+
readonly listTtlMs: number;
|
|
43
|
+
/** Window of the repeated tool failure logs (default 10000, D-0114). */
|
|
44
|
+
readonly logRepeatWindowMs: number;
|
|
45
|
+
/** Pending onCall promises at most (default 1000, D-0134). */
|
|
46
|
+
readonly onCallMaxPending: number;
|
|
47
|
+
readonly limits: Readonly<Record<string, string>>;
|
|
48
|
+
}
|
|
49
|
+
/** The `dokebi-mcp` options declaration (parser and options.schema.json). */
|
|
50
|
+
export declare const options: OptionsDeclaration<McpOptions>;
|