@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 +52 -14
- package/dist/app.d.ts +99 -33
- package/dist/app.js +44 -7
- package/dist/cli.d.ts +20 -0
- package/dist/cli.js +43 -15
- package/dist/context.js +6 -3
- package/dist/http.js +2 -2
- package/dist/index.d.ts +3 -3
- package/dist/mcp.js +22 -5
- package/dist/session.d.ts +39 -10
- package/dist/session.js +157 -41
- package/dist/webmcp.js +14 -4
- package/package.json +4 -4
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(
|
|
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
|
-
|
|
66
|
+
When the signal of a request aborts, `mcpHandler` and `httpHandler` cancel the call of the request.
|
|
64
67
|
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
90
|
-
-
|
|
91
|
-
- [`
|
|
92
|
-
- `
|
|
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
|
-
|
|
11
|
-
type
|
|
12
|
-
|
|
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
|
-
} &
|
|
15
|
-
}[keyof
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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?:
|
|
30
|
-
/** The failures, by 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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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>() => <
|
|
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
|
-
/**
|
|
78
|
-
|
|
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<
|
|
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
|
-
|
|
8
|
-
|
|
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 = () =>
|
|
36
|
+
const capabilitiesFor = () => defineCapability;
|
|
18
37
|
/** Declare an application. */
|
|
19
38
|
const defineApp = (app) => app;
|
|
20
|
-
|
|
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 } =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
107
|
-
|
|
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 ??
|
|
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
|
-
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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<
|
|
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:
|
|
16
|
+
execute(input: Input, options?: {
|
|
12
17
|
readonly signal?: AbortSignal | undefined;
|
|
13
|
-
}): Promise<Result<
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
/**
|
|
30
|
-
|
|
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
|
|
37
|
-
|
|
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(() =>
|
|
64
|
+
const work = Promise.resolve().then(() => prepared.start({
|
|
53
65
|
signal: controller.signal,
|
|
54
66
|
context: options.context
|
|
55
|
-
})).
|
|
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
|
|
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:
|
|
141
|
+
audience: [options.target],
|
|
81
142
|
availability,
|
|
82
143
|
...state === void 0 ? {} : { snapshot: detached(state) },
|
|
83
|
-
execute:
|
|
84
|
-
invokeExternal
|
|
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) =>
|
|
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
|
|
176
|
+
return { unsubscribe() {
|
|
177
|
+
if (listeners.delete(listener) && listeners.size === 0) unsubscribeAll();
|
|
178
|
+
} };
|
|
96
179
|
},
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
|
14
|
-
if (
|
|
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(
|
|
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
|
+
"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.
|
|
36
|
-
"@acid-cat/spec-ts": "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.
|
|
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",
|