@acid-cat/core-ts 0.3.1 → 0.5.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 CHANGED
@@ -27,17 +27,20 @@ export const aircon = defineApp<{ unit: Unit }>({
27
27
  audience: ["cli", "mcp"],
28
28
  input: z.object({ on: z.boolean() }),
29
29
  failures: { Offline },
30
- run: async ({ on }, { context, signal }) => ((await context.unit.setPower(on, signal)) ? ok(null) : fail("Offline")),
30
+ run: async ({ on }, { context, signal }) => ((await context.unit.setPower(on, signal)) ? ok(null) : fail({ _tag: "Offline" })),
31
31
  }),
32
32
  ],
33
33
  });
34
34
  ```
35
35
 
36
- - `run` gets the input, an `AbortSignal` and the context. It returns `ok(value)` or `fail(tag, data)`. If it throws, the call fails as `internal`.
36
+ - `run` gets the input, an `AbortSignal` and the context. 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
+ - With an input schema, `run` gets the output of the schema. With none, it gets an empty object.
37
38
  - A failure schema describes the whole failure object, with its `_tag`.
38
- - `availability` is an optional function that returns an observable availability.
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.
39
40
  - `state` is an optional `{ schema, source }`: the schema of the [state](../../spec/invocation.md#state), and a function that returns an observable. The handle gives it as `snapshot`.
40
41
  - `capabilitiesFor<Context>()` sets the context type one time. The other types come from the schemas.
42
+ - `defineCapability` and `capabilitiesFor` keep the literal type of `id`, so that an application can type its ids. `run`, `availability` and `state` all get the same `Context`.
43
+ - An application lists only capabilities that `defineCapability` or `capabilitiesFor` made. Each one has `decode`, which validates an input that is not trusted with the input schema and then gives the call of `run`. A session calls it, because there the type of the input is not known.
41
44
 
42
45
  ## Write the catalog
43
46
 
@@ -60,16 +63,31 @@ Standard Schema does not convert to JSON Schema. Give the converter of your sche
60
63
  | `cliMain(app, options)`, `runCli(…)` | [CLI](../../spec/bindings/cli.md) | The command line of the process, or an exit code |
61
64
  | `installWebMcp(session)` | [WebMCP](../../spec/bindings/webmcp.md) | Tools in the page, with a status |
62
65
 
63
- ### Context for each request
66
+ When the signal of a request aborts, `mcpHandler` and `httpHandler` cancel the call of the request.
64
67
 
65
- `mcpHandler` and `httpHandler` take `context` or `createContext`. With `context`, all requests share one context. With `createContext(request)`, the handler makes a new context for each request that passes the screening. Use it when a request must not use objects that an earlier request made, for example on Cloudflare Workers, or when a stateful service must not be shared between callers. When the context has `Symbol.asyncDispose` or `Symbol.dispose`, the handler calls it after the response:
68
+ ### Typed calls and handles
69
+
70
+ `createSession` gives a `Session<Context>`. It calls a capability with the types of the capability in two ways. Both take the capability object, so the types come from its schemas:
71
+
72
+ - `session.call(capability, input)` takes an input that is not trusted, for example the arguments of a request. The input schema of the capability checks it. The result has the types of the capability. An input that does not agree rejects as `invalid-input`.
73
+ - `session.handle(capability)` gives the handle of the capability (spec/bindings/typescript.md#handles): its availability, its `snapshot` of the state, and `execute` for an input that the types already check.
74
+
75
+ Both call the capability through the session: availability, cancellation, and the checks of the output and of the failures. The session must serve that capability object. For any other capability, also one with the same id, a call rejects as `unknown-capability`, and the handle is always unavailable.
76
+
77
+ `session.handles` is the map of the handles by id, for bindings. A handle of the map has the input type `never`: call it with `invokeExternal`, which validates the input. A function that gets a session declares its context: `(session: Session<{ unit: Unit }>) => …`.
78
+
79
+ A session is async-disposable. `await using` closes it at the end of the block, as `dispose()` does:
66
80
 
67
81
  ```ts
68
- const handler = httpHandler(aircon, {
69
- allowedOrigins: [],
70
- toJsonSchema,
71
- createContext: () => ({ unit: connectUnit(env), [Symbol.asyncDispose]: () => disconnectUnit() }),
72
- });
82
+ import { createSession } from "@acid-cat/core-ts";
83
+
84
+ await using session = createSession(aircon, { target: "app", context: { unit }, toJsonSchema });
85
+ const result = await session.call(powerSet, request.body); // Result<null, { readonly _tag: "Offline"; since?: string }>
86
+ if (!result.ok) console.log(result.error._tag); // "Offline"
87
+
88
+ const power = session.handle(powerSet);
89
+ power.availability.subscribe(render);
90
+ await power.execute({ on: true });
73
91
  ```
74
92
 
75
93
  ### Context for each request
@@ -84,9 +102,29 @@ const handler = httpHandler(aircon, {
84
102
  });
85
103
  ```
86
104
 
105
+ ### Commands of the application
106
+
107
+ The option `commands` of `cliMain` and `runCli` adds commands of the application beside the commands of the capabilities, `call` and `list`. A command has a `name`, a `description` for the help, and `run(args, io)`. `run` gets the arguments after the name of the command, and `io` with `stdout`, `stderr`, `signal` and the `context`. It returns the exit code. The name must not be `call`, `list`, the name of another command of the application, or the first word of the command of a capability. If it is, `runCli` throws. The option `description` gives the text at the top of the help. The default is the description of the application, then its title, then the name of the program.
108
+
109
+ ```ts
110
+ import { cliMain } from "@acid-cat/core-ts";
111
+ import type { CliCommand } from "@acid-cat/core-ts";
112
+
113
+ const report: CliCommand<{ unit: Unit }> = {
114
+ name: "report",
115
+ description: "Write the temperature of the room.",
116
+ run: (_args, { context, stdout }) => {
117
+ stdout(`The room is at ${context.unit.state.roomCelsius} °C.`);
118
+ return 0;
119
+ },
120
+ };
121
+
122
+ cliMain(aircon, { name: "aircon", version: "0.1.0", context: { unit }, toJsonSchema, description: "Control the air conditioner.", commands: [report] });
123
+ ```
124
+
87
125
  ## Examples and tests
88
126
 
89
- - [`examples/aircon.ts`](examples/aircon.ts) is the air conditioner of [`examples/aircon.pkl`](../../examples/aircon.pkl), with a simulated unit. Its catalog agrees with the Pkl catalog.
90
- - [`examples/aircon-cli.ts`](examples/aircon-cli.ts) is its command line: `node examples/aircon-cli.ts aircon power set --on`.
91
- - [`examples/apis`](../../examples/apis/README.md) builds GraphQL, Cap'n Web and Elysia APIs on it, each beside the HTTP binding.
92
- - `moon run core-ts:test` tests sessions, handles and all bindings with a test application, and runs `acid-cat conform` against all bindings.
127
+ - [`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.
128
+ - `moon run core-ts:test` tests sessions, handles and all bindings with a test application, and runs `acid-cat conform` against all bindings. Each test application is in `test/`:
129
+ - [`test/fixture.ts`](test/fixture.ts) has one capability for each behavior that `acid-cat conform` tests.
130
+ - [`test/kettle.ts`](test/kettle.ts) is an electric kettle, simulated in memory. Its tests examine the catalog that the core writes, named types, state, availability, typed handles and the command line.
package/dist/app.d.ts CHANGED
@@ -7,45 +7,111 @@ export interface Observable<T> {
7
7
  unsubscribe(): void;
8
8
  };
9
9
  }
10
- type Output<S> = S extends StandardSchemaV1 ? StandardSchemaV1.InferOutput<S> : null;
11
- type FailureOf<F> = {
12
- [Tag in keyof F]: {
10
+ /** The input of a capability that has no input schema: an empty object. */
11
+ export type EmptyInput = Record<string, never>;
12
+ /** The data of each failure, by tag: the output of its schema. */
13
+ export type FailureData = {
14
+ readonly [tag: string]: object;
15
+ };
16
+ /** The failures of a capability, each with its `_tag`. */
17
+ export type FailureOf<Failures> = {
18
+ [Tag in keyof Failures]: {
13
19
  readonly _tag: Tag;
14
- } & (F[Tag] extends StandardSchemaV1 ? StandardSchemaV1.InferOutput<F[Tag]> : object);
15
- }[keyof F];
16
- export interface Capability<Context = unknown, Input extends StandardSchemaV1 | undefined = StandardSchemaV1 | undefined, OutputSchema extends StandardSchemaV1 | undefined = StandardSchemaV1 | undefined, Failures extends {
17
- readonly [tag: string]: StandardSchemaV1;
18
- } = {
19
- readonly [tag: string]: StandardSchemaV1;
20
- }> {
21
- readonly id: string;
20
+ } & Failures[Tag];
21
+ }[keyof Failures];
22
+ /** The failure schemas of a capability, by tag. The schema of a failure describes the object, without or with its `_tag`. */
23
+ export type FailureSchemas<Failures> = {
24
+ readonly [Tag in keyof Failures]: StandardSchemaV1<unknown, Failures[Tag]>;
25
+ };
26
+ /** What `run` receives beside the input: the signal of the call, and the context. */
27
+ export interface Call<Context> {
28
+ readonly signal: AbortSignal;
29
+ readonly context: Context;
30
+ }
31
+ /** The function of a capability. Its result has the types of the output schema and of the failure schemas. */
32
+ 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>>>;
33
+ /** The members of a capability that do not depend on its input. */
34
+ export interface CapabilityParts<Context, Value, Failures, State, Id extends string> {
35
+ readonly id: Id;
22
36
  readonly description: string;
23
37
  /** The targets that can use the capability. With no audience, no target can use it. */
24
38
  readonly audience: readonly string[];
25
39
  readonly readOnly?: boolean | undefined;
26
- /** The input: an object schema. With no input schema, the input is an empty object. */
27
- readonly input?: Input;
28
40
  /** The value of a successful call. With no output schema, the value is `null`. */
29
- readonly output?: OutputSchema;
30
- /** The failures, by tag. The schema of a failure describes the object, without or with its `_tag`. */
31
- readonly failures?: Failures;
41
+ readonly output?: StandardSchemaV1<unknown, Value> | undefined;
42
+ /** The failures, by tag. */
43
+ readonly failures?: FailureSchemas<Failures> | undefined;
32
44
  /** A value that clients can observe (spec/invocation.md#state): its schema, and its source. */
45
+ readonly state?: {
46
+ readonly schema: StandardSchemaV1<unknown, State>;
47
+ readonly source: (context: Context) => Observable<NoInfer<State>>;
48
+ } | undefined;
49
+ /** The availability of the capability. With none, the capability is always available. */
50
+ readonly availability?: ((context: Context) => Observable<Availability>) | undefined;
51
+ }
52
+ /**
53
+ * A capability with an input schema: an object schema, or a union of object schemas. The catalog writes a union with
54
+ * `"type": "object"`. `run` receives the output of the schema.
55
+ */
56
+ export interface WithInput<Context, Input, Value, Failures> {
57
+ readonly input: StandardSchemaV1<unknown, Input>;
58
+ readonly run: Run<Context, Input, Value, Failures>;
59
+ }
60
+ /** A capability with no input schema. `run` receives an empty object. */
61
+ export interface WithoutInput<Context, Value, Failures> {
62
+ readonly input?: undefined;
63
+ readonly run: Run<Context, EmptyInput, Value, Failures>;
64
+ }
65
+ /**
66
+ * A capability as an application declares it. `Context` is the type of the services that `run` receives. `Input`,
67
+ * `Value`, `Failures` and `State` are the outputs of its schemas.
68
+ */
69
+ export type CapabilityDeclaration<Context, Input, Value, Failures, State, Id extends string> = CapabilityParts<Context, Value, Failures, State, Id> & (WithInput<Context, Input, Value, Failures> | WithoutInput<Context, Value, Failures>);
70
+ /** The result of `decode`: the issues of an input that is not valid, or the call of `run` with the decoded input. */
71
+ export type Decoded<Context> = {
72
+ readonly issues: readonly StandardSchemaV1.Issue[];
73
+ } | {
74
+ readonly start: (call: Call<Context>) => Promise<Result<unknown, unknown>> | Result<unknown, unknown>;
75
+ };
76
+ /** Decode an input that is not trusted with the input schema of a capability. */
77
+ export type Decode<Context> = (input: unknown) => Promise<Decoded<Context>>;
78
+ /**
79
+ * A capability that `defineCapability` or `capabilitiesFor` made: its declaration, and `decode`. A session calls
80
+ * `decode` for an input that is not trusted, because the type of the input is not known there.
81
+ */
82
+ export type Capability<Context = unknown, Input = EmptyInput, Value = null, Failures = {}, State = never, Id extends string = string> = CapabilityDeclaration<Context, Input, Value, Failures, State, Id> & {
83
+ readonly decode: Decode<Context>;
84
+ };
85
+ /** Any capability whose services the context `Context` gives, whatever its schemas are. */
86
+ export interface AnyCapability<Context = never> {
87
+ readonly id: string;
88
+ readonly description: string;
89
+ readonly audience: readonly string[];
90
+ readonly readOnly?: boolean | undefined;
91
+ readonly input?: StandardSchemaV1 | undefined;
92
+ readonly output?: StandardSchemaV1 | undefined;
93
+ readonly failures?: {
94
+ readonly [tag: string]: StandardSchemaV1;
95
+ } | undefined;
33
96
  readonly state?: {
34
97
  readonly schema: StandardSchemaV1;
35
98
  readonly source: (context: Context) => Observable<unknown>;
36
99
  } | undefined;
37
- /** The availability of the capability. With none, the capability is always available. */
38
100
  readonly availability?: ((context: Context) => Observable<Availability>) | undefined;
39
- readonly run: (input: Input extends StandardSchemaV1 ? StandardSchemaV1.InferOutput<Input> : Record<string, never>, call: {
40
- readonly signal: AbortSignal;
41
- readonly context: Context;
42
- }) => Promise<Result<Output<OutputSchema>, FailureOf<Failures>>> | Result<Output<OutputSchema>, FailureOf<Failures>>;
101
+ readonly decode: Decode<Context>;
102
+ }
103
+ /**
104
+ * Declare a capability. `Context` is the type of the services that `run` receives. The other types come from the
105
+ * schemas, and the id keeps its literal type. With an input schema, `run` receives the output of the schema. With
106
+ * none, it receives an empty object.
107
+ */
108
+ export declare function defineCapability<Context = unknown, Input = EmptyInput, Value = null, Failures extends FailureData = {}, State = never, const Id extends string = string>(declaration: CapabilityParts<Context, Value, Failures, State, Id> & WithInput<Context, Input, Value, Failures>): Capability<Context, Input, Value, Failures, State, Id>;
109
+ export declare function defineCapability<Context = unknown, Value = null, Failures extends FailureData = {}, State = never, const Id extends string = string>(declaration: CapabilityParts<Context, Value, Failures, State, Id> & WithoutInput<Context, Value, Failures>): Capability<Context, EmptyInput, Value, Failures, State, Id>;
110
+ /** The function that `capabilitiesFor` gives: `defineCapability`, with one context type. */
111
+ export interface CapabilityFactory<Context> {
112
+ <Input, Value = null, Failures extends FailureData = {}, State = never, const Id extends string = string>(declaration: CapabilityParts<Context, Value, Failures, State, Id> & WithInput<Context, Input, Value, Failures>): Capability<Context, Input, Value, Failures, State, Id>;
113
+ <Value = null, Failures extends FailureData = {}, State = never, const Id extends string = string>(declaration: CapabilityParts<Context, Value, Failures, State, Id> & WithoutInput<Context, Value, Failures>): Capability<Context, EmptyInput, Value, Failures, State, Id>;
43
114
  }
44
- export type AnyCapability<Context = any> = Capability<Context, any, any, any>;
45
- /** Declare a capability. `Context` is the type of the services that `run` receives. */
46
- export declare const defineCapability: <Context = unknown, Input extends StandardSchemaV1 | undefined = undefined, OutputSchema extends StandardSchemaV1 | undefined = undefined, Failures extends {
47
- readonly [tag: string]: StandardSchemaV1;
48
- } = {}>(capability: Capability<Context, Input, OutputSchema, Failures>) => Capability<Context, Input, OutputSchema, Failures>;
49
115
  /**
50
116
  * Declare capabilities for one context type. The other types come from the schemas:
51
117
  *
@@ -54,9 +120,7 @@ export declare const defineCapability: <Context = unknown, Input extends Standar
54
120
  * capability({ id: "aircon.power.set", input: z.object({ on: z.boolean() }), run: ({ on }, { context }) => … });
55
121
  * ```
56
122
  */
57
- export declare const capabilitiesFor: <Context>() => <Input extends StandardSchemaV1 | undefined = undefined, OutputSchema extends StandardSchemaV1 | undefined = undefined, Failures extends {
58
- readonly [tag: string]: StandardSchemaV1;
59
- } = {}>(capability: Capability<Context, Input, OutputSchema, Failures>) => Capability<Context, Input, OutputSchema, Failures>;
123
+ export declare const capabilitiesFor: <Context>() => CapabilityFactory<Context>;
60
124
  export interface Target {
61
125
  readonly bindings: readonly string[];
62
126
  readonly description?: string | undefined;
@@ -74,12 +138,14 @@ export interface App<Context = unknown> {
74
138
  export declare const defineApp: <Context = unknown>(app: App<Context>) => App<Context>;
75
139
  /** Converts a schema to JSON Schema 2020-12, for example `(schema) => z.toJSONSchema(schema)` with Zod. */
76
140
  export type ToJsonSchema = (schema: StandardSchemaV1) => JsonObject;
77
- /** The catalog of an application, as a JSON document. */
78
- export declare function catalogOf(app: App<any>, options: {
141
+ /**
142
+ * The catalog of an application, as a JSON document. The catalog is written one time for each application and
143
+ * `toJsonSchema`. Later calls with the same two give the same document.
144
+ */
145
+ export declare function catalogOf(app: App<never>, options: {
79
146
  readonly toJsonSchema: ToJsonSchema;
80
147
  }): CatalogDocument;
81
148
  /** The catalog of an application, as text in canonical form. */
82
- export declare const renderCatalog: (app: App<any>, options: {
149
+ export declare const renderCatalog: (app: App<never>, options: {
83
150
  readonly toJsonSchema: ToJsonSchema;
84
151
  }) => string;
85
- export {};
package/dist/app.js CHANGED
@@ -1,11 +1,30 @@
1
1
  import { canonicalText } from "@acid-cat/catalog";
2
+ import { isJsonObject, objectInput } from "@acid-cat/spec-ts";
2
3
  //#region src/app.ts
3
4
  /**
4
5
  * Capabilities and applications in plain TypeScript. An application writes its own catalog.
5
6
  * See spec/catalog.md.
6
7
  */
7
- /** Declare a capability. `Context` is the type of the services that `run` receives. */
8
- const defineCapability = (capability) => capability;
8
+ function defineCapability(declaration) {
9
+ return {
10
+ ...declaration,
11
+ decode: decoderOf(declaration)
12
+ };
13
+ }
14
+ /** The `decode` of a capability: validate the input with the input schema, then give the call of `run`. */
15
+ function decoderOf(declaration) {
16
+ if (declaration.input === void 0) {
17
+ const { run } = declaration;
18
+ return () => Promise.resolve({ start: (call) => run({}, call) });
19
+ }
20
+ const { input: schema, run } = declaration;
21
+ return async (input) => {
22
+ const validated = await schema["~standard"].validate(input);
23
+ if (validated.issues !== void 0) return { issues: validated.issues };
24
+ const { value } = validated;
25
+ return { start: (call) => run(value, call) };
26
+ };
27
+ }
9
28
  /**
10
29
  * Declare capabilities for one context type. The other types come from the schemas:
11
30
  *
@@ -14,15 +33,33 @@ const defineCapability = (capability) => capability;
14
33
  * capability({ id: "aircon.power.set", input: z.object({ on: z.boolean() }), run: ({ on }, { context }) => … });
15
34
  * ```
16
35
  */
17
- const capabilitiesFor = () => (capability) => capability;
36
+ const capabilitiesFor = () => defineCapability;
18
37
  /** Declare an application. */
19
38
  const defineApp = (app) => app;
20
- /** The catalog of an application, as a JSON document. */
39
+ const catalogs = /* @__PURE__ */ new WeakMap();
40
+ /**
41
+ * The catalog of an application, as a JSON document. The catalog is written one time for each application and
42
+ * `toJsonSchema`. Later calls with the same two give the same document.
43
+ */
21
44
  function catalogOf(app, options) {
45
+ let byConverter = catalogs.get(app);
46
+ if (byConverter === void 0) {
47
+ byConverter = /* @__PURE__ */ new WeakMap();
48
+ catalogs.set(app, byConverter);
49
+ }
50
+ let written = byConverter.get(options.toJsonSchema);
51
+ if (written === void 0) {
52
+ written = write(app, options.toJsonSchema);
53
+ byConverter.set(options.toJsonSchema, written);
54
+ }
55
+ return written;
56
+ }
57
+ function write(app, toJsonSchema) {
22
58
  const types = {};
23
59
  const toJson = (schema, where) => {
24
- const { $schema: _, $defs, ...json } = options.toJsonSchema(schema);
25
- for (const [name, definition] of Object.entries($defs ?? {})) {
60
+ const { $schema: _, $defs, ...json } = toJsonSchema(schema);
61
+ for (const [name, definition] of Object.entries(isJsonObject($defs) ? $defs : {})) {
62
+ if (!isJsonObject(definition)) throw new Error(`${where}: the schema ${name} is not an object. Give each named schema as an object.`);
26
63
  const existing = types[name];
27
64
  if (existing !== void 0 && JSON.stringify(existing) !== JSON.stringify(definition)) throw new Error(`${where}: two different schemas have the name ${name}. Give each one its own name.`);
28
65
  types[name] = definition;
@@ -36,7 +73,7 @@ function catalogOf(app, options) {
36
73
  capabilities[capability.id] = {
37
74
  description: capability.description,
38
75
  audience: capability.audience,
39
- ...capability.input === void 0 ? {} : { input: toJson(capability.input, capability.id) },
76
+ ...capability.input === void 0 ? {} : { input: objectInput(toJson(capability.input, capability.id), types) },
40
77
  ...capability.output === void 0 ? {} : { output: toJson(capability.output, capability.id) },
41
78
  ...Object.keys(failures).length === 0 ? {} : { failures },
42
79
  ...capability.readOnly === true ? { readOnly: true } : {},
package/dist/cli.d.ts CHANGED
@@ -6,12 +6,32 @@ export interface CliOptions<Context> {
6
6
  readonly toJsonSchema: ToJsonSchema;
7
7
  /** The target that the command line serves. The default is `"cli"`. */
8
8
  readonly target?: string | undefined;
9
+ /**
10
+ * The text at the top of the help. The default is the description of the application, then its title, then `name`.
11
+ */
12
+ readonly description?: string | undefined;
13
+ /**
14
+ * The commands of the application, beside the commands of the capabilities, `call` and `list`. The name of each one
15
+ * must not be `call`, `list`, the name of another command of the application, or the first word of the command of a
16
+ * capability (spec/bindings/cli.md#commands).
17
+ */
18
+ readonly commands?: readonly CliCommand<Context>[] | undefined;
9
19
  }
10
20
  export interface CliIo {
11
21
  readonly stdout: (text: string) => void;
12
22
  readonly stderr: (text: string) => void;
13
23
  readonly signal?: AbortSignal | undefined;
14
24
  }
25
+ /** A command that the application adds to the command line. */
26
+ export interface CliCommand<Context> {
27
+ readonly name: string;
28
+ /** The text that the help gives for the command. */
29
+ readonly description: string;
30
+ /** Run the command with the arguments that come after its name. The result is the exit code. */
31
+ run(args: readonly string[], io: CliIo & {
32
+ readonly context: Context;
33
+ }): number | Promise<number>;
34
+ }
15
35
  /** Run the command line as the main program of the process. An interrupt (`SIGINT`) cancels the call. */
16
36
  export declare function main<Context>(app: App<Context>, options: CliOptions<Context>): void;
17
37
  /** Run the command line with arguments. The result is the exit code (spec/bindings/cli.md#outcomes). */
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createSession } from "./session.js";
2
- import { CLI_EXIT_CODES, REFERENCE, bundle, cliExitCode, cliFlags, cliUsage, commandPath, isCallFailure } from "@acid-cat/spec-ts";
2
+ import { CLI_EXIT_CODES, REFERENCE, bundle, cliExitCode, cliFlags, cliUsage, commandPath, isCallFailure, isJsonObject } from "@acid-cat/spec-ts";
3
3
  //#region src/cli.ts
4
4
  /**
5
5
  * The CLI binding (spec/bindings/cli.md): a command line made from the catalog.
@@ -36,6 +36,16 @@ async function runCli(app, options, args, io) {
36
36
  path: commandPath(id)
37
37
  }));
38
38
  for (const { path } of commands) if (path[0] === "call" || path[0] === "list") throw new Error(`The command ${path.join(" ")} has the same name as a command of the command line.`);
39
+ const added = options.commands ?? [];
40
+ const taken = /* @__PURE__ */ new Set([
41
+ "call",
42
+ "list",
43
+ ...commands.flatMap(({ path }) => path.slice(0, 1))
44
+ ]);
45
+ for (const command of added) {
46
+ if (taken.has(command.name)) throw new Error(`The command ${command.name} of the application has the same name as another command.`);
47
+ taken.add(command.name);
48
+ }
39
49
  const print = (outcome) => {
40
50
  if (isCallFailure(outcome)) io.stderr(JSON.stringify({
41
51
  kind: outcome.kind,
@@ -47,7 +57,8 @@ async function runCli(app, options, args, io) {
47
57
  try {
48
58
  const [first, ...rest] = args;
49
59
  if (first === void 0 || first === "--help" || first === "-h") {
50
- io.stdout(help(options.name, commands.map(({ path }) => path.join(" "))));
60
+ const description = options.description ?? app.description ?? app.title ?? options.name;
61
+ io.stdout(help(options.name, description, commands.map(({ path }) => path.join(" ")), added));
51
62
  return CLI_EXIT_CODES.value;
52
63
  }
53
64
  if (first === "--version" || first === "-v") {
@@ -56,7 +67,7 @@ async function runCli(app, options, args, io) {
56
67
  }
57
68
  if (first === "list") {
58
69
  io.stdout(JSON.stringify([...session.handles.values()].map((handle) => {
59
- const input = session.view.capabilities[handle.id].input;
70
+ const input = viewCapability(session.view, handle.id).input;
60
71
  return {
61
72
  id: handle.id,
62
73
  description: handle.description,
@@ -80,9 +91,14 @@ async function runCli(app, options, args, io) {
80
91
  }
81
92
  return print(await session.invoke(id, input, { signal: io.signal }));
82
93
  }
94
+ const command = added.find(({ name }) => name === first);
95
+ if (command !== void 0) return await command.run(rest, {
96
+ ...io,
97
+ context: options.context
98
+ });
83
99
  const match = commands.filter(({ path }) => path.every((word, index) => args[index] === word)).sort((a, b) => b.path.length - a.path.length)[0];
84
100
  if (match === void 0) throw new UsageError(`There is no command ${args.filter((arg) => !arg.startsWith("-")).join(" ")}.`, CLI_EXIT_CODES["unknown-capability"]);
85
- const capability = session.view.capabilities[match.id];
101
+ const capability = viewCapability(session.view, match.id);
86
102
  const schema = bundle(capability.input ?? { type: "object" }, types);
87
103
  const flagArgs = args.slice(match.path.length);
88
104
  if (flagArgs.includes("--help") || flagArgs.includes("-h")) {
@@ -100,25 +116,26 @@ async function runCli(app, options, args, io) {
100
116
  }
101
117
  /** Make the input from the flags (spec/bindings/cli.md#commands). */
102
118
  function parseFlags(args, schema) {
103
- const properties = schema.properties ?? {};
104
- const definitions = schema.$defs ?? {};
119
+ const properties = objectOrEmpty(schema.properties);
120
+ const definitions = objectOrEmpty(schema.$defs);
105
121
  const input = {};
106
- for (let index = 0; index < args.length; index += 1) {
107
- const arg = args[index];
122
+ const remaining = args.values();
123
+ for (const arg of remaining) {
108
124
  if (!arg.startsWith("--")) throw new UsageError(`Unexpected argument ${arg}.`, CLI_EXIT_CODES["invalid-input"]);
109
- const [name, inline] = arg.slice(2).split(/=(.*)/s, 2);
125
+ const [name = "", inline] = arg.slice(2).split(/=(.*)/s, 2);
110
126
  const property = properties[name];
111
127
  if (property === void 0) throw new UsageError(`Unknown flag --${name}.`, CLI_EXIT_CODES["invalid-input"]);
112
- const type = resolve(property, definitions);
128
+ const type = resolve(objectOrEmpty(property), definitions);
113
129
  if (type.type === "boolean") {
114
130
  input[name] = inline === void 0 ? true : inline === "true";
115
131
  continue;
116
132
  }
117
- const text = inline ?? args[index += 1];
133
+ const text = inline ?? remaining.next().value;
118
134
  if (text === void 0) throw new UsageError(`The flag --${name} needs a value.`, CLI_EXIT_CODES["invalid-input"]);
119
135
  input[name] = valueOf(name, text, type);
120
136
  }
121
- for (const name of schema.required ?? []) if (!(name in input)) throw new UsageError(`The flag --${name} is required.`, CLI_EXIT_CODES["invalid-input"]);
137
+ const required = Array.isArray(schema.required) ? schema.required.filter((name) => typeof name === "string") : [];
138
+ for (const name of required) if (!(name in input)) throw new UsageError(`The flag --${name} is required.`, CLI_EXIT_CODES["invalid-input"]);
122
139
  return input;
123
140
  }
124
141
  function valueOf(name, text, type) {
@@ -140,16 +157,27 @@ function valueOf(name, text, type) {
140
157
  }
141
158
  function resolve(property, definitions) {
142
159
  const name = typeof property.$ref === "string" ? REFERENCE.exec(property.$ref)?.[1] : void 0;
143
- return name === void 0 ? property : definitions[name] ?? property;
160
+ return name === void 0 ? property : objectOrEmpty(definitions[name] ?? property);
161
+ }
162
+ /** A value as a JSON object, or an empty object when it is not one. */
163
+ const objectOrEmpty = (value) => isJsonObject(value) ? value : {};
164
+ /** The capability `id` of a view. The view has it, because the session serves it. */
165
+ function viewCapability(view, id) {
166
+ const capability = view.capabilities[id];
167
+ if (capability === void 0) throw new Error(`The view of the session has no capability ${id}.`);
168
+ return capability;
144
169
  }
145
- function help(name, paths) {
170
+ function help(name, description, paths, added) {
146
171
  return [
172
+ description,
173
+ "",
147
174
  `Usage: ${name} <command> [flags]`,
148
175
  "",
149
176
  "Commands:",
150
177
  ...paths.map((path) => ` ${path}`),
151
178
  " call <id> [input]",
152
- " list"
179
+ " list",
180
+ ...added.map((command) => ` ${command.name} ${command.description}`)
153
181
  ].join("\n");
154
182
  }
155
183
  function usage(name, path, description, schema) {
package/dist/context.js CHANGED
@@ -12,9 +12,12 @@ async function withContext(option, request, use) {
12
12
  /** Call `Symbol.asyncDispose` or `Symbol.dispose` of a value, if it has one. */
13
13
  async function release(value) {
14
14
  if (typeof value !== "object" || value === null) return;
15
- const disposable = value;
16
- if (typeof disposable[Symbol.asyncDispose] === "function") await disposable[Symbol.asyncDispose]();
17
- else if (typeof disposable[Symbol.dispose] === "function") disposable[Symbol.dispose]();
15
+ const asyncDispose = Symbol.asyncDispose in value ? value[Symbol.asyncDispose] : void 0;
16
+ const dispose = Symbol.dispose in value ? value[Symbol.dispose] : void 0;
17
+ if (isFunction(asyncDispose)) await asyncDispose.call(value);
18
+ else if (isFunction(dispose)) dispose.call(value);
18
19
  }
20
+ /** Tell if a value is a function. A function can take any arguments, and its result is not known. */
21
+ const isFunction = (value) => typeof value === "function";
19
22
  //#endregion
20
23
  export { withContext };
package/dist/http.js CHANGED
@@ -16,6 +16,8 @@ function httpHandler(app, options) {
16
16
  maximumBytes
17
17
  });
18
18
  if (screened !== void 0) return rejection(screened);
19
+ const body = await readBounded(request, maximumBytes);
20
+ if ("rejection" in body) return rejection(body.rejection);
19
21
  const path = new URL(request.url).pathname.slice((options.base ?? "").length);
20
22
  return withContext(options, request, async (context) => {
21
23
  const session = createSession(app, {
@@ -31,8 +33,6 @@ function httpHandler(app, options) {
31
33
  if (request.method === "GET" && path === "/catalog" && options.serveCatalog !== false) return json(200, session.view);
32
34
  const id = request.method === "POST" && path.startsWith("/capabilities/") ? path.slice(14) : void 0;
33
35
  if (id === void 0) return json(404, callFailure("unknown-capability", "There is no such route."));
34
- const body = await readBounded(request, maximumBytes);
35
- if ("rejection" in body) return rejection(body.rejection);
36
36
  let input;
37
37
  try {
38
38
  input = body.text === "" ? {} : JSON.parse(body.text);
package/dist/index.d.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  export { capabilitiesFor, catalogOf, defineApp, defineCapability, renderCatalog } from "./app.js";
2
- export type { AnyCapability, App, Capability, Observable, Target, ToJsonSchema } from "./app.js";
2
+ export type { AnyCapability, App, Call, Capability, CapabilityDeclaration, CapabilityFactory, EmptyInput, FailureOf, Observable, Target, ToJsonSchema } from "./app.js";
3
3
  export { createSession } from "./session.js";
4
- export type { Handle, Session, SessionOptions } from "./session.js";
4
+ export type { Handle, HandleOf, Session, SessionOptions } from "./session.js";
5
5
  export { mcpHandler } from "./mcp.js";
6
6
  export type { McpOptions, McpSettings } from "./mcp.js";
7
7
  export { httpHandler } from "./http.js";
8
8
  export type { HttpOptions, HttpSettings } from "./http.js";
9
9
  export type { ContextOption } from "./context.js";
10
10
  export { main as cliMain, runCli } from "./cli.js";
11
- export type { CliIo, CliOptions } from "./cli.js";
11
+ export type { CliCommand, CliIo, CliOptions } from "./cli.js";
12
12
  export { findHost, installWebMcp } from "./webmcp.js";
13
13
  export type { Installation, ModelContext, ModelContextTool, Phase, Status } from "./webmcp.js";
14
14
  export type { StandardSchemaV1 } from "./standard-schema.js";
package/dist/mcp.js CHANGED
@@ -1,11 +1,27 @@
1
1
  import { createSession } from "./session.js";
2
2
  import { withContext } from "./context.js";
3
- import { MCP_PROTOCOL_VERSION, MCP_REJECTIONS, MCP_UNKNOWN_TOOL, bundle, mcpToolResult, readBounded, screenRequest } from "@acid-cat/spec-ts";
3
+ import { MCP_PROTOCOL_VERSION, MCP_REJECTIONS, MCP_UNKNOWN_TOOL, bundle, isJsonObject, mcpToolResult, readBounded, screenRequest } from "@acid-cat/spec-ts";
4
4
  //#region src/mcp.ts
5
5
  /**
6
6
  * The MCP binding (spec/bindings/mcp.md): an MCP server through HTTP, revision 2026-07-28, with no state on the
7
7
  * server between requests.
8
8
  */
9
+ /**
10
+ * Read the members of a JSON-RPC request from a parsed body. A member that does not have its type is left out. An
11
+ * `id` that is not a string or a number is `null`.
12
+ */
13
+ function readRequest(body) {
14
+ if (!isJsonObject(body)) return {};
15
+ const { id, method, params } = body;
16
+ const meta = isJsonObject(params) ? params["_meta"] : void 0;
17
+ return {
18
+ ..."id" in body ? { id: typeof id === "string" || typeof id === "number" ? id : null } : {},
19
+ method: typeof method === "string" ? method : void 0,
20
+ name: isJsonObject(params) && typeof params["name"] === "string" ? params["name"] : void 0,
21
+ arguments: isJsonObject(params) ? params["arguments"] : void 0,
22
+ protocolVersion: isJsonObject(meta) ? meta["io.modelcontextprotocol/protocolVersion"] : void 0
23
+ };
24
+ }
9
25
  /** A web request handler for the MCP server. Each request has its own session, and with `createContext` its own context. */
10
26
  function mcpHandler(app, options) {
11
27
  const maximumBytes = options.maximumBytes ?? 1048576;
@@ -21,7 +37,7 @@ function mcpHandler(app, options) {
21
37
  if ("rejection" in body) return rejection(body.rejection);
22
38
  let message;
23
39
  try {
24
- message = JSON.parse(body.text);
40
+ message = readRequest(JSON.parse(body.text));
25
41
  } catch {
26
42
  return rejection("not-utf8");
27
43
  }
@@ -33,7 +49,7 @@ function mcpHandler(app, options) {
33
49
  status: 200,
34
50
  headers: { "Content-Type": "application/json" }
35
51
  });
36
- const metaVersion = message.params?._meta?.["io.modelcontextprotocol/protocolVersion"];
52
+ const metaVersion = message.protocolVersion;
37
53
  if (metaVersion === void 0) return error(400, id, -32602, "Required request metadata is missing");
38
54
  if (metaVersion !== version) return error(400, id, -32020, "MCP-Protocol-Version header does not match request metadata");
39
55
  return withContext(options, request, async (context) => {
@@ -75,10 +91,10 @@ async function answer(session, message, options, signal) {
75
91
  }, true);
76
92
  case "tools/list": return complete({ tools: listTools(session) }, true);
77
93
  case "tools/call": {
78
- const name = message.params?.name ?? "";
94
+ const name = message.name ?? "";
79
95
  const handle = session.handles.get(name);
80
96
  if (handle === void 0 || !handle.availability.getSnapshot().available) return error(200, id, MCP_UNKNOWN_TOOL, `Tool '${name}' not found`);
81
- const outcome = await session.invoke(name, message.params?.arguments ?? {}, { signal });
97
+ const outcome = await session.invoke(name, message.arguments ?? {}, { signal });
82
98
  return complete(mcpToolResult(outcome, hasObjectOutput(session, name)));
83
99
  }
84
100
  default: return error(404, id, -32601, `Method not found: ${message.method}`);
@@ -88,6 +104,7 @@ function listTools(session) {
88
104
  const types = session.view.$defs ?? {};
89
105
  return [...session.handles.values()].filter((handle) => handle.availability.getSnapshot().available).map((handle) => {
90
106
  const capability = session.view.capabilities[handle.id];
107
+ if (capability === void 0) throw new Error(`The view of the session has no capability ${handle.id}.`);
91
108
  const output = capability.output === void 0 ? void 0 : bundle(capability.output, types);
92
109
  return {
93
110
  name: handle.id,
package/dist/session.d.ts CHANGED
@@ -1,32 +1,59 @@
1
1
  import type { Availability, CatalogDocument, Outcome, Result } from "@acid-cat/spec-ts";
2
- import type { App, Observable, ToJsonSchema } from "./app.js";
3
- export interface Handle {
2
+ import type { App, Capability, FailureOf, Observable, ToJsonSchema } from "./app.js";
3
+ /**
4
+ * The handle of a capability (spec/bindings/typescript.md#handles). `Input`, `Value`, `Failure` and `State` are the
5
+ * types of the schemas of the capability. `session.handle` gives a handle with these types. A handle of
6
+ * `session.handles` has the input type `never`: only `invokeExternal` can call it, because it decodes the input.
7
+ */
8
+ export interface Handle<Input = never, Value = unknown, Failure = unknown, State = unknown> {
4
9
  readonly id: string;
5
10
  readonly description: string;
6
11
  readonly audience: readonly string[];
7
12
  readonly availability: Observable<Availability>;
8
13
  /** The state of the capability, if it has one. Each value is a copy. */
9
- readonly snapshot?: Observable<unknown>;
14
+ readonly snapshot?: Observable<State>;
10
15
  /** A call with an input that is already valid. A call failure rejects with a `CallFailureError`. */
11
- execute(input: unknown, options?: {
16
+ execute(input: Input, options?: {
12
17
  readonly signal?: AbortSignal | undefined;
13
- }): Promise<Result<unknown, unknown>>;
18
+ }): Promise<Result<Value, Failure>>;
14
19
  /** A call with an input that is not trusted. A call failure rejects with a `CallFailureError`. */
15
20
  invokeExternal(input: unknown, options?: {
16
21
  readonly signal?: AbortSignal | undefined;
17
22
  }): Promise<Result<unknown, unknown>>;
18
23
  }
19
- export interface Session {
24
+ /** The handle of a capability, with the types of its schemas. `session.handle` gives it. */
25
+ export type HandleOf<C> = C extends Capability<never, infer Input, infer Value, infer Failures, infer State, string> ? Handle<Input, Value, FailureOf<Failures>, State> : never;
26
+ export interface Session<Context = unknown> {
20
27
  readonly target: string;
21
- /** The view of the target. Bindings take names, descriptions and schemas from it. */
28
+ /**
29
+ * The view of the target. Bindings take names, descriptions and schemas from it. The session writes the view when it
30
+ * is first read.
31
+ */
22
32
  readonly view: CatalogDocument;
23
- /** The handles of the capabilities in the view, by id. */
33
+ /** The handles of the capabilities in the view, by id. Bindings use them. */
24
34
  readonly handles: ReadonlyMap<string, Handle>;
35
+ /**
36
+ * The handle of a capability, with the types of the capability. Only the capability object that the session serves
37
+ * gets a handle that calls it. Any other capability, also one with the same id, gets a handle that is always
38
+ * unavailable, and each call rejects as `unknown-capability`. Each call of `handle` gives a new handle.
39
+ */
40
+ handle<Input, Value, Failures, State, Id extends string>(capability: Capability<Context, Input, Value, Failures, State, Id>): Handle<Input, Value, FailureOf<Failures>, State>;
41
+ /**
42
+ * A call of a capability with an input that is not trusted. The input schema of the capability checks the input, so
43
+ * the result has the types of the capability. A call failure rejects with a `CallFailureError`. An input that does not
44
+ * agree with the schema rejects as `invalid-input`.
45
+ */
46
+ call<Input, Value, Failures, State, Id extends string>(capability: Capability<Context, Input, Value, Failures, State, Id>, input: unknown, options?: {
47
+ readonly signal?: AbortSignal | undefined;
48
+ }): Promise<Result<Value, FailureOf<Failures>>>;
25
49
  /** A call with an input that is not trusted. The outcome is in its JSON form. The promise never rejects. */
26
50
  invoke(id: string, input: unknown, options?: {
27
51
  readonly signal?: AbortSignal | undefined;
28
52
  }): Promise<Outcome<unknown, unknown>>;
29
- /** An event each time the availability of a capability changes. */
53
+ /**
54
+ * An event each time the availability of a capability changes. The session subscribes to the availabilities when the
55
+ * first listener subscribes, and unsubscribes after the last listener unsubscribes.
56
+ */
30
57
  onChange(listener: () => void): {
31
58
  unsubscribe(): void;
32
59
  };
@@ -34,6 +61,8 @@ export interface Session {
34
61
  readonly signal: AbortSignal;
35
62
  /** Close the session: accept no new calls, cancel the calls in progress, and wait for them. */
36
63
  dispose(): Promise<void>;
64
+ /** Close the session, as `dispose` does, at the end of the block of `await using session = createSession(…)`. */
65
+ [Symbol.asyncDispose](): Promise<void>;
37
66
  }
38
67
  export interface SessionOptions<Context> {
39
68
  readonly target: string;
@@ -44,4 +73,4 @@ export interface SessionOptions<Context> {
44
73
  readonly onDefect?: ((id: string, cause: unknown) => void) | undefined;
45
74
  }
46
75
  /** Open a session for a target. */
47
- export declare function createSession<Context>(app: App<Context>, options: SessionOptions<Context>): Session;
76
+ export declare function createSession<Context>(app: App<Context>, options: SessionOptions<Context>): Session<Context>;
package/dist/session.js CHANGED
@@ -4,55 +4,67 @@ import { AVAILABLE, CallFailureError, callFailure, isCallFailure, viewOf } from
4
4
  /**
5
5
  * Sessions and handles (spec/invocation.md, spec/bindings/typescript.md).
6
6
  */
7
+ /** The handle of a capability that the session does not serve. */
8
+ function outsideView(capability) {
9
+ const failure = callFailure("unknown-capability", `There is no capability ${capability.id}.`);
10
+ const unavailable = {
11
+ available: false,
12
+ reason: failure.message
13
+ };
14
+ const reject = () => Promise.reject(new CallFailureError(failure));
15
+ return {
16
+ id: capability.id,
17
+ description: capability.description,
18
+ audience: [],
19
+ availability: {
20
+ getSnapshot: () => unavailable,
21
+ subscribe: () => ({ unsubscribe() {} })
22
+ },
23
+ execute: reject,
24
+ invokeExternal: reject
25
+ };
26
+ }
7
27
  const always = {
8
28
  getSnapshot: () => AVAILABLE,
9
29
  subscribe: () => ({ unsubscribe() {} })
10
30
  };
11
31
  /** Open a session for a target. */
12
32
  function createSession(app, options) {
13
- const view = viewOf(catalogOf(app, { toJsonSchema: options.toJsonSchema }), options.target);
14
33
  const lifetime = new AbortController();
15
34
  const running = /* @__PURE__ */ new Set();
16
35
  const listeners = /* @__PURE__ */ new Set();
17
- const subscriptions = [];
36
+ let subscriptions = [];
18
37
  const onDefect = options.onDefect ?? ((id, cause) => console.error(`A capability failed with a defect: ${id}`, cause));
19
38
  const served = /* @__PURE__ */ new Map();
20
39
  for (const capability of app.capabilities) {
21
- if (!(capability.id in view.capabilities)) continue;
40
+ if (!capability.audience.includes(options.target)) continue;
41
+ if (served.has(capability.id)) throw new Error(`The capability ${capability.id} is declared two times.`);
22
42
  const availability = capability.availability?.(options.context) ?? always;
23
- subscriptions.push(availability.subscribe(() => listeners.forEach((listener) => listener())));
24
43
  served.set(capability.id, {
25
44
  capability,
26
45
  availability
27
46
  });
28
47
  }
29
- /** Run a call. The result is a result or a call failure. */
30
- async function call(id, input, trusted, signal) {
48
+ /**
49
+ * Run a call of a capability that the session serves: availability, then the decoded input, then the work, with
50
+ * cancellation. A defect is the call failure `internal`.
51
+ */
52
+ async function perform(id, entry, prepare, signal) {
31
53
  if (lifetime.signal.aborted || signal?.aborted) return callFailure("cancelled", "The call was cancelled.");
32
- const entry = served.get(id);
33
- if (entry === void 0) return callFailure("unknown-capability", `There is no capability ${id}.`);
34
54
  const availability = entry.availability.getSnapshot();
35
55
  if (!availability.available) return callFailure("unavailable", availability.reason);
36
- const { capability } = entry;
37
- let decoded = input ?? {};
38
- if (!trusted && capability.input !== void 0) {
39
- const validated = await capability.input["~standard"].validate(input);
40
- if (validated.issues !== void 0) {
41
- const message = validated.issues.map((issue) => `${(issue.path ?? []).map((key) => typeof key === "object" ? String(key.key) : String(key)).join(".") || "input"}: ${issue.message}`).join("; ");
42
- return callFailure("invalid-input", message);
43
- }
44
- decoded = validated.value;
45
- }
56
+ const prepared = await prepare();
57
+ if ("issues" in prepared) return callFailure("invalid-input", issueMessage(prepared.issues));
46
58
  const controller = new AbortController();
47
59
  const abort = () => controller.abort();
48
60
  lifetime.signal.addEventListener("abort", abort);
49
61
  signal?.addEventListener("abort", abort);
50
62
  const cancelled = new Promise((resolve) => controller.signal.addEventListener("abort", () => resolve(callFailure("cancelled", "The call was cancelled."))));
51
63
  if (lifetime.signal.aborted || signal?.aborted) controller.abort();
52
- const work = Promise.resolve().then(() => capability.run(decoded, {
64
+ const work = Promise.resolve().then(() => prepared.start({
53
65
  signal: controller.signal,
54
66
  context: options.context
55
- })).then((result) => checkResult(capability, result)).catch((cause) => {
67
+ })).catch((cause) => {
56
68
  onDefect(id, cause);
57
69
  return callFailure("internal", "The capability failed.");
58
70
  });
@@ -66,41 +78,113 @@ function createSession(app, options) {
66
78
  signal?.removeEventListener("abort", abort);
67
79
  }
68
80
  }
81
+ /** A call with an input that is not trusted. The outcome is a result or a call failure. */
82
+ async function invoke(id, input, signal) {
83
+ if (lifetime.signal.aborted || signal?.aborted) return callFailure("cancelled", "The call was cancelled.");
84
+ const entry = served.get(id);
85
+ if (entry === void 0) return callFailure("unknown-capability", `There is no capability ${id}.`);
86
+ const { capability } = entry;
87
+ return perform(id, entry, async () => {
88
+ const decoded = await capability.decode(input);
89
+ if ("issues" in decoded) return decoded;
90
+ return { start: async (call) => checkResult(capability, await decoded.start(call)) };
91
+ }, signal);
92
+ }
93
+ /** Reject a call failure, as a handle does. */
94
+ const settle = async (outcome) => {
95
+ const settled = await outcome;
96
+ if (isCallFailure(settled)) throw new CallFailureError(settled);
97
+ return settled;
98
+ };
99
+ /** The typed handle of a capability that the session serves, or the handle outside the view. */
100
+ function handle(capability) {
101
+ const entry = served.get(capability.id);
102
+ if (entry === void 0 || entry.capability !== capability) return outsideView(capability);
103
+ const state = capability.state?.source(options.context);
104
+ const run = (input, call) => capability.input === void 0 ? capability.run({}, call) : capability.run(input, call);
105
+ return {
106
+ id: capability.id,
107
+ description: capability.description,
108
+ audience: [options.target],
109
+ availability: entry.availability,
110
+ ...state === void 0 ? {} : { snapshot: detached(state) },
111
+ execute: async (input, callOptions) => {
112
+ const outcome = await perform(capability.id, entry, () => Promise.resolve({ start: async (call) => checkTyped(capability, await run(input, call)) }), callOptions?.signal);
113
+ if (isCallFailure(outcome)) throw new CallFailureError(outcome);
114
+ return outcome;
115
+ },
116
+ invokeExternal: (input, callOptions) => settle(invoke(capability.id, input, callOptions?.signal))
117
+ };
118
+ }
119
+ /** A typed call with an input that is not trusted: the input schema of the capability decodes it. */
120
+ async function call(capability, input, signal) {
121
+ const entry = served.get(capability.id);
122
+ if (entry === void 0 || entry.capability !== capability) throw new CallFailureError(callFailure("unknown-capability", `There is no capability ${capability.id}.`));
123
+ const prepare = async () => {
124
+ if (capability.input === void 0) return { start: async (each) => checkTyped(capability, await capability.run({}, each)) };
125
+ const validated = await capability.input["~standard"].validate(input);
126
+ if (validated.issues !== void 0) return { issues: validated.issues };
127
+ const { value } = validated;
128
+ return { start: async (each) => checkTyped(capability, await capability.run(value, each)) };
129
+ };
130
+ const outcome = await perform(capability.id, entry, prepare, signal);
131
+ if (isCallFailure(outcome)) throw new CallFailureError(outcome);
132
+ return outcome;
133
+ }
69
134
  const handles = /* @__PURE__ */ new Map();
70
135
  for (const [id, { capability, availability }] of served) {
71
- const reject = async (outcome) => {
72
- const settled = await outcome;
73
- if (isCallFailure(settled)) throw new CallFailureError(settled);
74
- return settled;
75
- };
136
+ const invokeExternal = (input, callOptions) => settle(invoke(id, input, callOptions?.signal));
76
137
  const state = capability.state?.source(options.context);
77
138
  handles.set(id, {
78
139
  id,
79
140
  description: capability.description,
80
- audience: capability.audience,
141
+ audience: [options.target],
81
142
  availability,
82
143
  ...state === void 0 ? {} : { snapshot: detached(state) },
83
- execute: (input, callOptions) => reject(call(id, input, true, callOptions?.signal)),
84
- invokeExternal: (input, callOptions) => reject(call(id, input, false, callOptions?.signal))
144
+ execute: invokeExternal,
145
+ invokeExternal
85
146
  });
86
147
  }
148
+ const unsubscribeAll = () => {
149
+ subscriptions.forEach((subscription) => subscription.unsubscribe());
150
+ subscriptions = [];
151
+ };
152
+ let view;
153
+ async function dispose() {
154
+ lifetime.abort();
155
+ listeners.clear();
156
+ unsubscribeAll();
157
+ await Promise.allSettled(running);
158
+ }
87
159
  return {
88
160
  target: options.target,
89
- view,
161
+ get view() {
162
+ return view ??= viewOf(catalogOf(app, { toJsonSchema: options.toJsonSchema }), options.target);
163
+ },
90
164
  handles,
165
+ handle,
166
+ call: (capability, input, callOptions) => call(capability, input, callOptions?.signal),
91
167
  signal: lifetime.signal,
92
- invoke: (id, input, callOptions) => call(id, input, false, callOptions?.signal),
168
+ invoke: (id, input, callOptions) => invoke(id, input, callOptions?.signal),
93
169
  onChange(listener) {
170
+ if (lifetime.signal.aborted) return { unsubscribe() {} };
171
+ if (listeners.size === 0) {
172
+ const announce = () => listeners.forEach((each) => each());
173
+ subscriptions = [...served.values()].map(({ availability }) => availability.subscribe(announce));
174
+ }
94
175
  listeners.add(listener);
95
- return { unsubscribe: () => listeners.delete(listener) };
176
+ return { unsubscribe() {
177
+ if (listeners.delete(listener) && listeners.size === 0) unsubscribeAll();
178
+ } };
96
179
  },
97
- async dispose() {
98
- lifetime.abort();
99
- subscriptions.forEach((subscription) => subscription.unsubscribe());
100
- await Promise.allSettled([...running]);
101
- }
180
+ dispose,
181
+ [Symbol.asyncDispose]: dispose
102
182
  };
103
183
  }
184
+ /** The message of the call failure `invalid-input`: each issue with its path. */
185
+ function issueMessage(issues) {
186
+ return issues.map((issue) => `${(issue.path ?? []).map((key) => typeof key === "object" ? String(key.key) : String(key)).join(".") || "input"}: ${issue.message}`).join("; ");
187
+ }
104
188
  /** An observable whose values are copies, so that a client cannot change the data of the provider. */
105
189
  function detached(source) {
106
190
  return {
@@ -122,18 +206,50 @@ async function checkResult(capability, result) {
122
206
  value: checked.value
123
207
  };
124
208
  }
125
- const tag = result.error._tag;
126
- const schema = tag === void 0 ? void 0 : capability.failures?.[tag];
127
- if (schema === void 0) throw new Error(`The failure ${String(tag)} is not in failures.`);
128
- const checked = await schema["~standard"].validate(result.error);
209
+ const { error } = result;
210
+ const tag = typeof error === "object" && error !== null && "_tag" in error ? error._tag : void 0;
211
+ const schema = typeof tag === "string" ? capability.failures?.[tag] : void 0;
212
+ if (typeof tag !== "string" || schema === void 0) throw new Error(`The failure ${String(tag)} is not in failures.`);
213
+ const checked = await schema["~standard"].validate(error);
129
214
  if (checked.issues !== void 0) throw new Error(`The failure ${tag} does not agree with its schema: ${checked.issues[0]?.message}`);
130
215
  return {
131
216
  ok: false,
132
217
  error: {
133
218
  _tag: tag,
134
- ...checked.value
219
+ ...typeof checked.value === "object" && checked.value !== null ? checked.value : {}
135
220
  }
136
221
  };
137
222
  }
223
+ /**
224
+ * Check a result of a typed handle, as `checkResult` does. The values come from the output schema and from the failure
225
+ * schema, so they have the types of the capability.
226
+ */
227
+ async function checkTyped(capability, result) {
228
+ if (result.ok) {
229
+ if (capability.output === void 0) return result;
230
+ const checked = await capability.output["~standard"].validate(result.value);
231
+ if (checked.issues !== void 0) throw new Error(`The output does not agree with its schema: ${checked.issues[0]?.message}`);
232
+ return {
233
+ ok: true,
234
+ value: checked.value
235
+ };
236
+ }
237
+ if (capability.failures === void 0) throw new Error(`The failure ${String(result.error._tag)} is not in failures.`);
238
+ return {
239
+ ok: false,
240
+ error: await checkFailure(result.error, result.error._tag, capability.failures)
241
+ };
242
+ }
243
+ /** Check one failure against the schema of its tag. The result has the type of the failure with that tag. */
244
+ async function checkFailure(failure, tag, schemas) {
245
+ const schema = schemas[tag];
246
+ if (schema === void 0) throw new Error(`The failure ${String(tag)} is not in failures.`);
247
+ const checked = await schema["~standard"].validate(failure);
248
+ if (checked.issues !== void 0) throw new Error(`The failure ${String(tag)} does not agree with its schema: ${checked.issues[0]?.message}`);
249
+ return {
250
+ _tag: tag,
251
+ ...checked.value
252
+ };
253
+ }
138
254
  //#endregion
139
255
  export { createSession };
package/dist/webmcp.js CHANGED
@@ -6,12 +6,21 @@ import { CallFailureError, bundle, isCallFailure } from "@acid-cat/spec-ts";
6
6
  /** The WebMCP host of the page, if there is one (spec/bindings/webmcp.md#host). */
7
7
  function findHost() {
8
8
  const scope = globalThis;
9
- for (const candidate of [scope.document?.modelContext, scope.navigator?.modelContext]) if (typeof candidate?.registerTool === "function") return candidate;
9
+ const holders = ["document" in scope ? scope.document : void 0, "navigator" in scope ? scope.navigator : void 0];
10
+ for (const holder of holders) {
11
+ const candidate = typeof holder === "object" && holder !== null && "modelContext" in holder ? holder.modelContext : void 0;
12
+ if (isModelContext(candidate)) return candidate;
13
+ }
14
+ }
15
+ /** Tell if a value has a `registerTool` function. The result of `registerTool` is not known, as `ModelContext` says. */
16
+ function isModelContext(value) {
17
+ return typeof value === "object" && value !== null && "registerTool" in value && typeof value.registerTool === "function";
10
18
  }
11
19
  /** Register the capabilities of a session as tools. If there is no host, do nothing and return `undefined`. */
12
20
  function installWebMcp(session, options = {}) {
13
- const host = options.host ?? findHost();
14
- if (host === void 0) return void 0;
21
+ const found = options.host ?? findHost();
22
+ if (found === void 0) return void 0;
23
+ const host = found;
15
24
  let status = {
16
25
  phase: "loading",
17
26
  message: null
@@ -29,6 +38,7 @@ function installWebMcp(session, options = {}) {
29
38
  let dirty = false;
30
39
  const toolFor = (id) => {
31
40
  const capability = session.view.capabilities[id];
41
+ if (capability === void 0) throw new Error(`The view of the session has no capability ${id}.`);
32
42
  return {
33
43
  name: id,
34
44
  description: capability.description,
@@ -118,7 +128,7 @@ function installWebMcp(session, options = {}) {
118
128
  subscription.unsubscribe();
119
129
  lifetime.abort();
120
130
  registered.clear();
121
- await Promise.allSettled([...calls]);
131
+ await Promise.allSettled(calls);
122
132
  setStatus({
123
133
  phase: "disposed",
124
134
  message: null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acid-cat/core-ts",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "Serve AcidCat capabilities in plain TypeScript, with no Effect dependency.",
5
5
  "keywords": [
6
6
  "acidcat",
@@ -32,11 +32,11 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@acid-cat/catalog": "0.3.1",
36
- "@acid-cat/spec-ts": "0.3.1"
35
+ "@acid-cat/catalog": "0.5.0",
36
+ "@acid-cat/spec-ts": "0.5.0"
37
37
  },
38
38
  "devDependencies": {
39
- "@acid-cat/cli": "0.3.1",
39
+ "@acid-cat/cli": "0.5.0",
40
40
  "@types/node": "26.6.5",
41
41
  "esbuild": "0.28.2",
42
42
  "playwright": "1.64.0",