@acid-cat/core-ts 0.4.0 → 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,18 +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
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.
41
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.
42
44
 
43
45
  ## Write the catalog
44
46
 
@@ -63,28 +65,29 @@ Standard Schema does not convert to JSON Schema. Give the converter of your sche
63
65
 
64
66
  When the signal of a request aborts, `mcpHandler` and `httpHandler` cancel the call of the request.
65
67
 
66
- ### Typed handles
68
+ ### Typed calls and handles
67
69
 
68
- `getHandle(session.handles, capability)` gives the handle of a capability with the types of its schemas. For a capability outside the view, the handle is always unavailable, and each call rejects as `unknown-capability`.
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:
69
71
 
70
- ```ts
71
- import { createSession, getHandle } from "@acid-cat/core-ts";
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.
72
74
 
73
- const session = createSession(aircon, { target: "app", context: { unit }, toJsonSchema });
74
- const power = getHandle(session.handles, powerSet); // HandleOf<typeof powerSet>
75
- const result = await power.execute({ on: true }); // Result<null, { _tag: "Offline"; since?: string }>
76
- ```
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.
77
76
 
78
- ### Context for each request
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 }>) => …`.
79
78
 
80
- `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:
79
+ A session is async-disposable. `await using` closes it at the end of the block, as `dispose()` does:
81
80
 
82
81
  ```ts
83
- const handler = httpHandler(aircon, {
84
- allowedOrigins: [],
85
- toJsonSchema,
86
- createContext: () => ({ unit: connectUnit(env), [Symbol.asyncDispose]: () => disconnectUnit() }),
87
- });
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 });
88
91
  ```
89
92
 
90
93
  ### Context for each request
@@ -121,7 +124,7 @@ cliMain(aircon, { name: "aircon", version: "0.1.0", context: { unit }, toJsonSch
121
124
 
122
125
  ## Examples and tests
123
126
 
124
- - [`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.
125
- - [`examples/aircon-cli.ts`](examples/aircon-cli.ts) is its command line: `node examples/aircon-cli.ts aircon power set --on`.
126
- - [`examples/apis`](../../examples/apis/README.md) builds GraphQL, Cap'n Web and Elysia APIs on it, each beside the HTTP binding.
127
- - `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,52 +7,111 @@ export interface Observable<T> {
7
7
  unsubscribe(): void;
8
8
  };
9
9
  }
10
- /** The input that `run` receives: the output of the input schema, or an empty object. */
11
- export type InputOf<S> = S extends StandardSchemaV1 ? StandardSchemaV1.InferOutput<S> : Record<string, never>;
12
- /** The value of a successful call: the output of the output schema, or `null`. */
13
- export type Output<S> = S extends StandardSchemaV1 ? StandardSchemaV1.InferOutput<S> : null;
14
- /** The failures, each with its `_tag`. */
15
- export type FailureOf<F> = {
16
- [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]: {
17
19
  readonly _tag: Tag;
18
- } & (F[Tag] extends StandardSchemaV1 ? StandardSchemaV1.InferOutput<F[Tag]> : object);
19
- }[keyof F];
20
- export interface Capability<Context = unknown, Input extends StandardSchemaV1 | undefined = StandardSchemaV1 | undefined, OutputSchema extends StandardSchemaV1 | undefined = StandardSchemaV1 | undefined, Failures extends {
21
- readonly [tag: string]: StandardSchemaV1;
22
- } = {
23
- readonly [tag: string]: StandardSchemaV1;
24
- }, StateSchema extends StandardSchemaV1 = StandardSchemaV1, Id extends string = 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> {
25
35
  readonly id: Id;
26
36
  readonly description: string;
27
37
  /** The targets that can use the capability. With no audience, no target can use it. */
28
38
  readonly audience: readonly string[];
29
39
  readonly readOnly?: boolean | undefined;
30
- /**
31
- * The input: an object schema, or a union of object schemas. The catalog writes a union with `"type": "object"`. With
32
- * no input schema, the input is an empty object.
33
- */
34
- readonly input?: Input;
35
40
  /** The value of a successful call. With no output schema, the value is `null`. */
36
- readonly output?: OutputSchema;
37
- /** The failures, by tag. The schema of a failure describes the object, without or with its `_tag`. */
38
- readonly failures?: Failures;
41
+ readonly output?: StandardSchemaV1<unknown, Value> | undefined;
42
+ /** The failures, by tag. */
43
+ readonly failures?: FailureSchemas<Failures> | undefined;
39
44
  /** A value that clients can observe (spec/invocation.md#state): its schema, and its source. */
40
45
  readonly state?: {
41
- readonly schema: StateSchema;
42
- readonly source: (context: Context) => Observable<StandardSchemaV1.InferOutput<StateSchema>>;
46
+ readonly schema: StandardSchemaV1<unknown, State>;
47
+ readonly source: (context: Context) => Observable<NoInfer<State>>;
43
48
  } | undefined;
44
49
  /** The availability of the capability. With none, the capability is always available. */
45
50
  readonly availability?: ((context: Context) => Observable<Availability>) | undefined;
46
- readonly run: (input: InputOf<Input>, call: {
47
- readonly signal: AbortSignal;
48
- readonly context: Context;
49
- }) => Promise<Result<Output<OutputSchema>, FailureOf<Failures>>> | Result<Output<OutputSchema>, FailureOf<Failures>>;
50
51
  }
51
- export type AnyCapability<Context = any> = Capability<Context, any, any, any, any>;
52
- /** Declare a capability. `Context` is the type of the services that `run` receives. The id keeps its literal type. */
53
- export declare const defineCapability: <Context = unknown, Input extends StandardSchemaV1 | undefined = undefined, OutputSchema extends StandardSchemaV1 | undefined = undefined, Failures extends {
54
- readonly [tag: string]: StandardSchemaV1;
55
- } = {}, StateSchema extends StandardSchemaV1 = never, const Id extends string = string>(capability: Capability<Context, Input, OutputSchema, Failures, StateSchema, Id>) => Capability<Context, Input, OutputSchema, Failures, StateSchema, Id>;
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;
96
+ readonly state?: {
97
+ readonly schema: StandardSchemaV1;
98
+ readonly source: (context: Context) => Observable<unknown>;
99
+ } | undefined;
100
+ readonly availability?: ((context: Context) => Observable<Availability>) | undefined;
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>;
114
+ }
56
115
  /**
57
116
  * Declare capabilities for one context type. The other types come from the schemas:
58
117
  *
@@ -61,9 +120,7 @@ export declare const defineCapability: <Context = unknown, Input extends Standar
61
120
  * capability({ id: "aircon.power.set", input: z.object({ on: z.boolean() }), run: ({ on }, { context }) => … });
62
121
  * ```
63
122
  */
64
- export declare const capabilitiesFor: <Context>() => <Input extends StandardSchemaV1 | undefined = undefined, OutputSchema extends StandardSchemaV1 | undefined = undefined, Failures extends {
65
- readonly [tag: string]: StandardSchemaV1;
66
- } = {}, StateSchema extends StandardSchemaV1 = never, const Id extends string = string>(capability: Capability<Context, Input, OutputSchema, Failures, StateSchema, Id>) => Capability<Context, Input, OutputSchema, Failures, StateSchema, Id>;
123
+ export declare const capabilitiesFor: <Context>() => CapabilityFactory<Context>;
67
124
  export interface Target {
68
125
  readonly bindings: readonly string[];
69
126
  readonly description?: string | undefined;
@@ -85,10 +142,10 @@ export type ToJsonSchema = (schema: StandardSchemaV1) => JsonObject;
85
142
  * The catalog of an application, as a JSON document. The catalog is written one time for each application and
86
143
  * `toJsonSchema`. Later calls with the same two give the same document.
87
144
  */
88
- export declare function catalogOf(app: App<any>, options: {
145
+ export declare function catalogOf(app: App<never>, options: {
89
146
  readonly toJsonSchema: ToJsonSchema;
90
147
  }): CatalogDocument;
91
148
  /** The catalog of an application, as text in canonical form. */
92
- export declare const renderCatalog: (app: App<any>, options: {
149
+ export declare const renderCatalog: (app: App<never>, options: {
93
150
  readonly toJsonSchema: ToJsonSchema;
94
151
  }) => string;
package/dist/app.js CHANGED
@@ -1,12 +1,30 @@
1
1
  import { canonicalText } from "@acid-cat/catalog";
2
- import { objectInput } from "@acid-cat/spec-ts";
2
+ import { isJsonObject, objectInput } from "@acid-cat/spec-ts";
3
3
  //#region src/app.ts
4
4
  /**
5
5
  * Capabilities and applications in plain TypeScript. An application writes its own catalog.
6
6
  * See spec/catalog.md.
7
7
  */
8
- /** Declare a capability. `Context` is the type of the services that `run` receives. The id keeps its literal type. */
9
- 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
+ }
10
28
  /**
11
29
  * Declare capabilities for one context type. The other types come from the schemas:
12
30
  *
@@ -15,7 +33,7 @@ const defineCapability = (capability) => capability;
15
33
  * capability({ id: "aircon.power.set", input: z.object({ on: z.boolean() }), run: ({ on }, { context }) => … });
16
34
  * ```
17
35
  */
18
- const capabilitiesFor = () => (capability) => capability;
36
+ const capabilitiesFor = () => defineCapability;
19
37
  /** Declare an application. */
20
38
  const defineApp = (app) => app;
21
39
  const catalogs = /* @__PURE__ */ new WeakMap();
@@ -40,7 +58,8 @@ function write(app, toJsonSchema) {
40
58
  const types = {};
41
59
  const toJson = (schema, where) => {
42
60
  const { $schema: _, $defs, ...json } = toJsonSchema(schema);
43
- for (const [name, definition] of Object.entries($defs ?? {})) {
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.`);
44
63
  const existing = types[name];
45
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.`);
46
65
  types[name] = definition;
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.
@@ -40,7 +40,7 @@ async function runCli(app, options, args, io) {
40
40
  const taken = /* @__PURE__ */ new Set([
41
41
  "call",
42
42
  "list",
43
- ...commands.map(({ path }) => path[0])
43
+ ...commands.flatMap(({ path }) => path.slice(0, 1))
44
44
  ]);
45
45
  for (const command of added) {
46
46
  if (taken.has(command.name)) throw new Error(`The command ${command.name} of the application has the same name as another command.`);
@@ -67,7 +67,7 @@ async function runCli(app, options, args, io) {
67
67
  }
68
68
  if (first === "list") {
69
69
  io.stdout(JSON.stringify([...session.handles.values()].map((handle) => {
70
- const input = session.view.capabilities[handle.id].input;
70
+ const input = viewCapability(session.view, handle.id).input;
71
71
  return {
72
72
  id: handle.id,
73
73
  description: handle.description,
@@ -98,7 +98,7 @@ async function runCli(app, options, args, io) {
98
98
  });
99
99
  const match = commands.filter(({ path }) => path.every((word, index) => args[index] === word)).sort((a, b) => b.path.length - a.path.length)[0];
100
100
  if (match === void 0) throw new UsageError(`There is no command ${args.filter((arg) => !arg.startsWith("-")).join(" ")}.`, CLI_EXIT_CODES["unknown-capability"]);
101
- const capability = session.view.capabilities[match.id];
101
+ const capability = viewCapability(session.view, match.id);
102
102
  const schema = bundle(capability.input ?? { type: "object" }, types);
103
103
  const flagArgs = args.slice(match.path.length);
104
104
  if (flagArgs.includes("--help") || flagArgs.includes("-h")) {
@@ -116,25 +116,26 @@ async function runCli(app, options, args, io) {
116
116
  }
117
117
  /** Make the input from the flags (spec/bindings/cli.md#commands). */
118
118
  function parseFlags(args, schema) {
119
- const properties = schema.properties ?? {};
120
- const definitions = schema.$defs ?? {};
119
+ const properties = objectOrEmpty(schema.properties);
120
+ const definitions = objectOrEmpty(schema.$defs);
121
121
  const input = {};
122
- for (let index = 0; index < args.length; index += 1) {
123
- const arg = args[index];
122
+ const remaining = args.values();
123
+ for (const arg of remaining) {
124
124
  if (!arg.startsWith("--")) throw new UsageError(`Unexpected argument ${arg}.`, CLI_EXIT_CODES["invalid-input"]);
125
- const [name, inline] = arg.slice(2).split(/=(.*)/s, 2);
125
+ const [name = "", inline] = arg.slice(2).split(/=(.*)/s, 2);
126
126
  const property = properties[name];
127
127
  if (property === void 0) throw new UsageError(`Unknown flag --${name}.`, CLI_EXIT_CODES["invalid-input"]);
128
- const type = resolve(property, definitions);
128
+ const type = resolve(objectOrEmpty(property), definitions);
129
129
  if (type.type === "boolean") {
130
130
  input[name] = inline === void 0 ? true : inline === "true";
131
131
  continue;
132
132
  }
133
- const text = inline ?? args[index += 1];
133
+ const text = inline ?? remaining.next().value;
134
134
  if (text === void 0) throw new UsageError(`The flag --${name} needs a value.`, CLI_EXIT_CODES["invalid-input"]);
135
135
  input[name] = valueOf(name, text, type);
136
136
  }
137
- 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"]);
138
139
  return input;
139
140
  }
140
141
  function valueOf(name, text, type) {
@@ -156,7 +157,15 @@ function valueOf(name, text, type) {
156
157
  }
157
158
  function resolve(property, definitions) {
158
159
  const name = typeof property.$ref === "string" ? REFERENCE.exec(property.$ref)?.[1] : void 0;
159
- 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;
160
169
  }
161
170
  function help(name, description, paths, added) {
162
171
  return [
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/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { capabilitiesFor, catalogOf, defineApp, defineCapability, renderCatalog } from "./app.js";
2
- export type { AnyCapability, App, Capability, Observable, Target, ToJsonSchema } from "./app.js";
3
- export { createSession, getHandle } from "./session.js";
2
+ export type { AnyCapability, App, Call, Capability, CapabilityDeclaration, CapabilityFactory, EmptyInput, FailureOf, Observable, Target, ToJsonSchema } from "./app.js";
3
+ export { createSession } from "./session.js";
4
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";
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { capabilitiesFor, catalogOf, defineApp, defineCapability, renderCatalog } from "./app.js";
2
- import { createSession, getHandle } from "./session.js";
2
+ import { createSession } from "./session.js";
3
3
  import { mcpHandler } from "./mcp.js";
4
4
  import { httpHandler } from "./http.js";
5
5
  import { main, runCli } from "./cli.js";
6
6
  import { findHost, installWebMcp } from "./webmcp.js";
7
7
  import { AVAILABLE, CallFailureError, fail, ok, unavailable } from "@acid-cat/spec-ts";
8
- export { AVAILABLE, CallFailureError, capabilitiesFor, catalogOf, main as cliMain, createSession, defineApp, defineCapability, fail, findHost, getHandle, httpHandler, installWebMcp, mcpHandler, ok, renderCatalog, runCli, unavailable };
8
+ export { AVAILABLE, CallFailureError, capabilitiesFor, catalogOf, main as cliMain, createSession, defineApp, defineCapability, fail, findHost, httpHandler, installWebMcp, mcpHandler, ok, renderCatalog, runCli, unavailable };
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,11 +1,11 @@
1
1
  import type { Availability, CatalogDocument, Outcome, Result } from "@acid-cat/spec-ts";
2
- import type { AnyCapability, App, Capability, FailureOf, InputOf, Observable, Output, ToJsonSchema } from "./app.js";
3
- import type { StandardSchemaV1 } from "./standard-schema.js";
2
+ import type { App, Capability, FailureOf, Observable, ToJsonSchema } from "./app.js";
4
3
  /**
5
4
  * The handle of a capability (spec/bindings/typescript.md#handles). `Input`, `Value`, `Failure` and `State` are the
6
- * types of the schemas of the capability. `getHandle` gives a handle with these types.
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
7
  */
8
- export interface Handle<Input = unknown, Value = unknown, Failure = unknown, State = unknown> {
8
+ export interface Handle<Input = never, Value = unknown, Failure = unknown, State = unknown> {
9
9
  readonly id: string;
10
10
  readonly description: string;
11
11
  readonly audience: readonly string[];
@@ -21,22 +21,31 @@ export interface Handle<Input = unknown, Value = unknown, Failure = unknown, Sta
21
21
  readonly signal?: AbortSignal | undefined;
22
22
  }): Promise<Result<unknown, unknown>>;
23
23
  }
24
- /** The handle of a capability, with the types of its schemas. */
25
- export type HandleOf<C> = C extends Capability<any, infer Input, infer OutputSchema, infer Failures, infer StateSchema> ? Handle<InputOf<Input>, Output<OutputSchema>, FailureOf<Failures>, StandardSchemaV1.InferOutput<StateSchema>> : never;
26
- /**
27
- * The handle of a capability in `handles`, with the types of the capability. For a capability outside the view, the
28
- * handle is always unavailable, and each call rejects as `unknown-capability`.
29
- */
30
- export declare function getHandle<C extends AnyCapability>(handles: ReadonlyMap<string, Handle>, capability: C): HandleOf<C>;
31
- 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> {
32
27
  readonly target: string;
33
28
  /**
34
29
  * The view of the target. Bindings take names, descriptions and schemas from it. The session writes the view when it
35
30
  * is first read.
36
31
  */
37
32
  readonly view: CatalogDocument;
38
- /** The handles of the capabilities in the view, by id. */
33
+ /** The handles of the capabilities in the view, by id. Bindings use them. */
39
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>>>;
40
49
  /** A call with an input that is not trusted. The outcome is in its JSON form. The promise never rejects. */
41
50
  invoke(id: string, input: unknown, options?: {
42
51
  readonly signal?: AbortSignal | undefined;
@@ -52,6 +61,8 @@ export interface Session {
52
61
  readonly signal: AbortSignal;
53
62
  /** Close the session: accept no new calls, cancel the calls in progress, and wait for them. */
54
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>;
55
66
  }
56
67
  export interface SessionOptions<Context> {
57
68
  readonly target: string;
@@ -62,4 +73,4 @@ export interface SessionOptions<Context> {
62
73
  readonly onDefect?: ((id: string, cause: unknown) => void) | undefined;
63
74
  }
64
75
  /** Open a session for a target. */
65
- 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,13 +4,7 @@ import { AVAILABLE, CallFailureError, callFailure, isCallFailure, viewOf } from
4
4
  /**
5
5
  * Sessions and handles (spec/invocation.md, spec/bindings/typescript.md).
6
6
  */
7
- /**
8
- * The handle of a capability in `handles`, with the types of the capability. For a capability outside the view, the
9
- * handle is always unavailable, and each call rejects as `unknown-capability`.
10
- */
11
- function getHandle(handles, capability) {
12
- return handles.get(capability.id) ?? outsideView(capability);
13
- }
7
+ /** The handle of a capability that the session does not serve. */
14
8
  function outsideView(capability) {
15
9
  const failure = callFailure("unknown-capability", `There is no capability ${capability.id}.`);
16
10
  const unavailable = {
@@ -51,33 +45,26 @@ function createSession(app, options) {
51
45
  availability
52
46
  });
53
47
  }
54
- /** Run a call. The result is a result or a call failure. */
55
- 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) {
56
53
  if (lifetime.signal.aborted || signal?.aborted) return callFailure("cancelled", "The call was cancelled.");
57
- const entry = served.get(id);
58
- if (entry === void 0) return callFailure("unknown-capability", `There is no capability ${id}.`);
59
54
  const availability = entry.availability.getSnapshot();
60
55
  if (!availability.available) return callFailure("unavailable", availability.reason);
61
- const { capability } = entry;
62
- let decoded = input ?? {};
63
- if (!trusted && capability.input !== void 0) {
64
- const validated = await capability.input["~standard"].validate(input);
65
- if (validated.issues !== void 0) {
66
- const message = validated.issues.map((issue) => `${(issue.path ?? []).map((key) => typeof key === "object" ? String(key.key) : String(key)).join(".") || "input"}: ${issue.message}`).join("; ");
67
- return callFailure("invalid-input", message);
68
- }
69
- decoded = validated.value;
70
- }
56
+ const prepared = await prepare();
57
+ if ("issues" in prepared) return callFailure("invalid-input", issueMessage(prepared.issues));
71
58
  const controller = new AbortController();
72
59
  const abort = () => controller.abort();
73
60
  lifetime.signal.addEventListener("abort", abort);
74
61
  signal?.addEventListener("abort", abort);
75
62
  const cancelled = new Promise((resolve) => controller.signal.addEventListener("abort", () => resolve(callFailure("cancelled", "The call was cancelled."))));
76
63
  if (lifetime.signal.aborted || signal?.aborted) controller.abort();
77
- const work = Promise.resolve().then(() => capability.run(decoded, {
64
+ const work = Promise.resolve().then(() => prepared.start({
78
65
  signal: controller.signal,
79
66
  context: options.context
80
- })).then((result) => checkResult(capability, result)).catch((cause) => {
67
+ })).catch((cause) => {
81
68
  onDefect(id, cause);
82
69
  return callFailure("internal", "The capability failed.");
83
70
  });
@@ -91,13 +78,62 @@ function createSession(app, options) {
91
78
  signal?.removeEventListener("abort", abort);
92
79
  }
93
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
+ }
94
134
  const handles = /* @__PURE__ */ new Map();
95
135
  for (const [id, { capability, availability }] of served) {
96
- const reject = async (outcome) => {
97
- const settled = await outcome;
98
- if (isCallFailure(settled)) throw new CallFailureError(settled);
99
- return settled;
100
- };
136
+ const invokeExternal = (input, callOptions) => settle(invoke(id, input, callOptions?.signal));
101
137
  const state = capability.state?.source(options.context);
102
138
  handles.set(id, {
103
139
  id,
@@ -105,8 +141,8 @@ function createSession(app, options) {
105
141
  audience: [options.target],
106
142
  availability,
107
143
  ...state === void 0 ? {} : { snapshot: detached(state) },
108
- execute: (input, callOptions) => reject(call(id, input, true, callOptions?.signal)),
109
- invokeExternal: (input, callOptions) => reject(call(id, input, false, callOptions?.signal))
144
+ execute: invokeExternal,
145
+ invokeExternal
110
146
  });
111
147
  }
112
148
  const unsubscribeAll = () => {
@@ -114,14 +150,22 @@ function createSession(app, options) {
114
150
  subscriptions = [];
115
151
  };
116
152
  let view;
153
+ async function dispose() {
154
+ lifetime.abort();
155
+ listeners.clear();
156
+ unsubscribeAll();
157
+ await Promise.allSettled(running);
158
+ }
117
159
  return {
118
160
  target: options.target,
119
161
  get view() {
120
162
  return view ??= viewOf(catalogOf(app, { toJsonSchema: options.toJsonSchema }), options.target);
121
163
  },
122
164
  handles,
165
+ handle,
166
+ call: (capability, input, callOptions) => call(capability, input, callOptions?.signal),
123
167
  signal: lifetime.signal,
124
- invoke: (id, input, callOptions) => call(id, input, false, callOptions?.signal),
168
+ invoke: (id, input, callOptions) => invoke(id, input, callOptions?.signal),
125
169
  onChange(listener) {
126
170
  if (lifetime.signal.aborted) return { unsubscribe() {} };
127
171
  if (listeners.size === 0) {
@@ -133,14 +177,14 @@ function createSession(app, options) {
133
177
  if (listeners.delete(listener) && listeners.size === 0) unsubscribeAll();
134
178
  } };
135
179
  },
136
- async dispose() {
137
- lifetime.abort();
138
- listeners.clear();
139
- unsubscribeAll();
140
- await Promise.allSettled([...running]);
141
- }
180
+ dispose,
181
+ [Symbol.asyncDispose]: dispose
142
182
  };
143
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
+ }
144
188
  /** An observable whose values are copies, so that a client cannot change the data of the provider. */
145
189
  function detached(source) {
146
190
  return {
@@ -162,18 +206,50 @@ async function checkResult(capability, result) {
162
206
  value: checked.value
163
207
  };
164
208
  }
165
- const tag = result.error._tag;
166
- const schema = tag === void 0 ? void 0 : capability.failures?.[tag];
167
- if (schema === void 0) throw new Error(`The failure ${String(tag)} is not in failures.`);
168
- 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);
169
214
  if (checked.issues !== void 0) throw new Error(`The failure ${tag} does not agree with its schema: ${checked.issues[0]?.message}`);
170
215
  return {
171
216
  ok: false,
172
217
  error: {
173
218
  _tag: tag,
174
- ...checked.value
219
+ ...typeof checked.value === "object" && checked.value !== null ? checked.value : {}
175
220
  }
176
221
  };
177
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
+ }
178
254
  //#endregion
179
- export { createSession, getHandle };
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.4.0",
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.4.0",
36
- "@acid-cat/spec-ts": "0.4.0"
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.4.0",
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",