@acid-cat/core-ts 0.9.0 → 0.10.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/README.md +152 -12
- package/dist/app.d.ts +73 -1
- package/dist/app.js +35 -4
- package/dist/cli.d.ts +4 -4
- package/dist/cli.js +116 -18
- package/dist/clock.d.ts +14 -0
- package/dist/clock.js +25 -0
- package/dist/context.d.ts +8 -0
- package/dist/context.js +15 -11
- package/dist/http.d.ts +4 -0
- package/dist/http.js +136 -32
- package/dist/index.d.ts +13 -5
- package/dist/index.js +5 -2
- package/dist/mcp-events.d.ts +90 -0
- package/dist/mcp-events.js +130 -0
- package/dist/mcp-poll.d.ts +28 -0
- package/dist/mcp-poll.js +181 -0
- package/dist/mcp-stdio.d.ts +13 -0
- package/dist/mcp-stdio.js +96 -16
- package/dist/mcp.d.ts +40 -4
- package/dist/mcp.js +103 -25
- package/dist/node.d.ts +2 -0
- package/dist/node.js +6 -3
- package/dist/session.d.ts +58 -3
- package/dist/session.js +144 -5
- package/dist/stream.d.ts +25 -0
- package/dist/stream.js +62 -0
- package/dist/subscription.d.ts +39 -0
- package/dist/subscription.js +144 -0
- package/dist/webhook.d.ts +40 -0
- package/dist/webhook.js +384 -0
- package/dist/webmcp.js +1 -2
- package/package.json +9 -7
package/dist/clock.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The time of the server: the current time, and timers. Poll leases and webhook subscriptions use it, so that a test
|
|
3
|
+
* can control the time.
|
|
4
|
+
*/
|
|
5
|
+
export interface Clock {
|
|
6
|
+
/** The current time, in milliseconds since the epoch. */
|
|
7
|
+
readonly now: () => number;
|
|
8
|
+
/** Call `callback` after `ms` milliseconds. The result cancels the timer. */
|
|
9
|
+
readonly schedule: (callback: () => void, ms: number) => () => void;
|
|
10
|
+
}
|
|
11
|
+
/** The clock of the system: `Date.now` and `setTimeout`. */
|
|
12
|
+
export declare const systemClock: Clock;
|
|
13
|
+
/** Wait `ms` milliseconds on a clock. The wait ends early, with `false`, when the signal aborts. */
|
|
14
|
+
export declare function sleep(clock: Clock, ms: number, signal: AbortSignal): Promise<boolean>;
|
package/dist/clock.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// src/clock.ts
|
|
2
|
+
/** The clock of the system: `Date.now` and `setTimeout`. */
|
|
3
|
+
const systemClock = {
|
|
4
|
+
now: () => Date.now(),
|
|
5
|
+
schedule: (callback, ms) => {
|
|
6
|
+
const timer = setTimeout(callback, ms);
|
|
7
|
+
return () => clearTimeout(timer);
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
/** Wait `ms` milliseconds on a clock. The wait ends early, with `false`, when the signal aborts. */
|
|
11
|
+
function sleep(clock, ms, signal) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
if (signal.aborted) return resolve(false);
|
|
14
|
+
const stop = () => {
|
|
15
|
+
cancel();
|
|
16
|
+
resolve(false);
|
|
17
|
+
};
|
|
18
|
+
const cancel = clock.schedule(() => {
|
|
19
|
+
signal.removeEventListener("abort", stop);
|
|
20
|
+
resolve(true);
|
|
21
|
+
}, ms);
|
|
22
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
export { sleep, systemClock };
|
package/dist/context.d.ts
CHANGED
|
@@ -16,3 +16,11 @@ export type ContextOption<Context> = {
|
|
|
16
16
|
};
|
|
17
17
|
/** Give the context of one request to `use`, and release it afterwards if the handler made it. */
|
|
18
18
|
export declare function withContext<Context, A>(option: ContextOption<Context>, request: Request, use: (context: Context) => Promise<A>): Promise<A>;
|
|
19
|
+
/**
|
|
20
|
+
* The context of one request, and the function that releases it if the handler made it. A response with a stream, such
|
|
21
|
+
* as the stream of a subscription, releases the context when the stream ends.
|
|
22
|
+
*/
|
|
23
|
+
export declare function openContext<Context>(option: ContextOption<Context>, request: Request): Promise<{
|
|
24
|
+
readonly context: Context;
|
|
25
|
+
readonly release: () => Promise<void>;
|
|
26
|
+
}>;
|
package/dist/context.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
|
-
|
|
2
|
-
/**
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
// src/context.ts
|
|
2
|
+
/**
|
|
3
|
+
* The context of one request, and the function that releases it if the handler made it. A response with a stream, such
|
|
4
|
+
* as the stream of a subscription, releases the context when the stream ends.
|
|
5
|
+
*/
|
|
6
|
+
async function openContext(option, request) {
|
|
7
|
+
if (option.createContext === void 0) return {
|
|
8
|
+
context: option.context,
|
|
9
|
+
release: () => Promise.resolve()
|
|
10
|
+
};
|
|
5
11
|
const context = await option.createContext(request);
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}
|
|
12
|
+
return {
|
|
13
|
+
context,
|
|
14
|
+
release: () => release(context)
|
|
15
|
+
};
|
|
11
16
|
}
|
|
12
17
|
/** Call `Symbol.asyncDispose` or `Symbol.dispose` of a value, if it has one. */
|
|
13
18
|
async function release(value) {
|
|
@@ -19,5 +24,4 @@ async function release(value) {
|
|
|
19
24
|
}
|
|
20
25
|
/** Tell if a value is a function. A function can take any arguments, and its result is not known. */
|
|
21
26
|
const isFunction = (value) => typeof value === "function";
|
|
22
|
-
|
|
23
|
-
export { withContext };
|
|
27
|
+
export { openContext };
|
package/dist/http.d.ts
CHANGED
|
@@ -14,6 +14,10 @@ export interface HttpSettings {
|
|
|
14
14
|
readonly serveCatalog?: boolean | undefined;
|
|
15
15
|
/** The maximum size of a request body, in bytes. The default is 1 MiB. */
|
|
16
16
|
readonly maximumBytes?: number | undefined;
|
|
17
|
+
/** The time with nothing sent on the stream of a subscription after which the binding sends a comment line, in milliseconds. The default is 15 seconds. */
|
|
18
|
+
readonly keepaliveMs?: number | undefined;
|
|
19
|
+
/** The number of occurrences that each subscription keeps for a client that reads slowly. The default is 256. */
|
|
20
|
+
readonly bufferLimit?: number | undefined;
|
|
17
21
|
}
|
|
18
22
|
/** A web request handler for the API. Each request has its own session, and with `createContext` its own context. */
|
|
19
23
|
export declare function httpHandler<Context>(app: App<Context>, options: HttpOptions<Context>): (request: Request) => Promise<Response>;
|
package/dist/http.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { openContext } from "./context.js";
|
|
1
2
|
import { createSession } from "./session.js";
|
|
2
|
-
import {
|
|
3
|
-
import { callFailure, httpStatus, isCallFailure, readBounded, screenRequest } from "@acid-cat/spec-ts";
|
|
4
|
-
|
|
3
|
+
import { idlePuller, textStream } from "./stream.js";
|
|
4
|
+
import { CallFailureError, KEEPALIVE_MS, SSE_MEDIA_TYPE, callFailure, httpEventMessage, httpStatus, isCallFailure, readBounded, screenRequest } from "@acid-cat/spec-ts";
|
|
5
|
+
// src/http.ts
|
|
5
6
|
/**
|
|
6
7
|
* The HTTP binding (spec/bindings/http.md): JSON through HTTP.
|
|
7
8
|
*/
|
|
@@ -18,38 +19,142 @@ function httpHandler(app, options) {
|
|
|
18
19
|
if (screened !== void 0) return rejection(screened);
|
|
19
20
|
const body = await readBounded(request, maximumBytes);
|
|
20
21
|
if ("rejection" in body) return rejection(body.rejection);
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
const url = new URL(request.url);
|
|
23
|
+
const path = url.pathname.slice((options.base ?? "").length);
|
|
24
|
+
const opened = await openContext(options, request);
|
|
25
|
+
const session = createSession(app, {
|
|
26
|
+
target: options.target ?? "api",
|
|
27
|
+
context: opened.context,
|
|
28
|
+
toJsonSchema: options.toJsonSchema,
|
|
29
|
+
...options.bufferLimit === void 0 ? {} : { bufferLimit: options.bufferLimit }
|
|
30
|
+
});
|
|
31
|
+
const close = async () => {
|
|
32
|
+
await session.dispose();
|
|
33
|
+
await opened.release();
|
|
34
|
+
};
|
|
35
|
+
let streaming = false;
|
|
36
|
+
try {
|
|
37
|
+
if (request.method === "GET" && path === "/capabilities") return json(200, [...session.handles.values()].map((handle) => ({
|
|
38
|
+
id: handle.id,
|
|
39
|
+
...handle.availability.getSnapshot()
|
|
40
|
+
})));
|
|
41
|
+
if (request.method === "GET" && path === "/events") return json(200, [...session.eventHandles.keys()].map((id) => ({ id })));
|
|
42
|
+
if (request.method === "GET" && path === "/catalog" && options.serveCatalog !== false) return json(200, session.view);
|
|
43
|
+
const route = request.method === "POST" ? postRoute(path) : void 0;
|
|
44
|
+
if (route === void 0) return json(404, callFailure("unknown-capability", "There is no such route."));
|
|
45
|
+
const id = decodedId(route.id);
|
|
46
|
+
if (id === void 0) return json(404, callFailure("unknown-capability", "There is no such route."));
|
|
47
|
+
let input;
|
|
28
48
|
try {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (request.method === "GET" && path === "/catalog" && options.serveCatalog !== false) return json(200, session.view);
|
|
34
|
-
const id = request.method === "POST" && path.startsWith("/capabilities/") ? path.slice(14) : void 0;
|
|
35
|
-
if (id === void 0) return json(404, callFailure("unknown-capability", "There is no such route."));
|
|
36
|
-
let input;
|
|
37
|
-
try {
|
|
38
|
-
input = body.text === "" ? {} : JSON.parse(body.text);
|
|
39
|
-
} catch {
|
|
40
|
-
return json(422, callFailure("invalid-input", "The request body is not JSON."));
|
|
41
|
-
}
|
|
42
|
-
const outcome = await session.invoke(decodeURIComponent(id), input, { signal: request.signal });
|
|
43
|
-
return json(httpStatus(outcome), isCallFailure(outcome) ? {
|
|
44
|
-
kind: outcome.kind,
|
|
45
|
-
message: outcome.message
|
|
46
|
-
} : outcome);
|
|
47
|
-
} finally {
|
|
48
|
-
await session.dispose();
|
|
49
|
+
input = body.text === "" ? {} : JSON.parse(body.text);
|
|
50
|
+
} catch {
|
|
51
|
+
const failure = notJson(session, route.kind, id, request.signal);
|
|
52
|
+
return json(httpStatus(failure), failureBody(failure));
|
|
49
53
|
}
|
|
50
|
-
|
|
54
|
+
if (route.kind === "capability") {
|
|
55
|
+
const outcome = await session.invoke(id, input, { signal: request.signal });
|
|
56
|
+
return json(httpStatus(outcome), isCallFailure(outcome) ? failureBody(outcome) : outcome);
|
|
57
|
+
}
|
|
58
|
+
const subscribed = await subscribe(session, id, input, url, request, {
|
|
59
|
+
keepaliveMs: options.keepaliveMs ?? KEEPALIVE_MS,
|
|
60
|
+
close
|
|
61
|
+
});
|
|
62
|
+
streaming = subscribed.streaming;
|
|
63
|
+
return subscribed.response;
|
|
64
|
+
} finally {
|
|
65
|
+
if (!streaming) await close();
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** The capability or the event of a `POST` route, or `undefined` for another path. */
|
|
70
|
+
function postRoute(path) {
|
|
71
|
+
if (path.startsWith("/capabilities/")) return {
|
|
72
|
+
kind: "capability",
|
|
73
|
+
id: path.slice(14)
|
|
74
|
+
};
|
|
75
|
+
if (path.startsWith("/events/")) return {
|
|
76
|
+
kind: "event",
|
|
77
|
+
id: path.slice(8)
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** The id of a route with its escapes decoded, or `undefined` if an escape is malformed, as in `%E0`. */
|
|
81
|
+
function decodedId(encoded) {
|
|
82
|
+
try {
|
|
83
|
+
return decodeURIComponent(encoded);
|
|
84
|
+
} catch {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The call failure of a request whose body is not JSON. The kinds before `invalid-input` apply first
|
|
90
|
+
* (spec/invocation.md#call-failures, spec/events.md#subscribing): `cancelled`, `unknown-capability`, then `unavailable`
|
|
91
|
+
* for a capability.
|
|
92
|
+
*/
|
|
93
|
+
function notJson(session, kind, id, signal) {
|
|
94
|
+
if (session.signal.aborted || signal.aborted) return callFailure("cancelled", "The call was cancelled.");
|
|
95
|
+
if (kind === "event") {
|
|
96
|
+
if (!session.eventHandles.has(id)) return callFailure("unknown-capability", `There is no event ${id}.`);
|
|
97
|
+
} else {
|
|
98
|
+
const handle = session.handles.get(id);
|
|
99
|
+
if (handle === void 0) return callFailure("unknown-capability", `There is no capability ${id}.`);
|
|
100
|
+
const availability = handle.availability.getSnapshot();
|
|
101
|
+
if (!availability.available) return callFailure("unavailable", availability.reason);
|
|
102
|
+
}
|
|
103
|
+
return callFailure("invalid-input", "The request body is not JSON.");
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Subscribe to an event (spec/bindings/http.md#events). A call failure before the first occurrence is a JSON answer.
|
|
107
|
+
* After that, the answer is a stream of server-sent events, which closes the session when it ends.
|
|
108
|
+
*/
|
|
109
|
+
async function subscribe(session, id, input, url, request, stream) {
|
|
110
|
+
const maxAge = url.searchParams.get("maxAgeMs");
|
|
111
|
+
const maxAgeMs = maxAge === null ? void 0 : /^[0-9]+$/.test(maxAge) ? Number(maxAge) : NaN;
|
|
112
|
+
const cursor = request.headers.get("Last-Event-ID") ?? void 0;
|
|
113
|
+
const subscribed = await session.subscribe(id, input, {
|
|
114
|
+
signal: request.signal,
|
|
115
|
+
...cursor === void 0 ? {} : { cursor },
|
|
116
|
+
...maxAgeMs === void 0 ? {} : { maxAgeMs }
|
|
117
|
+
});
|
|
118
|
+
if ("kind" in subscribed) return {
|
|
119
|
+
response: json(httpStatus(subscribed), failureBody(subscribed)),
|
|
120
|
+
streaming: false
|
|
121
|
+
};
|
|
122
|
+
const pull = idlePuller(subscribed, stream.keepaliveMs);
|
|
123
|
+
let ended = false;
|
|
124
|
+
const produce = async () => {
|
|
125
|
+
if (ended) return void 0;
|
|
126
|
+
const pulled = await pull();
|
|
127
|
+
if ("idle" in pulled) return [httpEventMessage.keepalive()];
|
|
128
|
+
if ("error" in pulled) {
|
|
129
|
+
ended = true;
|
|
130
|
+
return [httpEventMessage.error(internalOf(pulled.error))];
|
|
131
|
+
}
|
|
132
|
+
if (pulled.step.done === true) {
|
|
133
|
+
ended = true;
|
|
134
|
+
return [httpEventMessage.complete()];
|
|
135
|
+
}
|
|
136
|
+
return [httpEventMessage.next(pulled.step.value)];
|
|
137
|
+
};
|
|
138
|
+
const body = textStream(produce, () => void subscribed.return(), stream.close);
|
|
139
|
+
return {
|
|
140
|
+
response: new Response(body, {
|
|
141
|
+
status: 200,
|
|
142
|
+
headers: {
|
|
143
|
+
"Content-Type": SSE_MEDIA_TYPE,
|
|
144
|
+
"Cache-Control": "no-store"
|
|
145
|
+
}
|
|
146
|
+
}),
|
|
147
|
+
streaming: true
|
|
51
148
|
};
|
|
52
149
|
}
|
|
150
|
+
/** The call failure that ends a subscription. A subscription ends only with `internal`. */
|
|
151
|
+
function internalOf(error) {
|
|
152
|
+
return error instanceof CallFailureError ? callFailure(error.kind, error.message) : callFailure("internal", "The event failed.");
|
|
153
|
+
}
|
|
154
|
+
const failureBody = (failure) => ({
|
|
155
|
+
kind: failure.kind,
|
|
156
|
+
message: failure.message
|
|
157
|
+
});
|
|
53
158
|
const json = (status, body) => new Response(JSON.stringify(body), {
|
|
54
159
|
status,
|
|
55
160
|
headers: { "Content-Type": "application/json" }
|
|
@@ -69,5 +174,4 @@ function rejection(reason) {
|
|
|
69
174
|
headers
|
|
70
175
|
});
|
|
71
176
|
}
|
|
72
|
-
//#endregion
|
|
73
177
|
export { httpHandler };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
|
-
export { capabilitiesFor, catalogOf, defineApp, defineCapability, renderCatalog } from "./app.js";
|
|
2
|
-
export type { AnyCapability, App, Call, Capability, CapabilityDeclaration, CapabilityFactory, EmptyInput, FailureOf, Observable, Target, ToJsonSchema } from "./app.js";
|
|
1
|
+
export { capabilitiesFor, catalogOf, defineApp, defineCapability, defineEvent, eventsFor, renderCatalog } from "./app.js";
|
|
2
|
+
export type { AnyCapability, AnyEventDefinition, App, Call, Capability, CapabilityDeclaration, CapabilityFactory, EmptyInput, EventDeclaration, EventDefinition, EventFactory, FailureOf, Observable, Subscribe, Subscription, Target, ToJsonSchema, } from "./app.js";
|
|
3
3
|
export { createSession } from "./session.js";
|
|
4
|
-
export type { Handle, HandleOf, Session, SessionOptions } from "./session.js";
|
|
4
|
+
export type { EventHandle, EventHandleOf, Handle, HandleOf, Session, SessionOptions, SubscribeOptions } from "./session.js";
|
|
5
|
+
export { DEFAULT_BUFFER_LIMIT } from "./subscription.js";
|
|
6
|
+
export type { Occurrences } from "./subscription.js";
|
|
5
7
|
export { mcpHandler } from "./mcp.js";
|
|
6
|
-
export type { McpOptions, McpSettings } from "./mcp.js";
|
|
8
|
+
export type { McpHandler, McpOptions, McpSettings } from "./mcp.js";
|
|
9
|
+
export type { PollOptions } from "./mcp-poll.js";
|
|
10
|
+
export type { JsonRpcError, OpenSession, OwnSession, RpcAnswer, WebhookDelivery } from "./mcp-events.js";
|
|
11
|
+
export { systemClock } from "./clock.js";
|
|
12
|
+
export type { Clock } from "./clock.js";
|
|
13
|
+
export { mcpHandshake } from "@acid-cat/mcp-handshake";
|
|
14
|
+
export type { McpHandshakeOptions } from "@acid-cat/mcp-handshake";
|
|
7
15
|
export { main as mcpStdioMain, runMcpStdio } from "./mcp-stdio.js";
|
|
8
16
|
export type { McpStdioIo, McpStdioOptions } from "./mcp-stdio.js";
|
|
9
17
|
export { httpHandler } from "./http.js";
|
|
@@ -15,4 +23,4 @@ export { findHost, installWebMcp } from "./webmcp.js";
|
|
|
15
23
|
export type { Installation, ModelContext, ModelContextTool, Phase, Status } from "./webmcp.js";
|
|
16
24
|
export type { StandardSchemaV1 } from "./standard-schema.js";
|
|
17
25
|
export { AVAILABLE, CallFailureError, fail, ok, unavailable } from "@acid-cat/spec-ts";
|
|
18
|
-
export type { Availability, CallFailure, CallFailureKind, Outcome, Result } from "@acid-cat/spec-ts";
|
|
26
|
+
export type { Availability, CallFailure, CallFailureKind, Emitted, Occurrence, Outcome, Result } from "@acid-cat/spec-ts";
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { capabilitiesFor, catalogOf, defineApp, defineCapability, renderCatalog } from "./app.js";
|
|
1
|
+
import { capabilitiesFor, catalogOf, defineApp, defineCapability, defineEvent, eventsFor, renderCatalog } from "./app.js";
|
|
2
2
|
import { createSession } from "./session.js";
|
|
3
|
+
import { DEFAULT_BUFFER_LIMIT } from "./subscription.js";
|
|
3
4
|
import { mcpHandler } from "./mcp.js";
|
|
5
|
+
import { systemClock } from "./clock.js";
|
|
4
6
|
import { main as main$1, runMcpStdio } from "./mcp-stdio.js";
|
|
5
7
|
import { httpHandler } from "./http.js";
|
|
6
8
|
import { main, runCli } from "./cli.js";
|
|
7
9
|
import { findHost, installWebMcp } from "./webmcp.js";
|
|
8
10
|
import { AVAILABLE, CallFailureError, fail, ok, unavailable } from "@acid-cat/spec-ts";
|
|
9
|
-
|
|
11
|
+
import { mcpHandshake } from "@acid-cat/mcp-handshake";
|
|
12
|
+
export { AVAILABLE, CallFailureError, DEFAULT_BUFFER_LIMIT, capabilitiesFor, catalogOf, main as cliMain, createSession, defineApp, defineCapability, defineEvent, eventsFor, fail, findHost, httpHandler, installWebMcp, mcpHandler, mcpHandshake, main$1 as mcpStdioMain, ok, renderCatalog, runCli, runMcpStdio, systemClock, unavailable };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { JsonObject } from "@acid-cat/spec-ts";
|
|
2
|
+
import type { Session } from "./session.js";
|
|
3
|
+
/** A JSON-RPC error. */
|
|
4
|
+
export interface JsonRpcError {
|
|
5
|
+
readonly code: number;
|
|
6
|
+
readonly message: string;
|
|
7
|
+
readonly data?: JsonObject;
|
|
8
|
+
}
|
|
9
|
+
/** True if the view of the session has events. The server serves the extension only then. */
|
|
10
|
+
export declare const servesEvents: (session: Session) => boolean;
|
|
11
|
+
/** The members that the capabilities of `server/discover` get for events: the extension, if the view has events. */
|
|
12
|
+
export declare const eventCapabilities: (session: Session) => JsonObject;
|
|
13
|
+
/** The entries of `events/list`: one for each event in the view. `webhook` is true when webhook delivery is on. */
|
|
14
|
+
export declare function listEvents(session: Session, webhook?: boolean): JsonObject[];
|
|
15
|
+
/** The error of `events/subscribe` and `events/unsubscribe` when webhook delivery is off, or through stdio. */
|
|
16
|
+
export declare const unsupportedWebhook: JsonRpcError;
|
|
17
|
+
/** The answer to a request: its result, with no `resultType` and no `_meta`, or a JSON-RPC error. */
|
|
18
|
+
export type RpcAnswer = {
|
|
19
|
+
readonly result: JsonObject;
|
|
20
|
+
} | {
|
|
21
|
+
readonly error: JsonRpcError;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* A session of its own for a poll lease or a webhook subscription (spec/bindings/mcp.md#subscriptions-that-outlive-a-request).
|
|
25
|
+
* `close` closes the session, and releases its context. A second call does nothing more.
|
|
26
|
+
*/
|
|
27
|
+
export interface OwnSession {
|
|
28
|
+
readonly session: Session;
|
|
29
|
+
readonly close: () => Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
/** Open a session of its own, with the identity and the services of the request that starts the lease or the subscription. */
|
|
32
|
+
export type OpenSession = () => Promise<OwnSession>;
|
|
33
|
+
/**
|
|
34
|
+
* Webhook delivery (spec/bindings/mcp.md#webhook), as the MCP server through HTTP uses it. `webhookDelivery` of
|
|
35
|
+
* `@acid-cat/core-ts/node` makes one. The server checks the principal before it calls these functions.
|
|
36
|
+
*/
|
|
37
|
+
export interface WebhookDelivery {
|
|
38
|
+
/** Answer `events/subscribe`. */
|
|
39
|
+
readonly subscribe: (principal: string, params: JsonObject, open: OpenSession) => Promise<RpcAnswer>;
|
|
40
|
+
/** Answer `events/unsubscribe`. */
|
|
41
|
+
readonly unsubscribe: (principal: string, params: JsonObject) => Promise<RpcAnswer>;
|
|
42
|
+
/** End all webhook subscriptions. */
|
|
43
|
+
readonly close: () => Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
/** The error `Forbidden`: a request to `events/subscribe` or `events/unsubscribe` with no principal. */
|
|
46
|
+
export declare const forbidden: JsonRpcError;
|
|
47
|
+
/** The error `-32602` with a message. */
|
|
48
|
+
export declare const invalidParams: (message: string) => {
|
|
49
|
+
readonly error: JsonRpcError;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Read the subscription parameters (spec/bindings/mcp.md#events): the id, the input, the cursor and `maxAgeMs`. An
|
|
53
|
+
* absent cursor and a `null` cursor are the same. The input is `{}` when `arguments` is absent.
|
|
54
|
+
*
|
|
55
|
+
* This function does not check `maxAgeMs`: an unknown id comes before an invalid `maxAgeMs` (spec/events.md#subscribing),
|
|
56
|
+
* so the session checks it after the id. A `maxAgeMs` that is not a number is `NaN`, which the session rejects.
|
|
57
|
+
*/
|
|
58
|
+
export declare function subscriptionParams(params: JsonObject): {
|
|
59
|
+
readonly error: JsonRpcError;
|
|
60
|
+
} | {
|
|
61
|
+
readonly name: string;
|
|
62
|
+
readonly input: unknown;
|
|
63
|
+
readonly cursor: string | undefined;
|
|
64
|
+
readonly maxAgeMs: number | undefined;
|
|
65
|
+
};
|
|
66
|
+
/** The message of the call failure that ends a subscription. A subscription ends only with `internal`. */
|
|
67
|
+
export declare const endMessage: (error: unknown) => string;
|
|
68
|
+
/**
|
|
69
|
+
* The messages of one `events/stream` request, as a function that gives the next messages each time that it is called,
|
|
70
|
+
* or `undefined` after the result. `id` is the id of the request.
|
|
71
|
+
*/
|
|
72
|
+
export type StreamMessages = () => Promise<readonly JsonObject[] | undefined>;
|
|
73
|
+
export interface StreamSettings {
|
|
74
|
+
/** The time with nothing sent after which the server sends a heartbeat, in milliseconds. */
|
|
75
|
+
readonly heartbeatMs: number;
|
|
76
|
+
/** The `_meta` members of the result beside the subscription id, for example the server information. */
|
|
77
|
+
readonly resultMeta: JsonObject;
|
|
78
|
+
readonly signal: AbortSignal;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Start the subscription of an `events/stream` request. The result is the JSON-RPC error before the first occurrence,
|
|
82
|
+
* or the messages of the stream: `notifications/events/active`, then the occurrences and the heartbeats, then the
|
|
83
|
+
* result. `cancel` cancels the subscription.
|
|
84
|
+
*/
|
|
85
|
+
export declare function startEventStream(session: Session, id: string | number, params: JsonObject, settings: StreamSettings): Promise<{
|
|
86
|
+
readonly error: JsonRpcError;
|
|
87
|
+
} | {
|
|
88
|
+
readonly messages: StreamMessages;
|
|
89
|
+
readonly cancel: () => void;
|
|
90
|
+
}>;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { idlePuller } from "./stream.js";
|
|
2
|
+
import { CallFailureError, MCP_EVENTS_EXTENSION, MCP_EVENT_ERRORS, MCP_SUBSCRIPTION_ID, bundle, mcpEventEntry, mcpEventParams, mcpSubscribeError } from "@acid-cat/spec-ts";
|
|
3
|
+
// src/mcp-events.ts
|
|
4
|
+
/**
|
|
5
|
+
* Events through MCP (spec/bindings/mcp.md#events): the MCP events extension. This module serves push delivery, and
|
|
6
|
+
* declares the parts that poll and webhook delivery share. The HTTP and the stdio transports share these functions.
|
|
7
|
+
*/
|
|
8
|
+
/** True if the view of the session has events. The server serves the extension only then. */
|
|
9
|
+
const servesEvents = (session) => Object.keys(session.view.events ?? {}).length > 0;
|
|
10
|
+
/** The members that the capabilities of `server/discover` get for events: the extension, if the view has events. */
|
|
11
|
+
const eventCapabilities = (session) => servesEvents(session) ? { extensions: { [MCP_EVENTS_EXTENSION]: {} } } : {};
|
|
12
|
+
/** The entries of `events/list`: one for each event in the view. `webhook` is true when webhook delivery is on. */
|
|
13
|
+
function listEvents(session, webhook = false) {
|
|
14
|
+
const types = session.view.$defs ?? {};
|
|
15
|
+
return Object.entries(session.view.events ?? {}).map(([id, event]) => mcpEventEntry(id, event.description, event.input === void 0 ? void 0 : bundle(event.input, types), event.payload === void 0 ? void 0 : bundle(event.payload, types), webhook));
|
|
16
|
+
}
|
|
17
|
+
/** The error of `events/subscribe` and `events/unsubscribe` when webhook delivery is off, or through stdio. */
|
|
18
|
+
const unsupportedWebhook = {
|
|
19
|
+
code: MCP_EVENT_ERRORS.unsupported,
|
|
20
|
+
message: "Unsupported",
|
|
21
|
+
data: {
|
|
22
|
+
feature: "deliveryMode",
|
|
23
|
+
value: "webhook"
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
/** The error `Forbidden`: a request to `events/subscribe` or `events/unsubscribe` with no principal. */
|
|
27
|
+
const forbidden = {
|
|
28
|
+
code: MCP_EVENT_ERRORS.forbidden,
|
|
29
|
+
message: "Forbidden"
|
|
30
|
+
};
|
|
31
|
+
/** The error `-32602` with a message. */
|
|
32
|
+
const invalidParams = (message) => ({ error: {
|
|
33
|
+
code: MCP_EVENT_ERRORS.invalidParams,
|
|
34
|
+
message
|
|
35
|
+
} });
|
|
36
|
+
/**
|
|
37
|
+
* Read the subscription parameters (spec/bindings/mcp.md#events): the id, the input, the cursor and `maxAgeMs`. An
|
|
38
|
+
* absent cursor and a `null` cursor are the same. The input is `{}` when `arguments` is absent.
|
|
39
|
+
*
|
|
40
|
+
* This function does not check `maxAgeMs`: an unknown id comes before an invalid `maxAgeMs` (spec/events.md#subscribing),
|
|
41
|
+
* so the session checks it after the id. A `maxAgeMs` that is not a number is `NaN`, which the session rejects.
|
|
42
|
+
*/
|
|
43
|
+
function subscriptionParams(params) {
|
|
44
|
+
const { name, arguments: input, cursor, maxAgeMs } = params;
|
|
45
|
+
if (cursor !== void 0 && cursor !== null && typeof cursor !== "string") return invalidParams("cursor must be a string or null.");
|
|
46
|
+
return {
|
|
47
|
+
name: typeof name === "string" ? name : "",
|
|
48
|
+
input: input ?? {},
|
|
49
|
+
cursor: typeof cursor === "string" ? cursor : void 0,
|
|
50
|
+
maxAgeMs: maxAgeMs === void 0 || maxAgeMs === null ? void 0 : typeof maxAgeMs === "number" ? maxAgeMs : NaN
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** The message of the call failure that ends a subscription. A subscription ends only with `internal`. */
|
|
54
|
+
const endMessage = (error) => error instanceof CallFailureError ? error.message : "The event failed.";
|
|
55
|
+
/**
|
|
56
|
+
* Start the subscription of an `events/stream` request. The result is the JSON-RPC error before the first occurrence,
|
|
57
|
+
* or the messages of the stream: `notifications/events/active`, then the occurrences and the heartbeats, then the
|
|
58
|
+
* result. `cancel` cancels the subscription.
|
|
59
|
+
*/
|
|
60
|
+
async function startEventStream(session, id, params, settings) {
|
|
61
|
+
const read = subscriptionParams(params);
|
|
62
|
+
if ("error" in read) return read;
|
|
63
|
+
const { cursor, maxAgeMs } = read;
|
|
64
|
+
const subscribed = await session.subscribe(read.name, read.input, {
|
|
65
|
+
signal: settings.signal,
|
|
66
|
+
...cursor === void 0 ? {} : { cursor },
|
|
67
|
+
...maxAgeMs === void 0 ? {} : { maxAgeMs }
|
|
68
|
+
});
|
|
69
|
+
if ("kind" in subscribed) return { error: mcpSubscribeError(subscribed) };
|
|
70
|
+
const meta = { [MCP_SUBSCRIPTION_ID]: id };
|
|
71
|
+
const notification = (method, members) => ({
|
|
72
|
+
jsonrpc: "2.0",
|
|
73
|
+
method: `notifications/events/${method}`,
|
|
74
|
+
params: {
|
|
75
|
+
...members,
|
|
76
|
+
_meta: meta
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
const result = {
|
|
80
|
+
jsonrpc: "2.0",
|
|
81
|
+
id,
|
|
82
|
+
result: {
|
|
83
|
+
resultType: "complete",
|
|
84
|
+
_meta: {
|
|
85
|
+
...settings.resultMeta,
|
|
86
|
+
...meta
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
const pull = idlePuller(subscribed, settings.heartbeatMs);
|
|
91
|
+
let state = "start";
|
|
92
|
+
let lastCursor = null;
|
|
93
|
+
const messages = async () => {
|
|
94
|
+
if (state === "ended") return void 0;
|
|
95
|
+
if (state === "start") {
|
|
96
|
+
state = "open";
|
|
97
|
+
return [notification("active", { truncated: false })];
|
|
98
|
+
}
|
|
99
|
+
const pulled = await pull();
|
|
100
|
+
if ("idle" in pulled) return [notification("heartbeat", { cursor: lastCursor })];
|
|
101
|
+
if ("error" in pulled) {
|
|
102
|
+
state = "ended";
|
|
103
|
+
return [notification("terminated", { error: {
|
|
104
|
+
code: MCP_EVENT_ERRORS.internal,
|
|
105
|
+
message: endMessage(pulled.error)
|
|
106
|
+
} }), result];
|
|
107
|
+
}
|
|
108
|
+
if (pulled.step.done === true) {
|
|
109
|
+
state = "ended";
|
|
110
|
+
return [result];
|
|
111
|
+
}
|
|
112
|
+
const occurrence = pulled.step.value;
|
|
113
|
+
if (occurrence.cursor !== void 0) lastCursor = occurrence.cursor;
|
|
114
|
+
const event = {
|
|
115
|
+
jsonrpc: "2.0",
|
|
116
|
+
method: "notifications/events/event",
|
|
117
|
+
params: mcpEventParams(occurrence, id)
|
|
118
|
+
};
|
|
119
|
+
if (occurrence.truncated !== true) return [event];
|
|
120
|
+
return [notification("active", {
|
|
121
|
+
truncated: true,
|
|
122
|
+
...occurrence.cursor === void 0 ? {} : { cursor: occurrence.cursor }
|
|
123
|
+
}), event];
|
|
124
|
+
};
|
|
125
|
+
return {
|
|
126
|
+
messages,
|
|
127
|
+
cancel: () => void subscribed.return()
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
export { endMessage, eventCapabilities, forbidden, invalidParams, listEvents, servesEvents, startEventStream, subscriptionParams, unsupportedWebhook };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { JsonObject } from "@acid-cat/spec-ts";
|
|
2
|
+
import type { Clock } from "./clock.js";
|
|
3
|
+
import type { OpenSession, RpcAnswer } from "./mcp-events.js";
|
|
4
|
+
/** The settings of poll delivery. Each one has the default of spec/bindings/mcp.md#poll. */
|
|
5
|
+
export interface PollOptions {
|
|
6
|
+
/** The time after the last poll at which a lease ends, in milliseconds. The default is 5 minutes. */
|
|
7
|
+
readonly leaseMs?: number | undefined;
|
|
8
|
+
/** The number of occurrences that each lease keeps. The default is 1000. */
|
|
9
|
+
readonly bufferLimit?: number | undefined;
|
|
10
|
+
/** The number of occurrences in a result when the request gives no `maxEvents`. The default is 100. */
|
|
11
|
+
readonly maxEvents?: number | undefined;
|
|
12
|
+
/** The `nextPollMs` of each result. The default is 2000. */
|
|
13
|
+
readonly nextPollMs?: number | undefined;
|
|
14
|
+
}
|
|
15
|
+
/** The poll leases of one server. `close` ends them all. */
|
|
16
|
+
export declare class PollLeases {
|
|
17
|
+
#private;
|
|
18
|
+
constructor(options?: PollOptions, clock?: Clock);
|
|
19
|
+
/** The number of leases, for tests. */
|
|
20
|
+
get size(): number;
|
|
21
|
+
/**
|
|
22
|
+
* Answer `events/poll`. `principal` is the identity of the client, or `undefined` if the deployment gives none.
|
|
23
|
+
* `open` opens the session of a new lease.
|
|
24
|
+
*/
|
|
25
|
+
poll(principal: string | undefined, params: JsonObject, open: OpenSession): Promise<RpcAnswer>;
|
|
26
|
+
/** End all leases. */
|
|
27
|
+
close(): Promise<void>;
|
|
28
|
+
}
|