@acid-cat/core-ts 0.10.2 → 0.11.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 +49 -3
- package/dist/app.d.ts +8 -3
- package/dist/cli.d.ts +13 -2
- package/dist/cli.js +70 -29
- package/dist/context.d.ts +36 -8
- package/dist/context.js +36 -6
- package/dist/http-client.d.ts +62 -0
- package/dist/http-client.js +265 -0
- package/dist/http.d.ts +4 -1
- package/dist/http.js +6 -2
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/mcp.d.ts +4 -2
- package/dist/mcp.js +38 -17
- package/dist/session.js +3 -1
- package/dist/webhook.js +30 -7
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ export const aircon = defineApp<{ unit: Unit }>({
|
|
|
33
33
|
});
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
- `run` gets the input, an `AbortSignal
|
|
36
|
+
- `run` gets the input, and `{ signal, context, target }`: an `AbortSignal`, the context, and the target of the session, for example `"mcp"` or `"cli"`. It returns `ok(value)` or `fail(failure)`, for example `fail({ _tag: "Offline", since })`. The types check that the value agrees with the output schema, that the failure has a tag of `failures`, and that it has the members of the schema of that tag. If `run` throws, the call fails as `internal`.
|
|
37
37
|
- With an input schema, `run` gets the output of the schema. With none, it gets an empty object.
|
|
38
38
|
- A failure schema describes the whole failure object, with its `_tag`.
|
|
39
39
|
- `availability` is an optional function that returns an observable availability. A session reads it with `getSnapshot` when a call comes in. It subscribes to it only while `onChange` or the handle has listeners.
|
|
@@ -66,7 +66,7 @@ export const statusChanged = event({
|
|
|
66
66
|
export const aircon = defineApp<{ unit: Unit }>({ targets, capabilities: [powerSet], events: [statusChanged] });
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
- `subscribe` gets the input, and `{ signal, context, cursor, maxAgeMs }`. The signal aborts when the subscription ends, in all ways. Stop the work and release the resources then.
|
|
69
|
+
- `subscribe` gets the input, and `{ signal, context, target, cursor, maxAgeMs }`. `target` is the target of the session. The signal aborts when the subscription ends, in all ways. Stop the work and release the resources then.
|
|
70
70
|
- Each value is `{ data }`, and optionally `eventId`, `timestamp`, `cursor` and `truncated`. Give the `eventId` of the source if it has one. With none, the session makes a unique id. With no `timestamp`, the session uses the time at which it gets the value.
|
|
71
71
|
- `data` must agree with the payload schema. The session gives the client the output of the schema, as a copy. With no payload schema, `data` must be an empty object.
|
|
72
72
|
- If `subscribe` throws before it gives an iterable, the subscription fails as `internal` before it starts. If the iterable throws, or gives data that does not agree with the payload schema, the subscription ends with `internal`. The details go to `onDefect`, not to the client.
|
|
@@ -97,6 +97,7 @@ Standard Schema does not convert to JSON Schema. Give the converter of your sche
|
|
|
97
97
|
| `mcpStdioMain(app, options)`, `runMcpStdio(…)` | [MCP](../../spec/bindings/mcp.md#stdio) through stdio | The MCP server of the process, or a promise that resolves when the input ends |
|
|
98
98
|
| `httpHandler(app, options)` | [HTTP](../../spec/bindings/http.md) | `(request: Request) => Promise<Response>` |
|
|
99
99
|
| `cliMain(app, options)`, `runCli(…)` | [CLI](../../spec/bindings/cli.md) | The command line of the process, or an exit code |
|
|
100
|
+
| `httpClient(url, options)` | [CLI](../../spec/bindings/cli.md#server), [HTTP](../../spec/bindings/http.md) | A client of the HTTP binding of a server, for the option `remote` of the command line |
|
|
100
101
|
| `installWebMcp(session)` | [WebMCP](../../spec/bindings/webmcp.md) | Tools in the page, with a status. WebMCP serves no events |
|
|
101
102
|
| `serve(handler, options)`, from `@acid-cat/core-ts/node` | MCP and HTTP | A Node.js server for the web handlers. See [Serve on Node.js](#serve-on-nodejs) |
|
|
102
103
|
|
|
@@ -115,7 +116,7 @@ The options `bufferLimit` of `createSession`, `httpHandler`, `mcpHandler` and `r
|
|
|
115
116
|
|
|
116
117
|
### MCP poll and webhook delivery
|
|
117
118
|
|
|
118
|
-
A poll lease and a webhook subscription continue after the request that started them. Each one holds its subscription in a session of its own. Through HTTP, that session has the context of the request that started it: the shared `context`, or
|
|
119
|
+
A poll lease and a webhook subscription continue after the request that started them. Each one holds its subscription in a session of its own. Through HTTP, that session has the context of the request that started it: the shared `context`, or the context that `createContext` made for that request. Through stdio, it has the `context` of the process. The session closes when the lease or the webhook subscription ends. This state is in memory: when the process stops, it is lost, and clients start again with their cursors.
|
|
119
120
|
|
|
120
121
|
`handler.close()` of `mcpHandler` ends all leases and webhook subscriptions. `await using handler = mcpHandler(…)` calls it at the end of the block. Through stdio, the end of standard input ends them.
|
|
121
122
|
|
|
@@ -273,6 +274,30 @@ const handler = httpHandler(aircon, {
|
|
|
273
274
|
});
|
|
274
275
|
```
|
|
275
276
|
|
|
277
|
+
`createContext` can refuse a request: it gives `refuse(status, message, headers?)` in place of a context. Use it to authenticate the caller, and give the caller to the capabilities in the context:
|
|
278
|
+
|
|
279
|
+
```ts
|
|
280
|
+
import { httpHandler, refuse } from "@acid-cat/core-ts";
|
|
281
|
+
|
|
282
|
+
const handler = httpHandler(aircon, {
|
|
283
|
+
allowedOrigins: [],
|
|
284
|
+
toJsonSchema,
|
|
285
|
+
createContext: async (request) => {
|
|
286
|
+
const caller = await accounts.caller(request.headers.get("Authorization"));
|
|
287
|
+
if (caller === undefined) return refuse(401, "Give a valid token.", { "WWW-Authenticate": "Bearer" });
|
|
288
|
+
return { unit, caller };
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
- A refusal answers the request with its status, its headers, the media type `application/json`, and the body `{ "message" }`. The handler opens no session. No capability runs, and no subscription starts. The message shows no internal data.
|
|
294
|
+
- The status of a refusal is an integer from 400 to 599. `refuse` throws a `RangeError` for another status.
|
|
295
|
+
- `isRefusal(value)` tells if a value is a refusal that `refuse` made.
|
|
296
|
+
- The handler releases the context after the answer, or when the stream of a subscription ends.
|
|
297
|
+
- Through MCP, a poll lease or a webhook subscription uses the context of the request that started it. The handler calls `createContext` once for each request. It releases the context when the answer is done and each lease and webhook subscription of the request has ended.
|
|
298
|
+
- Set the option `principal` of `mcpHandler` too, from the same caller. A poll lease is keyed by the principal, the event and the input: with no principal, two callers that poll the same event with the same input share one lease, and the second caller reads the occurrences of the session of the first caller.
|
|
299
|
+
- The command line and MCP through stdio have one context for the program.
|
|
300
|
+
|
|
276
301
|
### Command line
|
|
277
302
|
|
|
278
303
|
The command line has one command for each capability in the view of the `cli` target. The words of the id give the path of the command. A shared prefix only contains other commands: if the path of a capability is the start of the path of another capability, for example `aircon.status` and `aircon.status.get`, `runCli` rejects and `cliMain` stops the program with an error when it starts. Each top-level property of the input is a flag with the same name, for example `--celsius 22` or `--celsius=22`. A boolean property is a switch, for example `--on`. A property with an array of strings, numbers or values of a string enum is a repeated flag: each occurrence gives one item, in the order of the flags. A repeated flag that occurs once can also give the whole array as JSON text. Other properties take JSON text. For example, with the command line of the air conditioner, [`examples/aircon/core-ts/cli.ts`](../../examples/aircon/core-ts/cli.ts):
|
|
@@ -316,6 +341,27 @@ const report: CliCommand<{ unit: Unit }> = {
|
|
|
316
341
|
cliMain(aircon, { name: "aircon", version: "0.1.0", context: { unit }, toJsonSchema, description: "Control the air conditioner.", commands: [report] });
|
|
317
342
|
```
|
|
318
343
|
|
|
344
|
+
### Command line of a server
|
|
345
|
+
|
|
346
|
+
The command line can serve a view of an application that runs on a server ([Server](../../spec/bindings/cli.md#server)). Give the option `remote` of `cliMain` and `runCli` a client of the HTTP binding of the server: `httpClient(url, { headers?, fetch? })`. The commands, the flags and the help still come from the view in the program. Each call is `POST {url}/capabilities/{id}`, `list` gets the availability from `GET {url}/capabilities`, and `watch` is `POST {url}/events/{id}`, with the cursor in `Last-Event-ID` and `maxAgeMs` in the query.
|
|
347
|
+
|
|
348
|
+
- A capability that the server does not list is unavailable in `list`.
|
|
349
|
+
- A server that does not answer is the call failure `unavailable`, with exit code 4. An answer that the HTTP binding does not give is `internal`, with exit code 5.
|
|
350
|
+
- An interrupt (`SIGINT`) closes the request, so the server cancels the call or the subscription. The exit code is 130.
|
|
351
|
+
- The client sends `headers` with each request, for example a token in `Authorization`. The command line never writes them. `httpClient` throws when the URL is not an `http` or `https` URL, when it has a user name, a password, a query or a fragment, and when a header is not valid. The error does not contain the value of a header.
|
|
352
|
+
|
|
353
|
+
Serve the HTTP binding with the target of the command line, so that both have the same view. [`examples/aircon/core-ts/serve.ts`](../../examples/aircon/core-ts/serve.ts) serves it, and [`examples/aircon/core-ts/cli.ts`](../../examples/aircon/core-ts/cli.ts) sends to it when `AIRCON_URL` is set:
|
|
354
|
+
|
|
355
|
+
```ts
|
|
356
|
+
// On the server.
|
|
357
|
+
serve({ "/cli": httpHandler(aircon, { allowedOrigins: [], target: "cli", base: "/cli", context: { unit }, toJsonSchema }) }, { hostname: "0.0.0.0", port: 8080 });
|
|
358
|
+
|
|
359
|
+
// In the command line.
|
|
360
|
+
cliMain(aircon, { name: "aircon", version: "0.1.0", context: { unit: new Unit() }, toJsonSchema, remote: httpClient("http://pi.local:8080/cli", { headers: { Authorization: `Bearer ${token}` } }) });
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
The client works alone too: `capabilities()`, `invoke(id, input, { signal })` and `subscribe(id, input, { signal, cursor, maxAgeMs })`. No method rejects: each one gives the call failure.
|
|
364
|
+
|
|
319
365
|
## Examples and tests
|
|
320
366
|
|
|
321
367
|
- [`examples/aircon/core-ts`](../../examples/aircon/README.md) is an air conditioner written with this package, with its command line. [`examples/aircon/apis`](../../examples/aircon/README.md#apis) builds GraphQL, Cap'n Web and Elysia APIs on it, each beside the HTTP binding.
|
package/dist/app.d.ts
CHANGED
|
@@ -23,10 +23,12 @@ export type FailureOf<Failures> = {
|
|
|
23
23
|
export type FailureSchemas<Failures> = {
|
|
24
24
|
readonly [Tag in keyof Failures]: StandardSchemaV1<unknown, Failures[Tag]>;
|
|
25
25
|
};
|
|
26
|
-
/** What `run` receives beside the input: the signal of the call, and the
|
|
26
|
+
/** What `run` receives beside the input: the signal of the call, the context, and the target of the session. */
|
|
27
27
|
export interface Call<Context> {
|
|
28
28
|
readonly signal: AbortSignal;
|
|
29
29
|
readonly context: Context;
|
|
30
|
+
/** The target of the session, for example `"mcp"` or `"cli"`. */
|
|
31
|
+
readonly target: string;
|
|
30
32
|
}
|
|
31
33
|
/** The function of a capability. Its result has the types of the output schema and of the failure schemas. */
|
|
32
34
|
export type Run<Context, Input, Value, Failures> = (input: Input, call: Call<Context>) => Promise<Result<NoInfer<Value>, NoInfer<FailureOf<Failures>>>> | Result<NoInfer<Value>, NoInfer<FailureOf<Failures>>>;
|
|
@@ -122,12 +124,15 @@ export interface CapabilityFactory<Context> {
|
|
|
122
124
|
*/
|
|
123
125
|
export declare const capabilitiesFor: <Context>() => CapabilityFactory<Context>;
|
|
124
126
|
/**
|
|
125
|
-
* What `subscribe` receives beside the input: the signal of the subscription, the context,
|
|
126
|
-
* starts (spec/events.md#resuming). The signal aborts when the subscription ends, in all
|
|
127
|
+
* What `subscribe` receives beside the input: the signal of the subscription, the context, the target of the session,
|
|
128
|
+
* and where the subscription starts (spec/events.md#resuming). The signal aborts when the subscription ends, in all
|
|
129
|
+
* ways.
|
|
127
130
|
*/
|
|
128
131
|
export interface Subscription<Context> {
|
|
129
132
|
readonly signal: AbortSignal;
|
|
130
133
|
readonly context: Context;
|
|
134
|
+
/** The target of the session, for example `"mcp"` or `"cli"`. */
|
|
135
|
+
readonly target: string;
|
|
131
136
|
/** The cursor of the client. With none, the subscription starts now. */
|
|
132
137
|
readonly cursor?: string | undefined;
|
|
133
138
|
/** The client wants no occurrence that is older than this number of milliseconds. */
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { App, ToJsonSchema } from "./app.js";
|
|
2
|
+
import type { HttpClient } from "./http-client.js";
|
|
2
3
|
export interface CliOptions<Context> {
|
|
3
4
|
readonly name: string;
|
|
4
5
|
readonly version: string;
|
|
@@ -16,6 +17,13 @@ export interface CliOptions<Context> {
|
|
|
16
17
|
* the command of a capability (spec/bindings/cli.md#commands).
|
|
17
18
|
*/
|
|
18
19
|
readonly commands?: readonly CliCommand<Context>[] | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* The HTTP binding of the application in another process or on another machine (spec/bindings/cli.md#server). With
|
|
22
|
+
* it, the command line sends each call, the availability of `list` and each subscription of `watch` to that server.
|
|
23
|
+
* The commands still come from the view of `target` in this program. Serve the HTTP binding with the same target, for
|
|
24
|
+
* example `httpHandler(app, { target: "cli", … })`. The commands of the application still get `context`.
|
|
25
|
+
*/
|
|
26
|
+
readonly remote?: HttpClient | undefined;
|
|
19
27
|
}
|
|
20
28
|
export interface CliIo {
|
|
21
29
|
readonly stdout: (text: string) => void;
|
|
@@ -32,7 +40,10 @@ export interface CliCommand<Context> {
|
|
|
32
40
|
readonly context: Context;
|
|
33
41
|
}): number | Promise<number>;
|
|
34
42
|
}
|
|
35
|
-
/**
|
|
36
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Run the command line as the main program of the process. An interrupt (`SIGINT`) cancels the call or the subscription.
|
|
45
|
+
* The default arguments are the arguments of the process.
|
|
46
|
+
*/
|
|
47
|
+
export declare function main<Context>(app: App<Context>, options: CliOptions<Context>, args?: readonly string[]): void;
|
|
37
48
|
/** Run the command line with arguments. The result is the exit code (spec/bindings/cli.md#outcomes). */
|
|
38
49
|
export declare function runCli<Context>(app: App<Context>, options: CliOptions<Context>, args: readonly string[], io: CliIo): Promise<number>;
|
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,40 @@
|
|
|
1
|
+
import { catalogOf } from "./app.js";
|
|
1
2
|
import { createSession } from "./session.js";
|
|
2
|
-
import { CLI_EXIT_CODES, CLI_RESUME_FLAGS, CLI_WATCH_EXIT_CODES, CallFailureError, bundle, cliEventUsage, cliExitCode, cliFlags, cliJsonArray, cliRepeatedItems, cliResolve, cliUsage, commandPath, isCallFailure, isJsonObject } from "@acid-cat/spec-ts";
|
|
3
|
+
import { CLI_EXIT_CODES, CLI_RESUME_FLAGS, CLI_WATCH_EXIT_CODES, CallFailureError, bundle, cliEventUsage, cliExitCode, cliFlags, cliJsonArray, cliRepeatedItems, cliResolve, cliUsage, commandPath, isCallFailure, isJsonObject, unavailable, viewOf } from "@acid-cat/spec-ts";
|
|
3
4
|
// src/cli.ts
|
|
4
5
|
/**
|
|
5
6
|
* The CLI binding (spec/bindings/cli.md): a command line made from the catalog.
|
|
6
7
|
*/
|
|
8
|
+
function localProvider(app, options) {
|
|
9
|
+
const session = createSession(app, {
|
|
10
|
+
target: options.target ?? "cli",
|
|
11
|
+
context: options.context,
|
|
12
|
+
toJsonSchema: options.toJsonSchema
|
|
13
|
+
});
|
|
14
|
+
return {
|
|
15
|
+
get view() {
|
|
16
|
+
return session.view;
|
|
17
|
+
},
|
|
18
|
+
availability: async () => new Map([...session.handles.values()].map((handle) => [handle.id, handle.availability.getSnapshot()])),
|
|
19
|
+
invoke: (id, input, signal) => session.invoke(id, input, { signal }),
|
|
20
|
+
subscribe: (id, input, subscribeOptions) => session.subscribe(id, input, subscribeOptions),
|
|
21
|
+
dispose: () => session.dispose()
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function remoteProvider(app, options, client) {
|
|
25
|
+
const view = viewOf(catalogOf(app, { toJsonSchema: options.toJsonSchema }), options.target ?? "cli");
|
|
26
|
+
return {
|
|
27
|
+
view,
|
|
28
|
+
availability: async (signal) => {
|
|
29
|
+
const served = await client.capabilities({ signal });
|
|
30
|
+
if ("kind" in served) return served;
|
|
31
|
+
return new Map(Object.keys(view.capabilities).map((id) => [id, served.get(id) ?? unavailable("The server does not serve this capability.")]));
|
|
32
|
+
},
|
|
33
|
+
invoke: (id, input, signal) => client.invoke(id, input, { signal }),
|
|
34
|
+
subscribe: (id, input, subscribeOptions) => client.subscribe(id, input, subscribeOptions),
|
|
35
|
+
dispose: async () => void 0
|
|
36
|
+
};
|
|
37
|
+
}
|
|
7
38
|
/** The commands of the command line itself. No other command can have their names. */
|
|
8
39
|
const GENERIC_COMMANDS = [
|
|
9
40
|
"call",
|
|
@@ -17,11 +48,14 @@ var UsageError = class extends Error {
|
|
|
17
48
|
this.exitCode = exitCode;
|
|
18
49
|
}
|
|
19
50
|
};
|
|
20
|
-
/**
|
|
21
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Run the command line as the main program of the process. An interrupt (`SIGINT`) cancels the call or the subscription.
|
|
53
|
+
* The default arguments are the arguments of the process.
|
|
54
|
+
*/
|
|
55
|
+
function main(app, options, args = process.argv.slice(2)) {
|
|
22
56
|
const interrupt = new AbortController();
|
|
23
57
|
process.once("SIGINT", () => interrupt.abort());
|
|
24
|
-
runCli(app, options,
|
|
58
|
+
runCli(app, options, args, {
|
|
25
59
|
stdout: (text) => process.stdout.write(`${text}\n`),
|
|
26
60
|
stderr: (text) => process.stderr.write(`${text}\n`),
|
|
27
61
|
signal: interrupt.signal
|
|
@@ -31,13 +65,10 @@ function main(app, options) {
|
|
|
31
65
|
}
|
|
32
66
|
/** Run the command line with arguments. The result is the exit code (spec/bindings/cli.md#outcomes). */
|
|
33
67
|
async function runCli(app, options, args, io) {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
});
|
|
39
|
-
const types = session.view.$defs ?? {};
|
|
40
|
-
const commands = Object.keys(session.view.capabilities).map((id) => ({
|
|
68
|
+
const provider = options.remote === void 0 ? localProvider(app, options) : remoteProvider(app, options, options.remote);
|
|
69
|
+
const view = provider.view;
|
|
70
|
+
const types = view.$defs ?? {};
|
|
71
|
+
const commands = Object.keys(view.capabilities).map((id) => ({
|
|
41
72
|
id,
|
|
42
73
|
path: commandPath(id)
|
|
43
74
|
}));
|
|
@@ -46,7 +77,7 @@ async function runCli(app, options, args, io) {
|
|
|
46
77
|
const longer = commands.find((other) => other.path.length > path.length && path.every((word, index) => other.path[index] === word));
|
|
47
78
|
if (longer !== void 0) throw new Error(`The command ${path.join(" ")} is also the start of the command ${longer.path.join(" ")}. A shared prefix only contains other commands.`);
|
|
48
79
|
}
|
|
49
|
-
for (const [id, event] of Object.entries(
|
|
80
|
+
for (const [id, event] of Object.entries(view.events ?? {})) {
|
|
50
81
|
const properties = event.input === void 0 ? void 0 : bundle(event.input, types).properties;
|
|
51
82
|
const taken = CLI_RESUME_FLAGS.find((flag) => isJsonObject(properties) && flag in properties);
|
|
52
83
|
if (taken !== void 0) throw new Error(`The input of the event ${id} has a property ${taken}, which is a flag of watch.`);
|
|
@@ -71,7 +102,7 @@ async function runCli(app, options, args, io) {
|
|
|
71
102
|
const [id, ...args] = rest;
|
|
72
103
|
const { flagArgs, resume } = resumeFlags(args);
|
|
73
104
|
if (id === void 0) throw new UsageError("Give the id of an event.", CLI_EXIT_CODES["invalid-input"]);
|
|
74
|
-
const event =
|
|
105
|
+
const event = view.events?.[id];
|
|
75
106
|
let input = {};
|
|
76
107
|
if (event !== void 0) {
|
|
77
108
|
const schema = bundle(event.input ?? { type: "object" }, types);
|
|
@@ -97,7 +128,7 @@ async function runCli(app, options, args, io) {
|
|
|
97
128
|
}
|
|
98
129
|
}
|
|
99
130
|
}
|
|
100
|
-
const subscribed = await
|
|
131
|
+
const subscribed = await provider.subscribe(id, input, {
|
|
101
132
|
signal: io.signal,
|
|
102
133
|
...resume
|
|
103
134
|
});
|
|
@@ -120,7 +151,7 @@ async function runCli(app, options, args, io) {
|
|
|
120
151
|
const [first, ...rest] = args;
|
|
121
152
|
if (first === void 0 || first === "--help" || first === "-h") {
|
|
122
153
|
const description = options.description ?? app.description ?? app.title ?? options.name;
|
|
123
|
-
io.stdout(help(options.name, description, commands.map(({ path }) => path.join(" ")), Object.keys(
|
|
154
|
+
io.stdout(help(options.name, description, commands.map(({ path }) => path.join(" ")), Object.keys(view.events ?? {}).length > 0, added));
|
|
124
155
|
return CLI_EXIT_CODES.value;
|
|
125
156
|
}
|
|
126
157
|
if (first === "--version" || first === "-v") {
|
|
@@ -128,16 +159,15 @@ async function runCli(app, options, args, io) {
|
|
|
128
159
|
return CLI_EXIT_CODES.value;
|
|
129
160
|
}
|
|
130
161
|
if (first === "list") {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}), ...Object.entries(session.view.events ?? {}).map(([id, event]) => ({
|
|
162
|
+
const availability = await provider.availability(io.signal);
|
|
163
|
+
if ("kind" in availability) return print(availability);
|
|
164
|
+
io.stdout(JSON.stringify([...Object.entries(view.capabilities).map(([id, capability]) => ({
|
|
165
|
+
kind: "capability",
|
|
166
|
+
id,
|
|
167
|
+
description: capability.description,
|
|
168
|
+
...availability.get(id) ?? unavailable("There is no such capability."),
|
|
169
|
+
usage: cliUsage(options.name, id, capability.input === void 0 ? void 0 : bundle(capability.input, types))
|
|
170
|
+
})), ...Object.entries(view.events ?? {}).map(([id, event]) => ({
|
|
141
171
|
kind: "event",
|
|
142
172
|
id,
|
|
143
173
|
description: event.description,
|
|
@@ -152,12 +182,23 @@ async function runCli(app, options, args, io) {
|
|
|
152
182
|
try {
|
|
153
183
|
input = JSON.parse(text);
|
|
154
184
|
} catch {
|
|
185
|
+
if (provider.view.capabilities[id] === void 0) return print({
|
|
186
|
+
kind: "unknown-capability",
|
|
187
|
+
message: `There is no capability ${id}.`
|
|
188
|
+
});
|
|
189
|
+
const availability = await provider.availability(io.signal);
|
|
190
|
+
if ("kind" in availability) return print(availability);
|
|
191
|
+
const state = availability.get(id);
|
|
192
|
+
if (state !== void 0 && !state.available) return print({
|
|
193
|
+
kind: "unavailable",
|
|
194
|
+
message: state.reason
|
|
195
|
+
});
|
|
155
196
|
return print({
|
|
156
197
|
kind: "invalid-input",
|
|
157
198
|
message: "The input is not JSON."
|
|
158
199
|
});
|
|
159
200
|
}
|
|
160
|
-
return print(await
|
|
201
|
+
return print(await provider.invoke(id, input, io.signal));
|
|
161
202
|
}
|
|
162
203
|
if (first === "watch") return await watch(rest);
|
|
163
204
|
const command = added.find(({ name }) => name === first);
|
|
@@ -167,20 +208,20 @@ async function runCli(app, options, args, io) {
|
|
|
167
208
|
});
|
|
168
209
|
const match = commands.filter(({ path }) => path.every((word, index) => args[index] === word)).sort((a, b) => b.path.length - a.path.length)[0];
|
|
169
210
|
if (match === void 0) throw new UsageError(`There is no command ${args.filter((arg) => !arg.startsWith("-")).join(" ")}.`, CLI_EXIT_CODES["unknown-capability"]);
|
|
170
|
-
const capability = viewCapability(
|
|
211
|
+
const capability = viewCapability(view, match.id);
|
|
171
212
|
const schema = bundle(capability.input ?? { type: "object" }, types);
|
|
172
213
|
const flagArgs = args.slice(match.path.length);
|
|
173
214
|
if (flagArgs.includes("--help") || flagArgs.includes("-h")) {
|
|
174
215
|
io.stdout(usage(options.name, match.path, capability.description, schema));
|
|
175
216
|
return CLI_EXIT_CODES.value;
|
|
176
217
|
}
|
|
177
|
-
return print(await
|
|
218
|
+
return print(await provider.invoke(match.id, parseFlags(flagArgs, schema), io.signal));
|
|
178
219
|
} catch (error) {
|
|
179
220
|
if (!(error instanceof UsageError)) throw error;
|
|
180
221
|
io.stderr(error.message);
|
|
181
222
|
return error.exitCode;
|
|
182
223
|
} finally {
|
|
183
|
-
await
|
|
224
|
+
await provider.dispose();
|
|
184
225
|
}
|
|
185
226
|
}
|
|
186
227
|
/**
|
package/dist/context.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The context of the web handlers: one context that all requests share, or a new context for each request.
|
|
2
|
+
* The context of the web handlers: one context that all requests share, or a new context for each request. The
|
|
3
|
+
* function that makes the context of a request can refuse the request (spec/bindings/README.md#remote-bindings).
|
|
3
4
|
*/
|
|
4
5
|
export type ContextOption<Context> = {
|
|
5
6
|
/** The context of all requests. */
|
|
@@ -9,18 +10,45 @@ export type ContextOption<Context> = {
|
|
|
9
10
|
/**
|
|
10
11
|
* Make the context of one request. The handler calls it for each request that passes the screening. When the
|
|
11
12
|
* context has `Symbol.asyncDispose` or `Symbol.dispose`, the handler calls it after the response, as a `using`
|
|
12
|
-
* declaration does.
|
|
13
|
+
* declaration does. Through MCP, a poll lease or a webhook subscription that the request starts keeps the context:
|
|
14
|
+
* the handler calls it when the last of them ends. Give a `Refusal` from `refuse` to answer the request with no session: for example `401` when
|
|
15
|
+
* the request has no valid token.
|
|
13
16
|
*/
|
|
14
|
-
readonly createContext: (request: Request) => Context | Promise<Context>;
|
|
17
|
+
readonly createContext: (request: Request) => Context | Refusal | Promise<Context | Refusal>;
|
|
15
18
|
readonly context?: undefined;
|
|
16
19
|
};
|
|
17
|
-
/**
|
|
18
|
-
|
|
20
|
+
/** The key that marks a refusal. A context cannot have it by accident. */
|
|
21
|
+
declare const refusalKey: unique symbol;
|
|
22
|
+
/** The answer that refuses a request before the handler opens a session. `refuse` makes it. */
|
|
23
|
+
export interface Refusal {
|
|
24
|
+
readonly [refusalKey]: true;
|
|
25
|
+
/** The status of the answer, for example `401` or `403`. */
|
|
26
|
+
readonly status: number;
|
|
27
|
+
/** The text of the answer, in its JSON body `{ "message" }`. Show no internal data. */
|
|
28
|
+
readonly message: string;
|
|
29
|
+
/** The other headers of the answer, for example `WWW-Authenticate`. */
|
|
30
|
+
readonly headers: Headers;
|
|
31
|
+
}
|
|
19
32
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
33
|
+
* Refuse a request: `createContext` gives the result instead of a context. The handler answers with `status`, the media
|
|
34
|
+
* type `application/json`, the body `{ "message": message }`, and `headers`. No capability runs, and no subscription
|
|
35
|
+
* starts. `status` must be an integer from 400 to 599: an other value throws a `RangeError`.
|
|
22
36
|
*/
|
|
23
|
-
export declare function
|
|
37
|
+
export declare function refuse(status: number, message: string, headers?: HeadersInit): Refusal;
|
|
38
|
+
/** Tell if a value is a refusal that `refuse` made. */
|
|
39
|
+
export declare function isRefusal(value: unknown): value is Refusal;
|
|
40
|
+
/** The answer of a refused request. */
|
|
41
|
+
export declare function refusalResponse(refusal: Refusal): Response;
|
|
42
|
+
/** The context of one request, and the function that releases it if the handler made it. */
|
|
43
|
+
export interface OpenedContext<Context> {
|
|
24
44
|
readonly context: Context;
|
|
25
45
|
readonly release: () => Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The context of one request, or the refusal of the request. A response with a stream, such as the stream of a
|
|
49
|
+
* subscription, releases the context when the stream ends.
|
|
50
|
+
*/
|
|
51
|
+
export declare function openContext<Context>(option: ContextOption<Context>, request: Request): Promise<OpenedContext<Context> | {
|
|
52
|
+
readonly refusal: Refusal;
|
|
26
53
|
}>;
|
|
54
|
+
export {};
|
package/dist/context.js
CHANGED
|
@@ -1,17 +1,47 @@
|
|
|
1
1
|
// src/context.ts
|
|
2
|
+
/** The key that marks a refusal. A context cannot have it by accident. */
|
|
3
|
+
const refusalKey = Symbol("acid-cat.refusal");
|
|
2
4
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
+
* Refuse a request: `createContext` gives the result instead of a context. The handler answers with `status`, the media
|
|
6
|
+
* type `application/json`, the body `{ "message": message }`, and `headers`. No capability runs, and no subscription
|
|
7
|
+
* starts. `status` must be an integer from 400 to 599: an other value throws a `RangeError`.
|
|
8
|
+
*/
|
|
9
|
+
function refuse(status, message, headers) {
|
|
10
|
+
if (!Number.isInteger(status) || status < 400 || status > 599) throw new RangeError(`The status of a refusal must be an integer from 400 to 599, not ${status}.`);
|
|
11
|
+
return {
|
|
12
|
+
[refusalKey]: true,
|
|
13
|
+
status,
|
|
14
|
+
message,
|
|
15
|
+
headers: new Headers(headers)
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** Tell if a value is a refusal that `refuse` made. */
|
|
19
|
+
function isRefusal(value) {
|
|
20
|
+
return typeof value === "object" && value !== null && refusalKey in value && value[refusalKey] === true && "status" in value && typeof value.status === "number" && "message" in value && typeof value.message === "string" && "headers" in value && value.headers instanceof Headers;
|
|
21
|
+
}
|
|
22
|
+
/** The answer of a refused request. */
|
|
23
|
+
function refusalResponse(refusal) {
|
|
24
|
+
const headers = new Headers(refusal.headers);
|
|
25
|
+
headers.set("Content-Type", "application/json");
|
|
26
|
+
return new Response(JSON.stringify({ message: refusal.message }), {
|
|
27
|
+
status: refusal.status,
|
|
28
|
+
headers
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The context of one request, or the refusal of the request. A response with a stream, such as the stream of a
|
|
33
|
+
* subscription, releases the context when the stream ends.
|
|
5
34
|
*/
|
|
6
35
|
async function openContext(option, request) {
|
|
7
36
|
if (option.createContext === void 0) return {
|
|
8
37
|
context: option.context,
|
|
9
38
|
release: () => Promise.resolve()
|
|
10
39
|
};
|
|
11
|
-
const
|
|
40
|
+
const made = await option.createContext(request);
|
|
41
|
+
if (isRefusal(made)) return { refusal: made };
|
|
12
42
|
return {
|
|
13
|
-
context,
|
|
14
|
-
release: () => release(
|
|
43
|
+
context: made,
|
|
44
|
+
release: () => release(made)
|
|
15
45
|
};
|
|
16
46
|
}
|
|
17
47
|
/** Call `Symbol.asyncDispose` or `Symbol.dispose` of a value, if it has one. */
|
|
@@ -24,4 +54,4 @@ async function release(value) {
|
|
|
24
54
|
}
|
|
25
55
|
/** Tell if a value is a function. A function can take any arguments, and its result is not known. */
|
|
26
56
|
const isFunction = (value) => typeof value === "function";
|
|
27
|
-
export { openContext };
|
|
57
|
+
export { isRefusal, openContext, refusalResponse, refuse };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Availability, CallFailure, Occurrence, Outcome } from "@acid-cat/spec-ts";
|
|
2
|
+
export interface HttpClientOptions {
|
|
3
|
+
/**
|
|
4
|
+
* The headers to send with each request, for example `{ Authorization: "Bearer …" }`. The client never writes them in
|
|
5
|
+
* a message or an error.
|
|
6
|
+
*/
|
|
7
|
+
readonly headers?: Readonly<Record<string, string>> | undefined;
|
|
8
|
+
/** The function that sends the requests. The default is the global `fetch`. */
|
|
9
|
+
readonly fetch?: typeof fetch | undefined;
|
|
10
|
+
}
|
|
11
|
+
export interface HttpSubscribeOptions {
|
|
12
|
+
/** Abort to close the request. The server then cancels the subscription, and the occurrences end. */
|
|
13
|
+
readonly signal?: AbortSignal | undefined;
|
|
14
|
+
/** The cursor to resume after, sent as `Last-Event-ID`. */
|
|
15
|
+
readonly cursor?: string | undefined;
|
|
16
|
+
/** The oldest occurrence to replay, in milliseconds, sent as the query parameter `maxAgeMs`. */
|
|
17
|
+
readonly maxAgeMs?: number | undefined;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A client of an HTTP binding at a base URL. No method rejects: a server that does not answer is the call failure
|
|
21
|
+
* `unavailable`, an answer that the HTTP binding does not give is `internal`, and an abort is `cancelled`.
|
|
22
|
+
*/
|
|
23
|
+
export interface HttpClient {
|
|
24
|
+
/** The base URL, without its last `/`. */
|
|
25
|
+
readonly base: string;
|
|
26
|
+
/** The capabilities of the view of the server, by id, with their availability: `GET {base}/capabilities`. */
|
|
27
|
+
capabilities(options?: {
|
|
28
|
+
readonly signal?: AbortSignal | undefined;
|
|
29
|
+
}): Promise<ReadonlyMap<string, Availability> | CallFailure>;
|
|
30
|
+
/** Call a capability: `POST {base}/capabilities/{id}`. Abort the signal to close the request: the server then cancels the call. */
|
|
31
|
+
invoke(id: string, input: unknown, options?: {
|
|
32
|
+
readonly signal?: AbortSignal | undefined;
|
|
33
|
+
}): Promise<Outcome<unknown, unknown>>;
|
|
34
|
+
/**
|
|
35
|
+
* Subscribe to an event: `POST {base}/events/{id}`. The result is the occurrences, or the call failure before the first
|
|
36
|
+
* occurrence. The occurrences reject with a `CallFailureError` when the subscription ends with a call failure, and end
|
|
37
|
+
* with no error when the signal aborts.
|
|
38
|
+
*/
|
|
39
|
+
subscribe(id: string, input: unknown, options?: HttpSubscribeOptions): Promise<AsyncIterable<Occurrence> | CallFailure>;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A client of the HTTP binding at `url`, an `http` or `https` URL, for example `http://pi.local:8080/cli`. It throws
|
|
43
|
+
* when the URL or a header is not valid. The error does not contain the value of a header.
|
|
44
|
+
*/
|
|
45
|
+
export declare function httpClient(url: string, options?: HttpClientOptions): HttpClient;
|
|
46
|
+
/** A message of a stream of server-sent events. */
|
|
47
|
+
interface Message {
|
|
48
|
+
readonly event: string;
|
|
49
|
+
readonly data: string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The parser of a stream of server-sent events (https://html.spec.whatwg.org/multipage/server-sent-events.html): lines
|
|
53
|
+
* end with CRLF, LF or CR, a line that starts with `:` is a comment, and a blank line ends a message. It reads the
|
|
54
|
+
* fields `event` and `data`, and ignores the others, also `id`: the cursor of an occurrence is in its data. It gives a
|
|
55
|
+
* message with no data only when its event is `complete`.
|
|
56
|
+
*/
|
|
57
|
+
export declare class SseParser {
|
|
58
|
+
#private;
|
|
59
|
+
/** Give the next text of the stream. The result is the messages that it ends. */
|
|
60
|
+
push(text: string): Message[];
|
|
61
|
+
}
|
|
62
|
+
export {};
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { AVAILABLE, CALL_FAILURE_KINDS, CallFailureError, SSE_MEDIA_TYPE, callFailure, isJsonObject, isOccurrence, unavailable } from "@acid-cat/spec-ts";
|
|
2
|
+
// src/http-client.ts
|
|
3
|
+
/**
|
|
4
|
+
* A client of the HTTP binding (spec/bindings/http.md). The command line uses it to call the capabilities and follow
|
|
5
|
+
* the events of an application that runs in another process or on another machine (spec/bindings/cli.md#server).
|
|
6
|
+
*/
|
|
7
|
+
/** The largest answer that the client reads, in bytes. */
|
|
8
|
+
const MAXIMUM_ANSWER = 67108864;
|
|
9
|
+
/**
|
|
10
|
+
* A client of the HTTP binding at `url`, an `http` or `https` URL, for example `http://pi.local:8080/cli`. It throws
|
|
11
|
+
* when the URL or a header is not valid. The error does not contain the value of a header.
|
|
12
|
+
*/
|
|
13
|
+
function httpClient(url, options = {}) {
|
|
14
|
+
let parsed;
|
|
15
|
+
try {
|
|
16
|
+
parsed = new URL(url);
|
|
17
|
+
} catch {
|
|
18
|
+
throw new Error("The URL of the server is not a URL.");
|
|
19
|
+
}
|
|
20
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("The URL of the server is not an http or https URL.");
|
|
21
|
+
if (parsed.search !== "" || parsed.hash !== "") throw new Error("The URL of the server must not have a query or a fragment.");
|
|
22
|
+
if (parsed.username !== "" || parsed.password !== "") throw new Error("The URL of the server must not have a user name or a password. Give them in a header.");
|
|
23
|
+
const base = `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, "");
|
|
24
|
+
const headers = new Headers();
|
|
25
|
+
for (const [name, value] of Object.entries(options.headers ?? {})) try {
|
|
26
|
+
headers.append(name, value);
|
|
27
|
+
} catch {
|
|
28
|
+
throw new Error(`The header ${/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name) ? name : "with that name"} is not valid.`);
|
|
29
|
+
}
|
|
30
|
+
const send = options.fetch ?? fetch;
|
|
31
|
+
const notAnswering = () => callFailure("unavailable", `The server at ${base} does not answer.`);
|
|
32
|
+
/** Send a request. The result is the response, or the call failure when the server does not answer or the signal aborts. */
|
|
33
|
+
async function request(method, path, init) {
|
|
34
|
+
if (aborted(init.signal)) return cancelled();
|
|
35
|
+
const requestHeaders = new Headers(headers);
|
|
36
|
+
requestHeaders.set("Accept", "application/json, text/event-stream");
|
|
37
|
+
if (init.lastEventId !== void 0) try {
|
|
38
|
+
requestHeaders.set("Last-Event-ID", init.lastEventId);
|
|
39
|
+
} catch {
|
|
40
|
+
return callFailure("invalid-input", "The cursor cannot be sent in the header Last-Event-ID.");
|
|
41
|
+
}
|
|
42
|
+
let body;
|
|
43
|
+
if (method === "POST") {
|
|
44
|
+
requestHeaders.set("Content-Type", "application/json");
|
|
45
|
+
body = JSON.stringify(init.body ?? {});
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
return await send(`${base}${path}`, {
|
|
49
|
+
method,
|
|
50
|
+
headers: requestHeaders,
|
|
51
|
+
...body === void 0 ? {} : { body },
|
|
52
|
+
...init.signal === void 0 ? {} : { signal: init.signal }
|
|
53
|
+
});
|
|
54
|
+
} catch {
|
|
55
|
+
return aborted(init.signal) ? cancelled() : notAnswering();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** The status and the JSON body of an answer, or the call failure when it is not JSON. */
|
|
59
|
+
async function read(response, signal) {
|
|
60
|
+
const text = await boundedText(response, signal);
|
|
61
|
+
if (typeof text !== "string") return text;
|
|
62
|
+
try {
|
|
63
|
+
return {
|
|
64
|
+
status: response.status,
|
|
65
|
+
body: JSON.parse(text)
|
|
66
|
+
};
|
|
67
|
+
} catch {
|
|
68
|
+
return notAnAnswer();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
base,
|
|
73
|
+
async capabilities(callOptions = {}) {
|
|
74
|
+
const response = await request("GET", "/capabilities", { signal: callOptions.signal });
|
|
75
|
+
if (!(response instanceof Response)) return response;
|
|
76
|
+
const answer = await read(response, callOptions.signal);
|
|
77
|
+
if (!("status" in answer)) return answer;
|
|
78
|
+
if (answer.status !== 200) return failureOf(answer.body);
|
|
79
|
+
if (!Array.isArray(answer.body)) return notAnAnswer();
|
|
80
|
+
const served = /* @__PURE__ */ new Map();
|
|
81
|
+
for (const item of answer.body) {
|
|
82
|
+
const entry = availabilityEntry(item);
|
|
83
|
+
if (entry === void 0) return notAnAnswer();
|
|
84
|
+
served.set(entry.id, entry.availability);
|
|
85
|
+
}
|
|
86
|
+
return served;
|
|
87
|
+
},
|
|
88
|
+
async invoke(id, input, callOptions = {}) {
|
|
89
|
+
const response = await request("POST", `/capabilities/${encodeURIComponent(id)}`, {
|
|
90
|
+
body: input,
|
|
91
|
+
signal: callOptions.signal
|
|
92
|
+
});
|
|
93
|
+
if (!(response instanceof Response)) return response;
|
|
94
|
+
const answer = await read(response, callOptions.signal);
|
|
95
|
+
if (!("status" in answer)) return answer;
|
|
96
|
+
if (answer.status !== 200) return failureOf(answer.body);
|
|
97
|
+
const { body } = answer;
|
|
98
|
+
if (isJsonObject(body) && body.ok === true && "value" in body) return {
|
|
99
|
+
ok: true,
|
|
100
|
+
value: body.value
|
|
101
|
+
};
|
|
102
|
+
if (isJsonObject(body) && body.ok === false && "error" in body) return {
|
|
103
|
+
ok: false,
|
|
104
|
+
error: body.error
|
|
105
|
+
};
|
|
106
|
+
return notAnAnswer();
|
|
107
|
+
},
|
|
108
|
+
async subscribe(id, input, subscribeOptions = {}) {
|
|
109
|
+
const query = subscribeOptions.maxAgeMs === void 0 ? "" : `?maxAgeMs=${encodeURIComponent(String(subscribeOptions.maxAgeMs))}`;
|
|
110
|
+
const response = await request("POST", `/events/${encodeURIComponent(id)}${query}`, {
|
|
111
|
+
body: input,
|
|
112
|
+
signal: subscribeOptions.signal,
|
|
113
|
+
lastEventId: subscribeOptions.cursor
|
|
114
|
+
});
|
|
115
|
+
if (!(response instanceof Response)) return response;
|
|
116
|
+
if (response.status !== 200) {
|
|
117
|
+
const answer = await read(response, subscribeOptions.signal);
|
|
118
|
+
return "status" in answer ? failureOf(answer.body) : answer;
|
|
119
|
+
}
|
|
120
|
+
if ((response.headers.get("Content-Type") ?? "").split(";")[0]?.trim().toLowerCase() !== SSE_MEDIA_TYPE || response.body === null) {
|
|
121
|
+
await response.body?.cancel().catch(() => void 0);
|
|
122
|
+
return notAnAnswer();
|
|
123
|
+
}
|
|
124
|
+
return occurrencesOf(response.body, subscribeOptions.signal);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** True if the signal aborted. A function, so that TypeScript does not keep a test of the signal from before an await. */
|
|
129
|
+
const aborted = (signal) => signal?.aborted === true;
|
|
130
|
+
const cancelled = () => callFailure("cancelled", "The call was cancelled.");
|
|
131
|
+
const notAnAnswer = () => callFailure("internal", "The server gave an answer that the HTTP binding does not give.");
|
|
132
|
+
const broken = () => callFailure("internal", "The connection to the server broke.");
|
|
133
|
+
const isCallFailureKind = (value) => CALL_FAILURE_KINDS.some((kind) => kind === value);
|
|
134
|
+
/** The call failure in the body of an answer, or `internal` when the body is not a call failure. */
|
|
135
|
+
function failureOf(body) {
|
|
136
|
+
if (isJsonObject(body) && isCallFailureKind(body.kind) && typeof body.message === "string") return callFailure(body.kind, body.message);
|
|
137
|
+
return notAnAnswer();
|
|
138
|
+
}
|
|
139
|
+
/** One item of `GET {base}/capabilities`, or `undefined` when it is not one. */
|
|
140
|
+
function availabilityEntry(item) {
|
|
141
|
+
if (!isJsonObject(item) || typeof item.id !== "string") return void 0;
|
|
142
|
+
if (item.available === true) return {
|
|
143
|
+
id: item.id,
|
|
144
|
+
availability: AVAILABLE
|
|
145
|
+
};
|
|
146
|
+
if (item.available === false && typeof item.reason === "string") return {
|
|
147
|
+
id: item.id,
|
|
148
|
+
availability: unavailable(item.reason)
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** The text of a body, at most `MAXIMUM_ANSWER` bytes, or the call failure when it cannot be read. */
|
|
152
|
+
async function boundedText(response, signal) {
|
|
153
|
+
if (response.body === null) return "";
|
|
154
|
+
const reader = response.body.getReader();
|
|
155
|
+
const decoder = new TextDecoder();
|
|
156
|
+
let text = "";
|
|
157
|
+
let size = 0;
|
|
158
|
+
try {
|
|
159
|
+
for (;;) {
|
|
160
|
+
const chunk = await reader.read();
|
|
161
|
+
if (chunk.done) return text + decoder.decode();
|
|
162
|
+
size += chunk.value.byteLength;
|
|
163
|
+
if (size > MAXIMUM_ANSWER) {
|
|
164
|
+
await reader.cancel().catch(() => void 0);
|
|
165
|
+
return notAnAnswer();
|
|
166
|
+
}
|
|
167
|
+
text += decoder.decode(chunk.value, { stream: true });
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
return aborted(signal) ? cancelled() : broken();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The parser of a stream of server-sent events (https://html.spec.whatwg.org/multipage/server-sent-events.html): lines
|
|
175
|
+
* end with CRLF, LF or CR, a line that starts with `:` is a comment, and a blank line ends a message. It reads the
|
|
176
|
+
* fields `event` and `data`, and ignores the others, also `id`: the cursor of an occurrence is in its data. It gives a
|
|
177
|
+
* message with no data only when its event is `complete`.
|
|
178
|
+
*/
|
|
179
|
+
var SseParser = class {
|
|
180
|
+
#buffer = "";
|
|
181
|
+
#event = "";
|
|
182
|
+
#data = [];
|
|
183
|
+
#started = false;
|
|
184
|
+
/** Give the next text of the stream. The result is the messages that it ends. */
|
|
185
|
+
push(text) {
|
|
186
|
+
this.#buffer += text;
|
|
187
|
+
if (!this.#started && this.#buffer.length > 0) {
|
|
188
|
+
this.#started = true;
|
|
189
|
+
if (this.#buffer.startsWith("")) this.#buffer = this.#buffer.slice(1);
|
|
190
|
+
}
|
|
191
|
+
const messages = [];
|
|
192
|
+
for (;;) {
|
|
193
|
+
const end = this.#buffer.search(/\r\n|\n|\r/);
|
|
194
|
+
if (end < 0) break;
|
|
195
|
+
if (this.#buffer[end] === "\r" && end === this.#buffer.length - 1) break;
|
|
196
|
+
const line = this.#buffer.slice(0, end);
|
|
197
|
+
this.#buffer = this.#buffer.slice(end + (this.#buffer.startsWith("\r\n", end) ? 2 : 1));
|
|
198
|
+
const message = this.#line(line);
|
|
199
|
+
if (message !== void 0) messages.push(message);
|
|
200
|
+
}
|
|
201
|
+
return messages;
|
|
202
|
+
}
|
|
203
|
+
#line(line) {
|
|
204
|
+
if (line === "") {
|
|
205
|
+
const message = {
|
|
206
|
+
event: this.#event === "" ? "message" : this.#event,
|
|
207
|
+
data: this.#data.join("\n")
|
|
208
|
+
};
|
|
209
|
+
const empty = this.#data.length === 0;
|
|
210
|
+
this.#event = "";
|
|
211
|
+
this.#data = [];
|
|
212
|
+
return empty && message.event !== "complete" ? void 0 : message;
|
|
213
|
+
}
|
|
214
|
+
if (line.startsWith(":")) return void 0;
|
|
215
|
+
const colon = line.indexOf(":");
|
|
216
|
+
const field = colon < 0 ? line : line.slice(0, colon);
|
|
217
|
+
const raw = colon < 0 ? "" : line.slice(colon + 1);
|
|
218
|
+
const value = raw.startsWith(" ") ? raw.slice(1) : raw;
|
|
219
|
+
if (field === "event") this.#event = value;
|
|
220
|
+
else if (field === "data") this.#data.push(value);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
/** Parse JSON text, or give `undefined` when it is not JSON. */
|
|
224
|
+
function parseJson(text) {
|
|
225
|
+
try {
|
|
226
|
+
return { value: JSON.parse(text) };
|
|
227
|
+
} catch {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* The occurrences of a stream of server-sent events of the HTTP binding. `next` is an occurrence, `complete` the end,
|
|
233
|
+
* and `error` the end with its call failure. A stream that ends with no `complete` or `error` is `internal`.
|
|
234
|
+
*/
|
|
235
|
+
async function* occurrencesOf(body, signal) {
|
|
236
|
+
const reader = body.getReader();
|
|
237
|
+
const decoder = new TextDecoder();
|
|
238
|
+
const parser = new SseParser();
|
|
239
|
+
try {
|
|
240
|
+
for (;;) {
|
|
241
|
+
let chunk;
|
|
242
|
+
try {
|
|
243
|
+
chunk = await reader.read();
|
|
244
|
+
} catch {
|
|
245
|
+
if (aborted(signal)) return;
|
|
246
|
+
throw new CallFailureError(broken());
|
|
247
|
+
}
|
|
248
|
+
if (chunk.done) {
|
|
249
|
+
if (aborted(signal)) return;
|
|
250
|
+
throw new CallFailureError(broken());
|
|
251
|
+
}
|
|
252
|
+
for (const message of parser.push(decoder.decode(chunk.value, { stream: true }))) {
|
|
253
|
+
const data = parseJson(message.data);
|
|
254
|
+
if (message.event === "next") {
|
|
255
|
+
if (data === void 0 || !isOccurrence(data.value)) throw new CallFailureError(notAnAnswer());
|
|
256
|
+
yield data.value;
|
|
257
|
+
} else if (message.event === "complete") return;
|
|
258
|
+
else if (message.event === "error") throw new CallFailureError(data === void 0 ? notAnAnswer() : failureOf(data.value));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
} finally {
|
|
262
|
+
await reader.cancel().catch(() => void 0);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
export { SseParser, httpClient };
|
package/dist/http.d.ts
CHANGED
|
@@ -19,5 +19,8 @@ export interface HttpSettings {
|
|
|
19
19
|
/** The number of occurrences that each subscription keeps for a client that reads slowly. The default is 256. */
|
|
20
20
|
readonly bufferLimit?: number | undefined;
|
|
21
21
|
}
|
|
22
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* A web request handler for the API. Each request has its own session, and with `createContext` its own context.
|
|
24
|
+
* `createContext` can refuse a request: then the handler answers with the refusal, and opens no session.
|
|
25
|
+
*/
|
|
23
26
|
export declare function httpHandler<Context>(app: App<Context>, options: HttpOptions<Context>): (request: Request) => Promise<Response>;
|
package/dist/http.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { openContext } from "./context.js";
|
|
1
|
+
import { openContext, refusalResponse } from "./context.js";
|
|
2
2
|
import { createSession } from "./session.js";
|
|
3
3
|
import { idlePuller, textStream } from "./stream.js";
|
|
4
4
|
import { CallFailureError, KEEPALIVE_MS, SSE_MEDIA_TYPE, callFailure, httpEventMessage, httpStatus, isCallFailure, readBounded, screenRequest } from "@acid-cat/spec-ts";
|
|
@@ -6,7 +6,10 @@ import { CallFailureError, KEEPALIVE_MS, SSE_MEDIA_TYPE, callFailure, httpEventM
|
|
|
6
6
|
/**
|
|
7
7
|
* The HTTP binding (spec/bindings/http.md): JSON through HTTP.
|
|
8
8
|
*/
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* A web request handler for the API. Each request has its own session, and with `createContext` its own context.
|
|
11
|
+
* `createContext` can refuse a request: then the handler answers with the refusal, and opens no session.
|
|
12
|
+
*/
|
|
10
13
|
function httpHandler(app, options) {
|
|
11
14
|
const maximumBytes = options.maximumBytes ?? 1048576;
|
|
12
15
|
return async (request) => {
|
|
@@ -22,6 +25,7 @@ function httpHandler(app, options) {
|
|
|
22
25
|
const url = new URL(request.url);
|
|
23
26
|
const path = url.pathname.slice((options.base ?? "").length);
|
|
24
27
|
const opened = await openContext(options, request);
|
|
28
|
+
if ("refusal" in opened) return refusalResponse(opened.refusal);
|
|
25
29
|
const session = createSession(app, {
|
|
26
30
|
target: options.target ?? "api",
|
|
27
31
|
context: opened.context,
|
package/dist/index.d.ts
CHANGED
|
@@ -16,9 +16,12 @@ export { main as mcpStdioMain, runMcpStdio } from "./mcp-stdio.js";
|
|
|
16
16
|
export type { McpStdioIo, McpStdioOptions } from "./mcp-stdio.js";
|
|
17
17
|
export { httpHandler } from "./http.js";
|
|
18
18
|
export type { HttpOptions, HttpSettings } from "./http.js";
|
|
19
|
-
export
|
|
19
|
+
export { isRefusal, refuse } from "./context.js";
|
|
20
|
+
export type { ContextOption, Refusal } from "./context.js";
|
|
20
21
|
export { main as cliMain, runCli } from "./cli.js";
|
|
21
22
|
export type { CliCommand, CliIo, CliOptions } from "./cli.js";
|
|
23
|
+
export { httpClient } from "./http-client.js";
|
|
24
|
+
export type { HttpClient, HttpClientOptions, HttpSubscribeOptions } from "./http-client.js";
|
|
22
25
|
export { findHost, installWebMcp } from "./webmcp.js";
|
|
23
26
|
export type { Installation, ModelContext, ModelContextTool, Phase, Status } from "./webmcp.js";
|
|
24
27
|
export type { StandardSchemaV1 } from "./standard-schema.js";
|
package/dist/index.js
CHANGED
|
@@ -5,8 +5,10 @@ import { mcpHandler } from "./mcp.js";
|
|
|
5
5
|
import { systemClock } from "./clock.js";
|
|
6
6
|
import { main as main$1, runMcpStdio } from "./mcp-stdio.js";
|
|
7
7
|
import { httpHandler } from "./http.js";
|
|
8
|
+
import { isRefusal, refuse } from "./context.js";
|
|
8
9
|
import { main, runCli } from "./cli.js";
|
|
10
|
+
import { httpClient } from "./http-client.js";
|
|
9
11
|
import { findHost, installWebMcp } from "./webmcp.js";
|
|
10
12
|
import { AVAILABLE, CallFailureError, fail, ok, unavailable } from "@acid-cat/spec-ts";
|
|
11
13
|
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 };
|
|
14
|
+
export { AVAILABLE, CallFailureError, DEFAULT_BUFFER_LIMIT, capabilitiesFor, catalogOf, main as cliMain, createSession, defineApp, defineCapability, defineEvent, eventsFor, fail, findHost, httpClient, httpHandler, installWebMcp, isRefusal, mcpHandler, mcpHandshake, main$1 as mcpStdioMain, ok, refuse, renderCatalog, runCli, runMcpStdio, systemClock, unavailable };
|
package/dist/mcp.d.ts
CHANGED
|
@@ -53,8 +53,10 @@ export interface McpHandler {
|
|
|
53
53
|
}
|
|
54
54
|
/**
|
|
55
55
|
* A web request handler for the MCP server. Each request has its own session, and with `createContext` its own
|
|
56
|
-
* context.
|
|
57
|
-
* request that
|
|
56
|
+
* context. `createContext` can refuse a request: then the handler answers with the refusal, and opens no session.
|
|
57
|
+
* Each poll lease and each webhook subscription also has its own session, with the context of the request that
|
|
58
|
+
* started it. The handler releases that context when the answer is done and each of those leases and subscriptions
|
|
59
|
+
* has ended.
|
|
58
60
|
*/
|
|
59
61
|
export declare function mcpHandler<Context>(app: App<Context>, options: McpOptions<Context>): McpHandler;
|
|
60
62
|
/** The tools of the available capabilities in the view of a session (spec/bindings/mcp.md#tools). */
|
package/dist/mcp.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { openContext } from "./context.js";
|
|
1
|
+
import { openContext, refusalResponse } from "./context.js";
|
|
2
2
|
import { eventCapabilities, forbidden, listEvents, servesEvents, startEventStream, unsupportedWebhook } from "./mcp-events.js";
|
|
3
3
|
import { PollLeases } from "./mcp-poll.js";
|
|
4
4
|
import { createSession } from "./session.js";
|
|
@@ -28,8 +28,10 @@ function readRequest(body) {
|
|
|
28
28
|
}
|
|
29
29
|
/**
|
|
30
30
|
* A web request handler for the MCP server. Each request has its own session, and with `createContext` its own
|
|
31
|
-
* context.
|
|
32
|
-
* request that
|
|
31
|
+
* context. `createContext` can refuse a request: then the handler answers with the refusal, and opens no session.
|
|
32
|
+
* Each poll lease and each webhook subscription also has its own session, with the context of the request that
|
|
33
|
+
* started it. The handler releases that context when the answer is done and each of those leases and subscriptions
|
|
34
|
+
* has ended.
|
|
33
35
|
*/
|
|
34
36
|
function mcpHandler(app, options) {
|
|
35
37
|
const maximumBytes = options.maximumBytes ?? 1048576;
|
|
@@ -53,19 +55,17 @@ function mcpHandler(app, options) {
|
|
|
53
55
|
} catch {
|
|
54
56
|
return rejection("not-utf8");
|
|
55
57
|
}
|
|
56
|
-
const
|
|
58
|
+
const opened = await openContext(options, request);
|
|
59
|
+
if ("refusal" in opened) return refusalResponse(opened.refusal);
|
|
60
|
+
const unanswerable = revisionError(request, message);
|
|
61
|
+
if (unanswerable !== void 0) {
|
|
62
|
+
await opened.release();
|
|
63
|
+
return unanswerable;
|
|
64
|
+
}
|
|
57
65
|
const id = message.id ?? null;
|
|
58
|
-
|
|
59
|
-
if (version !== MCP_PROTOCOL_VERSION) return error(400, id, -32022, `Unsupported protocol version ${version}`, { supported: [MCP_PROTOCOL_VERSION] });
|
|
60
|
-
if (message.id === void 0) return new Response(null, {
|
|
61
|
-
status: 200,
|
|
62
|
-
headers: { "Content-Type": "application/json" }
|
|
63
|
-
});
|
|
64
|
-
const metaVersion = message.protocolVersion;
|
|
65
|
-
if (metaVersion === void 0) return error(400, id, -32602, "Required request metadata is missing");
|
|
66
|
-
if (metaVersion !== version) return error(400, id, -32020, "MCP-Protocol-Version header does not match request metadata");
|
|
66
|
+
let holders = 0;
|
|
67
67
|
const open = async () => {
|
|
68
|
-
|
|
68
|
+
holders += 1;
|
|
69
69
|
const session = createSession(app, {
|
|
70
70
|
target: options.target ?? "mcp",
|
|
71
71
|
context: opened.context,
|
|
@@ -74,8 +74,12 @@ function mcpHandler(app, options) {
|
|
|
74
74
|
});
|
|
75
75
|
let closing;
|
|
76
76
|
const close = () => closing ??= (async () => {
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
try {
|
|
78
|
+
await session.dispose();
|
|
79
|
+
} finally {
|
|
80
|
+
holders -= 1;
|
|
81
|
+
if (holders === 0) await opened.release();
|
|
82
|
+
}
|
|
79
83
|
})();
|
|
80
84
|
return {
|
|
81
85
|
session,
|
|
@@ -85,7 +89,7 @@ function mcpHandler(app, options) {
|
|
|
85
89
|
const { session, close } = await open();
|
|
86
90
|
let streaming = false;
|
|
87
91
|
try {
|
|
88
|
-
if (message.method === "events/stream" && message.id !== null && servesEvents(session)) {
|
|
92
|
+
if (message.method === "events/stream" && message.id !== null && message.id !== void 0 && servesEvents(session)) {
|
|
89
93
|
const started = await startEventStream(session, message.id, message.params, {
|
|
90
94
|
heartbeatMs: options.heartbeatMs ?? KEEPALIVE_MS,
|
|
91
95
|
resultMeta: serverInfoOf(options),
|
|
@@ -127,6 +131,23 @@ function mcpHandler(app, options) {
|
|
|
127
131
|
[Symbol.asyncDispose]: closeAll
|
|
128
132
|
});
|
|
129
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* The error answer of a request that does not name revision 2026-07-28 correctly, or the empty answer of a
|
|
136
|
+
* notification. `undefined` for a request that the server answers.
|
|
137
|
+
*/
|
|
138
|
+
function revisionError(request, message) {
|
|
139
|
+
const version = request.headers.get("MCP-Protocol-Version");
|
|
140
|
+
const id = message.id ?? null;
|
|
141
|
+
if (version === null) return error(400, id, -32020, "MCP-Protocol-Version header is required");
|
|
142
|
+
if (version !== MCP_PROTOCOL_VERSION) return error(400, id, -32022, `Unsupported protocol version ${version}`, { supported: [MCP_PROTOCOL_VERSION] });
|
|
143
|
+
if (message.id === void 0) return new Response(null, {
|
|
144
|
+
status: 200,
|
|
145
|
+
headers: { "Content-Type": "application/json" }
|
|
146
|
+
});
|
|
147
|
+
const metaVersion = message.protocolVersion;
|
|
148
|
+
if (metaVersion === void 0) return error(400, id, -32602, "Required request metadata is missing");
|
|
149
|
+
if (metaVersion !== version) return error(400, id, -32020, "MCP-Protocol-Version header does not match request metadata");
|
|
150
|
+
}
|
|
130
151
|
/** The methods whose state outlives the request (spec/bindings/mcp.md#subscriptions-that-outlive-a-request). */
|
|
131
152
|
const LONG_LIVED = /* @__PURE__ */ new Set([
|
|
132
153
|
"events/poll",
|
package/dist/session.js
CHANGED
|
@@ -86,7 +86,8 @@ function createSession(app, options) {
|
|
|
86
86
|
if (lifetime.signal.aborted || signal?.aborted) controller.abort();
|
|
87
87
|
const work = Promise.resolve().then(() => prepared.start({
|
|
88
88
|
signal: controller.signal,
|
|
89
|
-
context: options.context
|
|
89
|
+
context: options.context,
|
|
90
|
+
target: options.target
|
|
90
91
|
})).catch((cause) => {
|
|
91
92
|
onDefect(id, cause);
|
|
92
93
|
return callFailure("internal", "The capability failed.");
|
|
@@ -199,6 +200,7 @@ function createSession(app, options) {
|
|
|
199
200
|
source = prepared.start({
|
|
200
201
|
signal: controller.signal,
|
|
201
202
|
context: options.context,
|
|
203
|
+
target: options.target,
|
|
202
204
|
...subscribeOptions.cursor === void 0 ? {} : { cursor: subscribeOptions.cursor },
|
|
203
205
|
...subscribeOptions.maxAgeMs === void 0 ? {} : { maxAgeMs: subscribeOptions.maxAgeMs }
|
|
204
206
|
});
|
package/dist/webhook.js
CHANGED
|
@@ -35,7 +35,9 @@ function webhookDelivery(options = {}) {
|
|
|
35
35
|
const subscriptions = /* @__PURE__ */ new Map();
|
|
36
36
|
/** The subscriptions that start, by key. A second request with the same key waits for the first. */
|
|
37
37
|
const starting = /* @__PURE__ */ new Map();
|
|
38
|
-
/** The
|
|
38
|
+
/** The new subscriptions of each principal that are starting now, so that they count for the limit. */
|
|
39
|
+
const admitted = /* @__PURE__ */ new Map();
|
|
40
|
+
/** The pairs of principal and URL, as the client sent it, that agreed to get deliveries, while the process runs. */
|
|
39
41
|
const verified = /* @__PURE__ */ new Set();
|
|
40
42
|
const pumps = /* @__PURE__ */ new Set();
|
|
41
43
|
let closed = false;
|
|
@@ -127,8 +129,8 @@ function webhookDelivery(options = {}) {
|
|
|
127
129
|
subscription.cancelExpiry = clock.schedule(() => void end(subscription), lifetime);
|
|
128
130
|
}
|
|
129
131
|
/** Verify that the endpoint agrees to get deliveries for this principal. The result is the reason of a failure. */
|
|
130
|
-
async function verify(principal, url, id, secret) {
|
|
131
|
-
const pair = canonicalJson([principal,
|
|
132
|
+
async function verify(principal, rawUrl, url, id, secret) {
|
|
133
|
+
const pair = canonicalJson([principal, rawUrl]);
|
|
132
134
|
if (verified.has(pair) || options.allow?.(url) === true) return void 0;
|
|
133
135
|
const challenge = randomBytes(24).toString("base64url");
|
|
134
136
|
const body = JSON.stringify({
|
|
@@ -158,7 +160,7 @@ function webhookDelivery(options = {}) {
|
|
|
158
160
|
} });
|
|
159
161
|
/** Start a new subscription: subscribe in a session of its own, verify the endpoint, then send the occurrences. */
|
|
160
162
|
async function start(principal, key, url, secret, request, open) {
|
|
161
|
-
if ([...subscriptions.values()].filter((each) => each.principal === principal).length >= settings.maxSubscriptions) return { error: {
|
|
163
|
+
if ([...subscriptions.values()].filter((each) => each.principal === principal).length + (admitted.get(principal) ?? 0) >= settings.maxSubscriptions) return { error: {
|
|
162
164
|
code: MCP_EVENT_ERRORS.resourceExhausted,
|
|
163
165
|
message: "Too many webhook subscriptions.",
|
|
164
166
|
data: {
|
|
@@ -166,6 +168,24 @@ function webhookDelivery(options = {}) {
|
|
|
166
168
|
max: settings.maxSubscriptions
|
|
167
169
|
}
|
|
168
170
|
} };
|
|
171
|
+
admitted.set(principal, (admitted.get(principal) ?? 0) + 1);
|
|
172
|
+
let held = true;
|
|
173
|
+
/** Stop counting the subscription as starting. It runs once: when the subscription exists, or when the start fails. */
|
|
174
|
+
const release = () => {
|
|
175
|
+
if (!held) return;
|
|
176
|
+
held = false;
|
|
177
|
+
const left = (admitted.get(principal) ?? 1) - 1;
|
|
178
|
+
if (left === 0) admitted.delete(principal);
|
|
179
|
+
else admitted.set(principal, left);
|
|
180
|
+
};
|
|
181
|
+
try {
|
|
182
|
+
return await startAdmitted(principal, key, url, secret, request, open, release);
|
|
183
|
+
} finally {
|
|
184
|
+
release();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** Start a subscription that the limit admitted. `release` runs in the same step that adds the subscription. */
|
|
188
|
+
async function startAdmitted(principal, key, url, secret, request, open, release) {
|
|
169
189
|
const id = await webhookSubscriptionId(principal, request.rawUrl, request.name, request.input);
|
|
170
190
|
const own = await open();
|
|
171
191
|
const subscribed = await own.session.subscribe(request.name, request.input, {
|
|
@@ -176,7 +196,7 @@ function webhookDelivery(options = {}) {
|
|
|
176
196
|
await own.close();
|
|
177
197
|
return { error: mcpSubscribeError(subscribed) };
|
|
178
198
|
}
|
|
179
|
-
const reason = closed ? "connection_refused" : await verify(principal, url, id, secret);
|
|
199
|
+
const reason = closed ? "connection_refused" : await verify(principal, request.rawUrl, url, id, secret);
|
|
180
200
|
if (reason !== void 0 || closed) {
|
|
181
201
|
await subscribed.return();
|
|
182
202
|
await own.close();
|
|
@@ -198,6 +218,7 @@ function webhookDelivery(options = {}) {
|
|
|
198
218
|
lastDeliveryAt: null,
|
|
199
219
|
lastError: null
|
|
200
220
|
};
|
|
221
|
+
release();
|
|
201
222
|
subscriptions.set(key, subscription);
|
|
202
223
|
grant(subscription, request.ttlMs);
|
|
203
224
|
const work = pump(subscription).finally(() => pumps.delete(work));
|
|
@@ -210,7 +231,7 @@ function webhookDelivery(options = {}) {
|
|
|
210
231
|
if ("error" in read) return read;
|
|
211
232
|
const { url, secret } = read;
|
|
212
233
|
const key = subscriptionKey(principal, read.rawUrl, read.name, read.input);
|
|
213
|
-
|
|
234
|
+
for (let pending = starting.get(key); pending !== void 0; pending = starting.get(key)) await pending.catch(() => void 0);
|
|
214
235
|
const existing = subscriptions.get(key);
|
|
215
236
|
if (existing !== void 0) {
|
|
216
237
|
if (!isMaxAgeMs(read.maxAgeMs)) return invalidParams("maxAgeMs must be an integer that is 0 or more.");
|
|
@@ -226,7 +247,9 @@ function webhookDelivery(options = {}) {
|
|
|
226
247
|
}
|
|
227
248
|
const started = start(principal, key, url, secret, read, open);
|
|
228
249
|
starting.set(key, started);
|
|
229
|
-
return started.finally(() =>
|
|
250
|
+
return started.finally(() => {
|
|
251
|
+
if (starting.get(key) === started) starting.delete(key);
|
|
252
|
+
});
|
|
230
253
|
},
|
|
231
254
|
unsubscribe(principal, params) {
|
|
232
255
|
const { name, arguments: input = {}, delivery } = params;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acid-cat/core-ts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Serve AcidCat capabilities and events in plain TypeScript, with no Effect dependency.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"acidcat",
|
|
@@ -36,12 +36,12 @@
|
|
|
36
36
|
"access": "public"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@acid-cat/catalog": "0.
|
|
40
|
-
"@acid-cat/mcp-handshake": "0.
|
|
41
|
-
"@acid-cat/spec-ts": "0.
|
|
39
|
+
"@acid-cat/catalog": "0.11.0",
|
|
40
|
+
"@acid-cat/mcp-handshake": "0.11.0",
|
|
41
|
+
"@acid-cat/spec-ts": "0.11.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@acid-cat/cli": "0.
|
|
44
|
+
"@acid-cat/cli": "0.11.0",
|
|
45
45
|
"@types/node": "26.6.6",
|
|
46
46
|
"esbuild": "0.28.2",
|
|
47
47
|
"graphql": "17.1.0",
|