@acid-cat/core-ts 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +151 -11
- package/dist/app.d.ts +73 -1
- package/dist/app.js +34 -2
- package/dist/cli.d.ts +4 -4
- package/dist/cli.js +111 -16
- package/dist/clock.d.ts +14 -0
- package/dist/clock.js +26 -0
- package/dist/context.d.ts +8 -0
- package/dist/context.js +14 -9
- package/dist/http.d.ts +4 -0
- package/dist/http.js +112 -30
- package/dist/index.d.ts +13 -5
- package/dist/index.js +5 -2
- package/dist/mcp-events.d.ts +87 -0
- package/dist/mcp-events.js +129 -0
- package/dist/mcp-poll.d.ts +28 -0
- package/dist/mcp-poll.js +179 -0
- package/dist/mcp-stdio.d.ts +13 -0
- package/dist/mcp-stdio.js +94 -13
- package/dist/mcp.d.ts +40 -4
- package/dist/mcp.js +102 -23
- package/dist/node.d.ts +2 -0
- package/dist/node.js +5 -1
- package/dist/session.d.ts +53 -3
- package/dist/session.js +129 -1
- package/dist/stream.d.ts +25 -0
- package/dist/stream.js +63 -0
- package/dist/subscription.d.ts +39 -0
- package/dist/subscription.js +145 -0
- package/dist/webhook.d.ts +40 -0
- package/dist/webhook.js +385 -0
- package/package.json +7 -5
package/dist/clock.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
//#region 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
|
+
//#endregion
|
|
26
|
+
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
1
|
//#region src/context.ts
|
|
2
|
-
/**
|
|
3
|
-
|
|
4
|
-
|
|
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) {
|
|
@@ -20,4 +25,4 @@ async function release(value) {
|
|
|
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
27
|
//#endregion
|
|
23
|
-
export {
|
|
28
|
+
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,6 +1,7 @@
|
|
|
1
1
|
import { createSession } from "./session.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { openContext } from "./context.js";
|
|
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";
|
|
4
5
|
//#region src/http.ts
|
|
5
6
|
/**
|
|
6
7
|
* The HTTP binding (spec/bindings/http.md): JSON through HTTP.
|
|
@@ -18,38 +19,119 @@ 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
|
+
let input;
|
|
28
46
|
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();
|
|
47
|
+
input = body.text === "" ? {} : JSON.parse(body.text);
|
|
48
|
+
} catch {
|
|
49
|
+
return json(422, callFailure("invalid-input", "The request body is not JSON."));
|
|
49
50
|
}
|
|
50
|
-
|
|
51
|
+
const id = decodeURIComponent(route.id);
|
|
52
|
+
if (route.kind === "capability") {
|
|
53
|
+
const outcome = await session.invoke(id, input, { signal: request.signal });
|
|
54
|
+
return json(httpStatus(outcome), isCallFailure(outcome) ? failureBody(outcome) : outcome);
|
|
55
|
+
}
|
|
56
|
+
const subscribed = await subscribe(session, id, input, url, request, {
|
|
57
|
+
keepaliveMs: options.keepaliveMs ?? KEEPALIVE_MS,
|
|
58
|
+
close
|
|
59
|
+
});
|
|
60
|
+
streaming = subscribed.streaming;
|
|
61
|
+
return subscribed.response;
|
|
62
|
+
} finally {
|
|
63
|
+
if (!streaming) await close();
|
|
64
|
+
}
|
|
51
65
|
};
|
|
52
66
|
}
|
|
67
|
+
/** The capability or the event of a `POST` route, or `undefined` for another path. */
|
|
68
|
+
function postRoute(path) {
|
|
69
|
+
if (path.startsWith("/capabilities/")) return {
|
|
70
|
+
kind: "capability",
|
|
71
|
+
id: path.slice(14)
|
|
72
|
+
};
|
|
73
|
+
if (path.startsWith("/events/")) return {
|
|
74
|
+
kind: "event",
|
|
75
|
+
id: path.slice(8)
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Subscribe to an event (spec/bindings/http.md#events). A call failure before the first occurrence is a JSON answer.
|
|
80
|
+
* After that, the answer is a stream of server-sent events, which closes the session when it ends.
|
|
81
|
+
*/
|
|
82
|
+
async function subscribe(session, id, input, url, request, stream) {
|
|
83
|
+
const maxAge = url.searchParams.get("maxAgeMs");
|
|
84
|
+
const maxAgeMs = maxAge === null ? void 0 : Number(maxAge);
|
|
85
|
+
if (maxAgeMs !== void 0 && (maxAge?.trim() === "" || !Number.isInteger(maxAgeMs) || maxAgeMs < 0)) return {
|
|
86
|
+
response: json(422, callFailure("invalid-input", "maxAgeMs must be a whole number of milliseconds.")),
|
|
87
|
+
streaming: false
|
|
88
|
+
};
|
|
89
|
+
const cursor = request.headers.get("Last-Event-ID") ?? void 0;
|
|
90
|
+
const subscribed = await session.subscribe(id, input, {
|
|
91
|
+
signal: request.signal,
|
|
92
|
+
...cursor === void 0 ? {} : { cursor },
|
|
93
|
+
...maxAgeMs === void 0 ? {} : { maxAgeMs }
|
|
94
|
+
});
|
|
95
|
+
if ("kind" in subscribed) return {
|
|
96
|
+
response: json(httpStatus(subscribed), failureBody(subscribed)),
|
|
97
|
+
streaming: false
|
|
98
|
+
};
|
|
99
|
+
const pull = idlePuller(subscribed, stream.keepaliveMs);
|
|
100
|
+
let ended = false;
|
|
101
|
+
const produce = async () => {
|
|
102
|
+
if (ended) return void 0;
|
|
103
|
+
const pulled = await pull();
|
|
104
|
+
if ("idle" in pulled) return [httpEventMessage.keepalive()];
|
|
105
|
+
if ("error" in pulled) {
|
|
106
|
+
ended = true;
|
|
107
|
+
return [httpEventMessage.error(internalOf(pulled.error))];
|
|
108
|
+
}
|
|
109
|
+
if (pulled.step.done === true) {
|
|
110
|
+
ended = true;
|
|
111
|
+
return [httpEventMessage.complete()];
|
|
112
|
+
}
|
|
113
|
+
return [httpEventMessage.next(pulled.step.value)];
|
|
114
|
+
};
|
|
115
|
+
const body = textStream(produce, () => void subscribed.return(), stream.close);
|
|
116
|
+
return {
|
|
117
|
+
response: new Response(body, {
|
|
118
|
+
status: 200,
|
|
119
|
+
headers: {
|
|
120
|
+
"Content-Type": SSE_MEDIA_TYPE,
|
|
121
|
+
"Cache-Control": "no-store"
|
|
122
|
+
}
|
|
123
|
+
}),
|
|
124
|
+
streaming: true
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** The call failure that ends a subscription. A subscription ends only with `internal`. */
|
|
128
|
+
function internalOf(error) {
|
|
129
|
+
return error instanceof CallFailureError ? callFailure(error.kind, error.message) : callFailure("internal", "The event failed.");
|
|
130
|
+
}
|
|
131
|
+
const failureBody = (failure) => ({
|
|
132
|
+
kind: failure.kind,
|
|
133
|
+
message: failure.message
|
|
134
|
+
});
|
|
53
135
|
const json = (status, body) => new Response(JSON.stringify(body), {
|
|
54
136
|
status,
|
|
55
137
|
headers: { "Content-Type": "application/json" }
|
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
|
+
import { DEFAULT_BUFFER_LIMIT } from "./subscription.js";
|
|
2
3
|
import { createSession } from "./session.js";
|
|
4
|
+
import { systemClock } from "./clock.js";
|
|
3
5
|
import { mcpHandler } from "./mcp.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,87 @@
|
|
|
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
|
+
export declare function subscriptionParams(params: JsonObject): {
|
|
56
|
+
readonly error: JsonRpcError;
|
|
57
|
+
} | {
|
|
58
|
+
readonly name: string;
|
|
59
|
+
readonly input: unknown;
|
|
60
|
+
readonly cursor: string | undefined;
|
|
61
|
+
readonly maxAgeMs: number | undefined;
|
|
62
|
+
};
|
|
63
|
+
/** The message of the call failure that ends a subscription. A subscription ends only with `internal`. */
|
|
64
|
+
export declare const endMessage: (error: unknown) => string;
|
|
65
|
+
/**
|
|
66
|
+
* The messages of one `events/stream` request, as a function that gives the next messages each time that it is called,
|
|
67
|
+
* or `undefined` after the result. `id` is the id of the request.
|
|
68
|
+
*/
|
|
69
|
+
export type StreamMessages = () => Promise<readonly JsonObject[] | undefined>;
|
|
70
|
+
export interface StreamSettings {
|
|
71
|
+
/** The time with nothing sent after which the server sends a heartbeat, in milliseconds. */
|
|
72
|
+
readonly heartbeatMs: number;
|
|
73
|
+
/** The `_meta` members of the result beside the subscription id, for example the server information. */
|
|
74
|
+
readonly resultMeta: JsonObject;
|
|
75
|
+
readonly signal: AbortSignal;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Start the subscription of an `events/stream` request. The result is the JSON-RPC error before the first occurrence,
|
|
79
|
+
* or the messages of the stream: `notifications/events/active`, then the occurrences and the heartbeats, then the
|
|
80
|
+
* result. `cancel` cancels the subscription.
|
|
81
|
+
*/
|
|
82
|
+
export declare function startEventStream(session: Session, id: string | number, params: JsonObject, settings: StreamSettings): Promise<{
|
|
83
|
+
readonly error: JsonRpcError;
|
|
84
|
+
} | {
|
|
85
|
+
readonly messages: StreamMessages;
|
|
86
|
+
readonly cancel: () => void;
|
|
87
|
+
}>;
|
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
//#region 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
|
+
function subscriptionParams(params) {
|
|
41
|
+
const { name, arguments: input, cursor, maxAgeMs } = params;
|
|
42
|
+
if (cursor !== void 0 && cursor !== null && typeof cursor !== "string") return invalidParams("cursor must be a string or null.");
|
|
43
|
+
if (maxAgeMs !== void 0 && maxAgeMs !== null && (typeof maxAgeMs !== "number" || !Number.isFinite(maxAgeMs) || maxAgeMs < 0)) return invalidParams("maxAgeMs must be a number of milliseconds.");
|
|
44
|
+
return {
|
|
45
|
+
name: typeof name === "string" ? name : "",
|
|
46
|
+
input: input ?? {},
|
|
47
|
+
cursor: typeof cursor === "string" ? cursor : void 0,
|
|
48
|
+
maxAgeMs: typeof maxAgeMs === "number" ? maxAgeMs : void 0
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** The message of the call failure that ends a subscription. A subscription ends only with `internal`. */
|
|
52
|
+
const endMessage = (error) => error instanceof CallFailureError ? error.message : "The event failed.";
|
|
53
|
+
/**
|
|
54
|
+
* Start the subscription of an `events/stream` request. The result is the JSON-RPC error before the first occurrence,
|
|
55
|
+
* or the messages of the stream: `notifications/events/active`, then the occurrences and the heartbeats, then the
|
|
56
|
+
* result. `cancel` cancels the subscription.
|
|
57
|
+
*/
|
|
58
|
+
async function startEventStream(session, id, params, settings) {
|
|
59
|
+
const read = subscriptionParams(params);
|
|
60
|
+
if ("error" in read) return read;
|
|
61
|
+
const { cursor, maxAgeMs } = read;
|
|
62
|
+
const subscribed = await session.subscribe(read.name, read.input, {
|
|
63
|
+
signal: settings.signal,
|
|
64
|
+
...cursor === void 0 ? {} : { cursor },
|
|
65
|
+
...maxAgeMs === void 0 ? {} : { maxAgeMs }
|
|
66
|
+
});
|
|
67
|
+
if ("kind" in subscribed) return { error: mcpSubscribeError(subscribed) };
|
|
68
|
+
const meta = { [MCP_SUBSCRIPTION_ID]: id };
|
|
69
|
+
const notification = (method, members) => ({
|
|
70
|
+
jsonrpc: "2.0",
|
|
71
|
+
method: `notifications/events/${method}`,
|
|
72
|
+
params: {
|
|
73
|
+
...members,
|
|
74
|
+
_meta: meta
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
const result = {
|
|
78
|
+
jsonrpc: "2.0",
|
|
79
|
+
id,
|
|
80
|
+
result: {
|
|
81
|
+
resultType: "complete",
|
|
82
|
+
_meta: {
|
|
83
|
+
...settings.resultMeta,
|
|
84
|
+
...meta
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const pull = idlePuller(subscribed, settings.heartbeatMs);
|
|
89
|
+
let state = "start";
|
|
90
|
+
let lastCursor = null;
|
|
91
|
+
const messages = async () => {
|
|
92
|
+
if (state === "ended") return void 0;
|
|
93
|
+
if (state === "start") {
|
|
94
|
+
state = "open";
|
|
95
|
+
return [notification("active", { truncated: false })];
|
|
96
|
+
}
|
|
97
|
+
const pulled = await pull();
|
|
98
|
+
if ("idle" in pulled) return [notification("heartbeat", { cursor: lastCursor })];
|
|
99
|
+
if ("error" in pulled) {
|
|
100
|
+
state = "ended";
|
|
101
|
+
return [notification("terminated", { error: {
|
|
102
|
+
code: MCP_EVENT_ERRORS.internal,
|
|
103
|
+
message: endMessage(pulled.error)
|
|
104
|
+
} }), result];
|
|
105
|
+
}
|
|
106
|
+
if (pulled.step.done === true) {
|
|
107
|
+
state = "ended";
|
|
108
|
+
return [result];
|
|
109
|
+
}
|
|
110
|
+
const occurrence = pulled.step.value;
|
|
111
|
+
if (occurrence.cursor !== void 0) lastCursor = occurrence.cursor;
|
|
112
|
+
const event = {
|
|
113
|
+
jsonrpc: "2.0",
|
|
114
|
+
method: "notifications/events/event",
|
|
115
|
+
params: mcpEventParams(occurrence, id)
|
|
116
|
+
};
|
|
117
|
+
if (occurrence.truncated !== true) return [event];
|
|
118
|
+
return [notification("active", {
|
|
119
|
+
truncated: true,
|
|
120
|
+
...occurrence.cursor === void 0 ? {} : { cursor: occurrence.cursor }
|
|
121
|
+
}), event];
|
|
122
|
+
};
|
|
123
|
+
return {
|
|
124
|
+
messages,
|
|
125
|
+
cancel: () => void subscribed.return()
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
//#endregion
|
|
129
|
+
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
|
+
}
|