@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 +24 -21
- package/dist/app.d.ts +95 -38
- package/dist/app.js +24 -5
- package/dist/cli.js +22 -13
- package/dist/context.js +6 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/mcp.js +22 -5
- package/dist/session.d.ts +25 -14
- package/dist/session.js +119 -43
- package/dist/webmcp.js +14 -4
- package/package.json +4 -4
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(
|
|
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
|
-
`
|
|
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
|
-
|
|
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.
|
|
72
74
|
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
125
|
-
-
|
|
126
|
-
- [`
|
|
127
|
-
- `
|
|
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
|
|
11
|
-
export type
|
|
12
|
-
/** The
|
|
13
|
-
export type
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
} &
|
|
19
|
-
}[keyof
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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?:
|
|
37
|
-
/** The failures, by 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:
|
|
42
|
-
readonly source: (context: Context) => Observable<
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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>() => <
|
|
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<
|
|
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<
|
|
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
|
-
|
|
9
|
-
|
|
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 = () =>
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
123
|
-
|
|
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 ??
|
|
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
|
-
|
|
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
|
|
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/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
|
|
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
|
|
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,
|
|
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.
|
|
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,11 +1,11 @@
|
|
|
1
1
|
import type { Availability, CatalogDocument, Outcome, Result } from "@acid-cat/spec-ts";
|
|
2
|
-
import type {
|
|
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. `
|
|
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 =
|
|
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<
|
|
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
|
-
/**
|
|
55
|
-
|
|
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
|
|
62
|
-
|
|
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(() =>
|
|
64
|
+
const work = Promise.resolve().then(() => prepared.start({
|
|
78
65
|
signal: controller.signal,
|
|
79
66
|
context: options.context
|
|
80
|
-
})).
|
|
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
|
|
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:
|
|
109
|
-
invokeExternal
|
|
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) =>
|
|
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
|
-
|
|
137
|
-
|
|
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
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
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",
|