@acid-cat/core-ts 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +91 -2
- package/dist/app.d.ts +85 -0
- package/dist/app.js +62 -0
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +163 -0
- package/dist/context.d.ts +18 -0
- package/dist/context.js +20 -0
- package/dist/http.d.ts +19 -0
- package/dist/http.js +73 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +8 -0
- package/dist/mcp.d.ts +17 -0
- package/dist/mcp.js +155 -0
- package/dist/session.d.ts +47 -0
- package/dist/session.js +139 -0
- package/dist/standard-schema.d.ts +36 -0
- package/dist/webmcp.d.ts +33 -0
- package/dist/webmcp.js +130 -0
- package/package.json +43 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hebilicious
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,92 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @acid-cat/core-ts
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Serve AcidCat capabilities in plain TypeScript. npm package: `@acid-cat/core-ts`.
|
|
4
|
+
|
|
5
|
+
This package has no Effect dependency. A capability returns a promise. Inputs are validated with any library that implements [Standard Schema](https://standardschema.dev), for example Zod, Valibot or ArkType. The application writes its own catalog.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm add @acid-cat/core-ts
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Write capabilities
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { capabilitiesFor, defineApp, fail, ok } from "@acid-cat/core-ts";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
|
|
17
|
+
const Offline = z.object({ _tag: z.literal("Offline") });
|
|
18
|
+
const capability = capabilitiesFor<{ unit: Unit }>();
|
|
19
|
+
|
|
20
|
+
export const aircon = defineApp<{ unit: Unit }>({
|
|
21
|
+
title: "Living-room air conditioner",
|
|
22
|
+
targets: { cli: { bindings: ["cli"] }, mcp: { bindings: ["mcp"] } },
|
|
23
|
+
capabilities: [
|
|
24
|
+
capability({
|
|
25
|
+
id: "aircon.power.set",
|
|
26
|
+
description: "Turn the unit on or off.",
|
|
27
|
+
audience: ["cli", "mcp"],
|
|
28
|
+
input: z.object({ on: z.boolean() }),
|
|
29
|
+
failures: { Offline },
|
|
30
|
+
run: async ({ on }, { context, signal }) => ((await context.unit.setPower(on, signal)) ? ok(null) : fail("Offline")),
|
|
31
|
+
}),
|
|
32
|
+
],
|
|
33
|
+
});
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- `run` gets the input, an `AbortSignal` and the context. It returns `ok(value)` or `fail(tag, data)`. If it throws, the call fails as `internal`.
|
|
37
|
+
- A failure schema describes the whole failure object, with its `_tag`.
|
|
38
|
+
- `availability` is an optional function that returns an observable availability.
|
|
39
|
+
- `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
|
+
- `capabilitiesFor<Context>()` sets the context type one time. The other types come from the schemas.
|
|
41
|
+
|
|
42
|
+
## Write the catalog
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { renderCatalog } from "@acid-cat/core-ts";
|
|
46
|
+
|
|
47
|
+
const toJsonSchema = (schema) => z.toJSONSchema(schema, { target: "draft-2020-12" });
|
|
48
|
+
await writeFile("acidcat.catalog.json", renderCatalog(aircon, { toJsonSchema })); // canonical form
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Standard Schema does not convert to JSON Schema. Give the converter of your schema library as `toJsonSchema`.
|
|
52
|
+
|
|
53
|
+
## Serve the capabilities
|
|
54
|
+
|
|
55
|
+
| Function | Binding | Result |
|
|
56
|
+
| --- | --- | --- |
|
|
57
|
+
| `createSession(app, options)` | [TypeScript](../../spec/bindings/typescript.md) | A session with a handle for each capability in the view |
|
|
58
|
+
| `mcpHandler(app, options)` | [MCP](../../spec/bindings/mcp.md) | `(request: Request) => Promise<Response>` |
|
|
59
|
+
| `httpHandler(app, options)` | [HTTP](../../spec/bindings/http.md) | `(request: Request) => Promise<Response>` |
|
|
60
|
+
| `cliMain(app, options)`, `runCli(…)` | [CLI](../../spec/bindings/cli.md) | The command line of the process, or an exit code |
|
|
61
|
+
| `installWebMcp(session)` | [WebMCP](../../spec/bindings/webmcp.md) | Tools in the page, with a status |
|
|
62
|
+
|
|
63
|
+
### Context for each request
|
|
64
|
+
|
|
65
|
+
`mcpHandler` and `httpHandler` take `context` or `createContext`. With `context`, all requests share one context. With `createContext(request)`, the handler makes a new context for each request that passes the screening. Use it when a request must not use objects that an earlier request made, for example on Cloudflare Workers, or when a stateful service must not be shared between callers. When the context has `Symbol.asyncDispose` or `Symbol.dispose`, the handler calls it after the response:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
const handler = httpHandler(aircon, {
|
|
69
|
+
allowedOrigins: [],
|
|
70
|
+
toJsonSchema,
|
|
71
|
+
createContext: () => ({ unit: connectUnit(env), [Symbol.asyncDispose]: () => disconnectUnit() }),
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Context for each request
|
|
76
|
+
|
|
77
|
+
`mcpHandler` and `httpHandler` take `context` or `createContext`. With `context`, all requests share one context. With `createContext(request)`, the handler makes a new context for each request that passes the screening. Use it when a request must not use objects that an earlier request made, for example on Cloudflare Workers, or when a stateful service must not be shared between callers. When the context has `Symbol.asyncDispose` or `Symbol.dispose`, the handler calls it after the response:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
const handler = httpHandler(aircon, {
|
|
81
|
+
allowedOrigins: [],
|
|
82
|
+
toJsonSchema,
|
|
83
|
+
createContext: () => ({ unit: connectUnit(env), [Symbol.asyncDispose]: () => disconnectUnit() }),
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Examples and tests
|
|
88
|
+
|
|
89
|
+
- [`examples/aircon.ts`](examples/aircon.ts) is the air conditioner of [`examples/aircon.pkl`](../../examples/aircon.pkl), with a simulated unit. Its catalog agrees with the Pkl catalog.
|
|
90
|
+
- [`examples/aircon-cli.ts`](examples/aircon-cli.ts) is its command line: `node examples/aircon-cli.ts aircon power set --on`.
|
|
91
|
+
- [`examples/apis`](../../examples/apis/README.md) builds GraphQL, Cap'n Web and Elysia APIs on it, each beside the HTTP binding.
|
|
92
|
+
- `moon run core-ts:test` tests sessions, handles and all bindings with a test application, and runs `acid-cat conform` against all bindings.
|
package/dist/app.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Availability, CatalogDocument, JsonObject, Result } from "@acid-cat/spec-ts";
|
|
2
|
+
import type { StandardSchemaV1 } from "./standard-schema.js";
|
|
3
|
+
/** A value that can change, and that a client can follow (spec/bindings/typescript.md#handles). */
|
|
4
|
+
export interface Observable<T> {
|
|
5
|
+
getSnapshot(): T;
|
|
6
|
+
subscribe(listener: (value: T) => void): {
|
|
7
|
+
unsubscribe(): void;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
type Output<S> = S extends StandardSchemaV1 ? StandardSchemaV1.InferOutput<S> : null;
|
|
11
|
+
type FailureOf<F> = {
|
|
12
|
+
[Tag in keyof F]: {
|
|
13
|
+
readonly _tag: Tag;
|
|
14
|
+
} & (F[Tag] extends StandardSchemaV1 ? StandardSchemaV1.InferOutput<F[Tag]> : object);
|
|
15
|
+
}[keyof F];
|
|
16
|
+
export interface Capability<Context = unknown, Input extends StandardSchemaV1 | undefined = StandardSchemaV1 | undefined, OutputSchema extends StandardSchemaV1 | undefined = StandardSchemaV1 | undefined, Failures extends {
|
|
17
|
+
readonly [tag: string]: StandardSchemaV1;
|
|
18
|
+
} = {
|
|
19
|
+
readonly [tag: string]: StandardSchemaV1;
|
|
20
|
+
}> {
|
|
21
|
+
readonly id: string;
|
|
22
|
+
readonly description: string;
|
|
23
|
+
/** The targets that can use the capability. With no audience, no target can use it. */
|
|
24
|
+
readonly audience: readonly string[];
|
|
25
|
+
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
|
+
/** The value of a successful call. With no output schema, the value is `null`. */
|
|
29
|
+
readonly output?: OutputSchema;
|
|
30
|
+
/** The failures, by tag. The schema of a failure describes the object, without or with its `_tag`. */
|
|
31
|
+
readonly failures?: Failures;
|
|
32
|
+
/** A value that clients can observe (spec/invocation.md#state): its schema, and its source. */
|
|
33
|
+
readonly state?: {
|
|
34
|
+
readonly schema: StandardSchemaV1;
|
|
35
|
+
readonly source: (context: Context) => Observable<unknown>;
|
|
36
|
+
} | undefined;
|
|
37
|
+
/** The availability of the capability. With none, the capability is always available. */
|
|
38
|
+
readonly availability?: ((context: Context) => Observable<Availability>) | undefined;
|
|
39
|
+
readonly run: (input: Input extends StandardSchemaV1 ? StandardSchemaV1.InferOutput<Input> : Record<string, never>, call: {
|
|
40
|
+
readonly signal: AbortSignal;
|
|
41
|
+
readonly context: Context;
|
|
42
|
+
}) => Promise<Result<Output<OutputSchema>, FailureOf<Failures>>> | Result<Output<OutputSchema>, FailureOf<Failures>>;
|
|
43
|
+
}
|
|
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
|
+
/**
|
|
50
|
+
* Declare capabilities for one context type. The other types come from the schemas:
|
|
51
|
+
*
|
|
52
|
+
* ```ts
|
|
53
|
+
* const capability = capabilitiesFor<{ unit: Unit }>();
|
|
54
|
+
* capability({ id: "aircon.power.set", input: z.object({ on: z.boolean() }), run: ({ on }, { context }) => … });
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export declare const capabilitiesFor: <Context>() => <Input extends StandardSchemaV1 | undefined = undefined, OutputSchema extends StandardSchemaV1 | undefined = undefined, Failures extends {
|
|
58
|
+
readonly [tag: string]: StandardSchemaV1;
|
|
59
|
+
} = {}>(capability: Capability<Context, Input, OutputSchema, Failures>) => Capability<Context, Input, OutputSchema, Failures>;
|
|
60
|
+
export interface Target {
|
|
61
|
+
readonly bindings: readonly string[];
|
|
62
|
+
readonly description?: string | undefined;
|
|
63
|
+
readonly options?: JsonObject | undefined;
|
|
64
|
+
}
|
|
65
|
+
export interface App<Context = unknown> {
|
|
66
|
+
readonly title?: string | undefined;
|
|
67
|
+
readonly description?: string | undefined;
|
|
68
|
+
readonly targets: {
|
|
69
|
+
readonly [name: string]: Target;
|
|
70
|
+
};
|
|
71
|
+
readonly capabilities: readonly AnyCapability<Context>[];
|
|
72
|
+
}
|
|
73
|
+
/** Declare an application. */
|
|
74
|
+
export declare const defineApp: <Context = unknown>(app: App<Context>) => App<Context>;
|
|
75
|
+
/** Converts a schema to JSON Schema 2020-12, for example `(schema) => z.toJSONSchema(schema)` with Zod. */
|
|
76
|
+
export type ToJsonSchema = (schema: StandardSchemaV1) => JsonObject;
|
|
77
|
+
/** The catalog of an application, as a JSON document. */
|
|
78
|
+
export declare function catalogOf(app: App<any>, options: {
|
|
79
|
+
readonly toJsonSchema: ToJsonSchema;
|
|
80
|
+
}): CatalogDocument;
|
|
81
|
+
/** The catalog of an application, as text in canonical form. */
|
|
82
|
+
export declare const renderCatalog: (app: App<any>, options: {
|
|
83
|
+
readonly toJsonSchema: ToJsonSchema;
|
|
84
|
+
}) => string;
|
|
85
|
+
export {};
|
package/dist/app.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { canonicalText } from "@acid-cat/catalog";
|
|
2
|
+
//#region src/app.ts
|
|
3
|
+
/**
|
|
4
|
+
* Capabilities and applications in plain TypeScript. An application writes its own catalog.
|
|
5
|
+
* See spec/catalog.md.
|
|
6
|
+
*/
|
|
7
|
+
/** Declare a capability. `Context` is the type of the services that `run` receives. */
|
|
8
|
+
const defineCapability = (capability) => capability;
|
|
9
|
+
/**
|
|
10
|
+
* Declare capabilities for one context type. The other types come from the schemas:
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* const capability = capabilitiesFor<{ unit: Unit }>();
|
|
14
|
+
* capability({ id: "aircon.power.set", input: z.object({ on: z.boolean() }), run: ({ on }, { context }) => … });
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
const capabilitiesFor = () => (capability) => capability;
|
|
18
|
+
/** Declare an application. */
|
|
19
|
+
const defineApp = (app) => app;
|
|
20
|
+
/** The catalog of an application, as a JSON document. */
|
|
21
|
+
function catalogOf(app, options) {
|
|
22
|
+
const types = {};
|
|
23
|
+
const toJson = (schema, where) => {
|
|
24
|
+
const { $schema: _, $defs, ...json } = options.toJsonSchema(schema);
|
|
25
|
+
for (const [name, definition] of Object.entries($defs ?? {})) {
|
|
26
|
+
const existing = types[name];
|
|
27
|
+
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
|
+
types[name] = definition;
|
|
29
|
+
}
|
|
30
|
+
return json;
|
|
31
|
+
};
|
|
32
|
+
const capabilities = {};
|
|
33
|
+
for (const capability of app.capabilities) {
|
|
34
|
+
if (capability.id in capabilities) throw new Error(`The capability ${capability.id} is declared two times.`);
|
|
35
|
+
const failures = Object.fromEntries(Object.entries(capability.failures ?? {}).map(([tag, schema]) => [tag, toJson(schema, capability.id)]));
|
|
36
|
+
capabilities[capability.id] = {
|
|
37
|
+
description: capability.description,
|
|
38
|
+
audience: capability.audience,
|
|
39
|
+
...capability.input === void 0 ? {} : { input: toJson(capability.input, capability.id) },
|
|
40
|
+
...capability.output === void 0 ? {} : { output: toJson(capability.output, capability.id) },
|
|
41
|
+
...Object.keys(failures).length === 0 ? {} : { failures },
|
|
42
|
+
...capability.readOnly === true ? { readOnly: true } : {},
|
|
43
|
+
...capability.state === void 0 ? {} : { state: toJson(capability.state.schema, capability.id) }
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
acidcat: "0.1",
|
|
48
|
+
...app.title === void 0 ? {} : { title: app.title },
|
|
49
|
+
...app.description === void 0 ? {} : { description: app.description },
|
|
50
|
+
targets: Object.fromEntries(Object.entries(app.targets).map(([name, target]) => [name, {
|
|
51
|
+
bindings: target.bindings,
|
|
52
|
+
...target.description === void 0 ? {} : { description: target.description },
|
|
53
|
+
...target.options === void 0 ? {} : { options: target.options }
|
|
54
|
+
}])),
|
|
55
|
+
...Object.keys(types).length === 0 ? {} : { $defs: types },
|
|
56
|
+
capabilities
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** The catalog of an application, as text in canonical form. */
|
|
60
|
+
const renderCatalog = (app, options) => canonicalText(catalogOf(app, options));
|
|
61
|
+
//#endregion
|
|
62
|
+
export { capabilitiesFor, catalogOf, defineApp, defineCapability, renderCatalog };
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { App, ToJsonSchema } from "./app.js";
|
|
2
|
+
export interface CliOptions<Context> {
|
|
3
|
+
readonly name: string;
|
|
4
|
+
readonly version: string;
|
|
5
|
+
readonly context: Context;
|
|
6
|
+
readonly toJsonSchema: ToJsonSchema;
|
|
7
|
+
/** The target that the command line serves. The default is `"cli"`. */
|
|
8
|
+
readonly target?: string | undefined;
|
|
9
|
+
}
|
|
10
|
+
export interface CliIo {
|
|
11
|
+
readonly stdout: (text: string) => void;
|
|
12
|
+
readonly stderr: (text: string) => void;
|
|
13
|
+
readonly signal?: AbortSignal | undefined;
|
|
14
|
+
}
|
|
15
|
+
/** Run the command line as the main program of the process. An interrupt (`SIGINT`) cancels the call. */
|
|
16
|
+
export declare function main<Context>(app: App<Context>, options: CliOptions<Context>): void;
|
|
17
|
+
/** Run the command line with arguments. The result is the exit code (spec/bindings/cli.md#outcomes). */
|
|
18
|
+
export declare function runCli<Context>(app: App<Context>, options: CliOptions<Context>, args: readonly string[], io: CliIo): Promise<number>;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { createSession } from "./session.js";
|
|
2
|
+
import { CLI_EXIT_CODES, REFERENCE, bundle, cliExitCode, commandPath, isCallFailure } from "@acid-cat/spec-ts";
|
|
3
|
+
//#region src/cli.ts
|
|
4
|
+
/**
|
|
5
|
+
* The CLI binding (spec/bindings/cli.md): a command line made from the catalog.
|
|
6
|
+
*/
|
|
7
|
+
var UsageError = class extends Error {
|
|
8
|
+
exitCode;
|
|
9
|
+
constructor(message, exitCode) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.exitCode = exitCode;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
/** Run the command line as the main program of the process. An interrupt (`SIGINT`) cancels the call. */
|
|
15
|
+
function main(app, options) {
|
|
16
|
+
const interrupt = new AbortController();
|
|
17
|
+
process.once("SIGINT", () => interrupt.abort());
|
|
18
|
+
runCli(app, options, process.argv.slice(2), {
|
|
19
|
+
stdout: (text) => process.stdout.write(`${text}\n`),
|
|
20
|
+
stderr: (text) => process.stderr.write(`${text}\n`),
|
|
21
|
+
signal: interrupt.signal
|
|
22
|
+
}).then((code) => {
|
|
23
|
+
process.exitCode = code;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/** Run the command line with arguments. The result is the exit code (spec/bindings/cli.md#outcomes). */
|
|
27
|
+
async function runCli(app, options, args, io) {
|
|
28
|
+
const session = createSession(app, {
|
|
29
|
+
target: options.target ?? "cli",
|
|
30
|
+
context: options.context,
|
|
31
|
+
toJsonSchema: options.toJsonSchema
|
|
32
|
+
});
|
|
33
|
+
const types = session.view.$defs ?? {};
|
|
34
|
+
const commands = Object.keys(session.view.capabilities).map((id) => ({
|
|
35
|
+
id,
|
|
36
|
+
path: commandPath(id)
|
|
37
|
+
}));
|
|
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 print = (outcome) => {
|
|
40
|
+
if (isCallFailure(outcome)) io.stderr(JSON.stringify({
|
|
41
|
+
kind: outcome.kind,
|
|
42
|
+
message: outcome.message
|
|
43
|
+
}));
|
|
44
|
+
else io.stdout(JSON.stringify(outcome.ok ? outcome.value : outcome.error, null, 2));
|
|
45
|
+
return cliExitCode(outcome);
|
|
46
|
+
};
|
|
47
|
+
try {
|
|
48
|
+
const [first, ...rest] = args;
|
|
49
|
+
if (first === void 0 || first === "--help" || first === "-h") {
|
|
50
|
+
io.stdout(help(options.name, commands.map(({ path }) => path.join(" "))));
|
|
51
|
+
return CLI_EXIT_CODES.value;
|
|
52
|
+
}
|
|
53
|
+
if (first === "--version" || first === "-v") {
|
|
54
|
+
io.stdout(options.version);
|
|
55
|
+
return CLI_EXIT_CODES.value;
|
|
56
|
+
}
|
|
57
|
+
if (first === "list") {
|
|
58
|
+
io.stdout(JSON.stringify([...session.handles.values()].map((handle) => ({
|
|
59
|
+
id: handle.id,
|
|
60
|
+
description: handle.description,
|
|
61
|
+
...handle.availability.getSnapshot(),
|
|
62
|
+
usage: `${options.name} ${commandPath(handle.id).join(" ")}`
|
|
63
|
+
})), null, 2));
|
|
64
|
+
return CLI_EXIT_CODES.value;
|
|
65
|
+
}
|
|
66
|
+
if (first === "call") {
|
|
67
|
+
const [id, text = "{}"] = rest;
|
|
68
|
+
if (id === void 0) throw new UsageError("Give the id of a capability.", CLI_EXIT_CODES["invalid-input"]);
|
|
69
|
+
let input;
|
|
70
|
+
try {
|
|
71
|
+
input = JSON.parse(text);
|
|
72
|
+
} catch {
|
|
73
|
+
return print({
|
|
74
|
+
kind: "invalid-input",
|
|
75
|
+
message: "The input is not JSON."
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return print(await session.invoke(id, input, { signal: io.signal }));
|
|
79
|
+
}
|
|
80
|
+
const match = commands.filter(({ path }) => path.every((word, index) => args[index] === word)).sort((a, b) => b.path.length - a.path.length)[0];
|
|
81
|
+
if (match === void 0) throw new UsageError(`There is no command ${args.filter((arg) => !arg.startsWith("-")).join(" ")}.`, CLI_EXIT_CODES["unknown-capability"]);
|
|
82
|
+
const capability = session.view.capabilities[match.id];
|
|
83
|
+
const schema = bundle(capability.input ?? { type: "object" }, types);
|
|
84
|
+
const flagArgs = args.slice(match.path.length);
|
|
85
|
+
if (flagArgs.includes("--help") || flagArgs.includes("-h")) {
|
|
86
|
+
io.stdout(usage(options.name, match.path, capability.description, schema));
|
|
87
|
+
return CLI_EXIT_CODES.value;
|
|
88
|
+
}
|
|
89
|
+
return print(await session.invoke(match.id, parseFlags(flagArgs, schema), { signal: io.signal }));
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (!(error instanceof UsageError)) throw error;
|
|
92
|
+
io.stderr(error.message);
|
|
93
|
+
return error.exitCode;
|
|
94
|
+
} finally {
|
|
95
|
+
await session.dispose();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** Make the input from the flags (spec/bindings/cli.md#commands). */
|
|
99
|
+
function parseFlags(args, schema) {
|
|
100
|
+
const properties = schema.properties ?? {};
|
|
101
|
+
const definitions = schema.$defs ?? {};
|
|
102
|
+
const input = {};
|
|
103
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
104
|
+
const arg = args[index];
|
|
105
|
+
if (!arg.startsWith("--")) throw new UsageError(`Unexpected argument ${arg}.`, CLI_EXIT_CODES["invalid-input"]);
|
|
106
|
+
const [name, inline] = arg.slice(2).split(/=(.*)/s, 2);
|
|
107
|
+
const property = properties[name];
|
|
108
|
+
if (property === void 0) throw new UsageError(`Unknown flag --${name}.`, CLI_EXIT_CODES["invalid-input"]);
|
|
109
|
+
const type = resolve(property, definitions);
|
|
110
|
+
if (type.type === "boolean") {
|
|
111
|
+
input[name] = inline === void 0 ? true : inline === "true";
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const text = inline ?? args[index += 1];
|
|
115
|
+
if (text === void 0) throw new UsageError(`The flag --${name} needs a value.`, CLI_EXIT_CODES["invalid-input"]);
|
|
116
|
+
input[name] = valueOf(name, text, type);
|
|
117
|
+
}
|
|
118
|
+
for (const name of schema.required ?? []) if (!(name in input)) throw new UsageError(`The flag --${name} is required.`, CLI_EXIT_CODES["invalid-input"]);
|
|
119
|
+
return input;
|
|
120
|
+
}
|
|
121
|
+
function valueOf(name, text, type) {
|
|
122
|
+
if (Array.isArray(type.enum) && type.enum.every((value) => typeof value === "string")) {
|
|
123
|
+
if (!type.enum.includes(text)) throw new UsageError(`The flag --${name} must be one of: ${type.enum.join(", ")}.`, CLI_EXIT_CODES["invalid-input"]);
|
|
124
|
+
return text;
|
|
125
|
+
}
|
|
126
|
+
if (type.type === "integer" || type.type === "number") {
|
|
127
|
+
const number = Number(text);
|
|
128
|
+
if (text.trim() === "" || !Number.isFinite(number) || type.type === "integer" && !Number.isInteger(number)) throw new UsageError(`The flag --${name} must be a ${type.type}.`, CLI_EXIT_CODES["invalid-input"]);
|
|
129
|
+
return number;
|
|
130
|
+
}
|
|
131
|
+
if (type.type === "string") return text;
|
|
132
|
+
try {
|
|
133
|
+
return JSON.parse(text);
|
|
134
|
+
} catch {
|
|
135
|
+
throw new UsageError(`The flag --${name} must be JSON.`, CLI_EXIT_CODES["invalid-input"]);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function resolve(property, definitions) {
|
|
139
|
+
const name = typeof property.$ref === "string" ? REFERENCE.exec(property.$ref)?.[1] : void 0;
|
|
140
|
+
return name === void 0 ? property : definitions[name] ?? property;
|
|
141
|
+
}
|
|
142
|
+
function help(name, paths) {
|
|
143
|
+
return [
|
|
144
|
+
`Usage: ${name} <command> [flags]`,
|
|
145
|
+
"",
|
|
146
|
+
"Commands:",
|
|
147
|
+
...paths.map((path) => ` ${path}`),
|
|
148
|
+
" call <id> [input]",
|
|
149
|
+
" list"
|
|
150
|
+
].join("\n");
|
|
151
|
+
}
|
|
152
|
+
function usage(name, path, description, schema) {
|
|
153
|
+
const properties = Object.keys(schema.properties ?? {});
|
|
154
|
+
const required = new Set(schema.required ?? []);
|
|
155
|
+
const flags = properties.map((property) => required.has(property) ? `--${property} <value>` : `[--${property} <value>]`);
|
|
156
|
+
return [
|
|
157
|
+
`Usage: ${name} ${[...path, ...flags].join(" ")}`,
|
|
158
|
+
"",
|
|
159
|
+
description
|
|
160
|
+
].join("\n");
|
|
161
|
+
}
|
|
162
|
+
//#endregion
|
|
163
|
+
export { main, runCli };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The context of the web handlers: one context that all requests share, or a new context for each request.
|
|
3
|
+
*/
|
|
4
|
+
export type ContextOption<Context> = {
|
|
5
|
+
/** The context of all requests. */
|
|
6
|
+
readonly context: Context;
|
|
7
|
+
readonly createContext?: undefined;
|
|
8
|
+
} | {
|
|
9
|
+
/**
|
|
10
|
+
* Make the context of one request. The handler calls it for each request that passes the screening. When the
|
|
11
|
+
* context has `Symbol.asyncDispose` or `Symbol.dispose`, the handler calls it after the response, as a `using`
|
|
12
|
+
* declaration does.
|
|
13
|
+
*/
|
|
14
|
+
readonly createContext: (request: Request) => Context | Promise<Context>;
|
|
15
|
+
readonly context?: undefined;
|
|
16
|
+
};
|
|
17
|
+
/** Give the context of one request to `use`, and release it afterwards if the handler made it. */
|
|
18
|
+
export declare function withContext<Context, A>(option: ContextOption<Context>, request: Request, use: (context: Context) => Promise<A>): Promise<A>;
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
//#region src/context.ts
|
|
2
|
+
/** Give the context of one request to `use`, and release it afterwards if the handler made it. */
|
|
3
|
+
async function withContext(option, request, use) {
|
|
4
|
+
if (option.createContext === void 0) return use(option.context);
|
|
5
|
+
const context = await option.createContext(request);
|
|
6
|
+
try {
|
|
7
|
+
return await use(context);
|
|
8
|
+
} finally {
|
|
9
|
+
await release(context);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** Call `Symbol.asyncDispose` or `Symbol.dispose` of a value, if it has one. */
|
|
13
|
+
async function release(value) {
|
|
14
|
+
if (typeof value !== "object" || value === null) return;
|
|
15
|
+
const disposable = value;
|
|
16
|
+
if (typeof disposable[Symbol.asyncDispose] === "function") await disposable[Symbol.asyncDispose]();
|
|
17
|
+
else if (typeof disposable[Symbol.dispose] === "function") disposable[Symbol.dispose]();
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
export { withContext };
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { App, ToJsonSchema } from "./app.js";
|
|
2
|
+
import type { ContextOption } from "./context.js";
|
|
3
|
+
/** The options of `httpHandler`. Give `context` to share one context, or `createContext` to make one for each request. */
|
|
4
|
+
export type HttpOptions<Context> = HttpSettings & ContextOption<Context>;
|
|
5
|
+
export interface HttpSettings {
|
|
6
|
+
/** The origins that can call the API. A request with no `Origin` header is accepted. */
|
|
7
|
+
readonly allowedOrigins: readonly string[];
|
|
8
|
+
readonly toJsonSchema: ToJsonSchema;
|
|
9
|
+
/** The target that the API serves. The default is `"api"`. */
|
|
10
|
+
readonly target?: string | undefined;
|
|
11
|
+
/** The path before `/capabilities` and `/catalog`, for example `"/api"`. The default is `""`. */
|
|
12
|
+
readonly base?: string | undefined;
|
|
13
|
+
/** Serve `GET {base}/catalog`. The default is `true`. */
|
|
14
|
+
readonly serveCatalog?: boolean | undefined;
|
|
15
|
+
/** The maximum size of a request body, in bytes. The default is 1 MiB. */
|
|
16
|
+
readonly maximumBytes?: number | undefined;
|
|
17
|
+
}
|
|
18
|
+
/** A web request handler for the API. Each request has its own session, and with `createContext` its own context. */
|
|
19
|
+
export declare function httpHandler<Context>(app: App<Context>, options: HttpOptions<Context>): (request: Request) => Promise<Response>;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createSession } from "./session.js";
|
|
2
|
+
import { withContext } from "./context.js";
|
|
3
|
+
import { callFailure, httpStatus, isCallFailure, readBounded, screenRequest } from "@acid-cat/spec-ts";
|
|
4
|
+
//#region src/http.ts
|
|
5
|
+
/**
|
|
6
|
+
* The HTTP binding (spec/bindings/http.md): JSON through HTTP.
|
|
7
|
+
*/
|
|
8
|
+
/** A web request handler for the API. Each request has its own session, and with `createContext` its own context. */
|
|
9
|
+
function httpHandler(app, options) {
|
|
10
|
+
const maximumBytes = options.maximumBytes ?? 1048576;
|
|
11
|
+
return async (request) => {
|
|
12
|
+
const screened = screenRequest(request, {
|
|
13
|
+
methods: ["GET", "POST"],
|
|
14
|
+
allowedOrigins: options.allowedOrigins,
|
|
15
|
+
mediaType: "application/json",
|
|
16
|
+
maximumBytes
|
|
17
|
+
});
|
|
18
|
+
if (screened !== void 0) return rejection(screened);
|
|
19
|
+
const path = new URL(request.url).pathname.slice((options.base ?? "").length);
|
|
20
|
+
return withContext(options, request, async (context) => {
|
|
21
|
+
const session = createSession(app, {
|
|
22
|
+
target: options.target ?? "api",
|
|
23
|
+
context,
|
|
24
|
+
toJsonSchema: options.toJsonSchema
|
|
25
|
+
});
|
|
26
|
+
try {
|
|
27
|
+
if (request.method === "GET" && path === "/capabilities") return json(200, [...session.handles.values()].map((handle) => ({
|
|
28
|
+
id: handle.id,
|
|
29
|
+
...handle.availability.getSnapshot()
|
|
30
|
+
})));
|
|
31
|
+
if (request.method === "GET" && path === "/catalog" && options.serveCatalog !== false) return json(200, session.view);
|
|
32
|
+
const id = request.method === "POST" && path.startsWith("/capabilities/") ? path.slice(14) : void 0;
|
|
33
|
+
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
|
+
let input;
|
|
37
|
+
try {
|
|
38
|
+
input = body.text === "" ? {} : JSON.parse(body.text);
|
|
39
|
+
} catch {
|
|
40
|
+
return json(422, callFailure("invalid-input", "The request body is not JSON."));
|
|
41
|
+
}
|
|
42
|
+
const outcome = await session.invoke(decodeURIComponent(id), input, { signal: request.signal });
|
|
43
|
+
return json(httpStatus(outcome), isCallFailure(outcome) ? {
|
|
44
|
+
kind: outcome.kind,
|
|
45
|
+
message: outcome.message
|
|
46
|
+
} : outcome);
|
|
47
|
+
} finally {
|
|
48
|
+
await session.dispose();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const json = (status, body) => new Response(JSON.stringify(body), {
|
|
54
|
+
status,
|
|
55
|
+
headers: { "Content-Type": "application/json" }
|
|
56
|
+
});
|
|
57
|
+
const statuses = {
|
|
58
|
+
method: 405,
|
|
59
|
+
origin: 403,
|
|
60
|
+
"media-type": 415,
|
|
61
|
+
"too-large": 413,
|
|
62
|
+
"not-utf8": 400
|
|
63
|
+
};
|
|
64
|
+
function rejection(reason) {
|
|
65
|
+
const headers = new Headers({ "Cache-Control": "no-store" });
|
|
66
|
+
if (reason === "method") headers.set("Allow", "GET, POST");
|
|
67
|
+
return new Response(null, {
|
|
68
|
+
status: statuses[reason],
|
|
69
|
+
headers
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
//#endregion
|
|
73
|
+
export { httpHandler };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
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 } from "./session.js";
|
|
4
|
+
export type { Handle, Session, SessionOptions } from "./session.js";
|
|
5
|
+
export { mcpHandler } from "./mcp.js";
|
|
6
|
+
export type { McpOptions, McpSettings } from "./mcp.js";
|
|
7
|
+
export { httpHandler } from "./http.js";
|
|
8
|
+
export type { HttpOptions, HttpSettings } from "./http.js";
|
|
9
|
+
export type { ContextOption } from "./context.js";
|
|
10
|
+
export { main as cliMain, runCli } from "./cli.js";
|
|
11
|
+
export type { CliIo, CliOptions } from "./cli.js";
|
|
12
|
+
export { findHost, installWebMcp } from "./webmcp.js";
|
|
13
|
+
export type { Installation, ModelContext, ModelContextTool, Phase, Status } from "./webmcp.js";
|
|
14
|
+
export type { StandardSchemaV1 } from "./standard-schema.js";
|
|
15
|
+
export { AVAILABLE, CallFailureError, fail, ok, unavailable } from "@acid-cat/spec-ts";
|
|
16
|
+
export type { Availability, CallFailure, CallFailureKind, Outcome, Result } from "@acid-cat/spec-ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { capabilitiesFor, catalogOf, defineApp, defineCapability, renderCatalog } from "./app.js";
|
|
2
|
+
import { createSession } from "./session.js";
|
|
3
|
+
import { mcpHandler } from "./mcp.js";
|
|
4
|
+
import { httpHandler } from "./http.js";
|
|
5
|
+
import { main, runCli } from "./cli.js";
|
|
6
|
+
import { findHost, installWebMcp } from "./webmcp.js";
|
|
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, httpHandler, installWebMcp, mcpHandler, ok, renderCatalog, runCli, unavailable };
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { App, ToJsonSchema } from "./app.js";
|
|
2
|
+
import type { ContextOption } from "./context.js";
|
|
3
|
+
/** The options of `mcpHandler`. Give `context` to share one context, or `createContext` to make one for each request. */
|
|
4
|
+
export type McpOptions<Context> = McpSettings & ContextOption<Context>;
|
|
5
|
+
export interface McpSettings {
|
|
6
|
+
readonly name: string;
|
|
7
|
+
readonly version: string;
|
|
8
|
+
/** The origins that can call the server. A request with no `Origin` header is accepted. */
|
|
9
|
+
readonly allowedOrigins: readonly string[];
|
|
10
|
+
readonly toJsonSchema: ToJsonSchema;
|
|
11
|
+
/** The target that the server serves. The default is `"mcp"`. */
|
|
12
|
+
readonly target?: string | undefined;
|
|
13
|
+
/** The maximum size of a request body, in bytes. The default is 1 MiB. */
|
|
14
|
+
readonly maximumBytes?: number | undefined;
|
|
15
|
+
}
|
|
16
|
+
/** A web request handler for the MCP server. Each request has its own session, and with `createContext` its own context. */
|
|
17
|
+
export declare function mcpHandler<Context>(app: App<Context>, options: McpOptions<Context>): (request: Request) => Promise<Response>;
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createSession } from "./session.js";
|
|
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";
|
|
4
|
+
//#region src/mcp.ts
|
|
5
|
+
/**
|
|
6
|
+
* The MCP binding (spec/bindings/mcp.md): an MCP server through HTTP, revision 2026-07-28, with no state on the
|
|
7
|
+
* server between requests.
|
|
8
|
+
*/
|
|
9
|
+
/** A web request handler for the MCP server. Each request has its own session, and with `createContext` its own context. */
|
|
10
|
+
function mcpHandler(app, options) {
|
|
11
|
+
const maximumBytes = options.maximumBytes ?? 1048576;
|
|
12
|
+
return async (request) => {
|
|
13
|
+
const screened = screenRequest(request, {
|
|
14
|
+
methods: ["POST"],
|
|
15
|
+
allowedOrigins: options.allowedOrigins,
|
|
16
|
+
mediaType: "application/json",
|
|
17
|
+
maximumBytes
|
|
18
|
+
});
|
|
19
|
+
if (screened !== void 0) return rejection(screened);
|
|
20
|
+
const body = await readBounded(request, maximumBytes);
|
|
21
|
+
if ("rejection" in body) return rejection(body.rejection);
|
|
22
|
+
let message;
|
|
23
|
+
try {
|
|
24
|
+
message = JSON.parse(body.text);
|
|
25
|
+
} catch {
|
|
26
|
+
return rejection("not-utf8");
|
|
27
|
+
}
|
|
28
|
+
const version = request.headers.get("MCP-Protocol-Version");
|
|
29
|
+
const id = message.id ?? null;
|
|
30
|
+
if (version === null) return error(400, id, -32020, "MCP-Protocol-Version header is required");
|
|
31
|
+
if (version !== MCP_PROTOCOL_VERSION) return error(400, id, -32022, `Unsupported protocol version ${version}`, { supported: [MCP_PROTOCOL_VERSION] });
|
|
32
|
+
if (message.id === void 0) return new Response(null, {
|
|
33
|
+
status: 200,
|
|
34
|
+
headers: { "Content-Type": "application/json" }
|
|
35
|
+
});
|
|
36
|
+
const metaVersion = message.params?._meta?.["io.modelcontextprotocol/protocolVersion"];
|
|
37
|
+
if (metaVersion === void 0) return error(400, id, -32602, "Required request metadata is missing");
|
|
38
|
+
if (metaVersion !== version) return error(400, id, -32020, "MCP-Protocol-Version header does not match request metadata");
|
|
39
|
+
return withContext(options, request, async (context) => {
|
|
40
|
+
const session = createSession(app, {
|
|
41
|
+
target: options.target ?? "mcp",
|
|
42
|
+
context,
|
|
43
|
+
toJsonSchema: options.toJsonSchema
|
|
44
|
+
});
|
|
45
|
+
try {
|
|
46
|
+
return await answer(session, message, options, request.signal);
|
|
47
|
+
} finally {
|
|
48
|
+
await session.dispose();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
async function answer(session, message, options, signal) {
|
|
54
|
+
const id = message.id ?? null;
|
|
55
|
+
const complete = (result, list = false) => reply(id, {
|
|
56
|
+
_meta: { "io.modelcontextprotocol/serverInfo": {
|
|
57
|
+
name: options.name,
|
|
58
|
+
version: options.version
|
|
59
|
+
} },
|
|
60
|
+
resultType: "complete",
|
|
61
|
+
...list ? {
|
|
62
|
+
ttlMs: 0,
|
|
63
|
+
cacheScope: "private"
|
|
64
|
+
} : {},
|
|
65
|
+
...result
|
|
66
|
+
});
|
|
67
|
+
switch (message.method) {
|
|
68
|
+
case "server/discover": return complete({
|
|
69
|
+
supportedVersions: [MCP_PROTOCOL_VERSION],
|
|
70
|
+
capabilities: { tools: { listChanged: false } }
|
|
71
|
+
}, true);
|
|
72
|
+
case "tools/list": return complete({ tools: listTools(session) }, true);
|
|
73
|
+
case "tools/call": {
|
|
74
|
+
const name = message.params?.name ?? "";
|
|
75
|
+
const handle = session.handles.get(name);
|
|
76
|
+
if (handle === void 0 || !handle.availability.getSnapshot().available) return error(200, id, MCP_UNKNOWN_TOOL, `Tool '${name}' not found`);
|
|
77
|
+
const outcome = await session.invoke(name, message.params?.arguments ?? {}, { signal });
|
|
78
|
+
return complete(mcpToolResult(outcome, hasObjectOutput(session, name)));
|
|
79
|
+
}
|
|
80
|
+
default: return error(404, id, -32601, `Method not found: ${message.method}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function listTools(session) {
|
|
84
|
+
const types = session.view.$defs ?? {};
|
|
85
|
+
return [...session.handles.values()].filter((handle) => handle.availability.getSnapshot().available).map((handle) => {
|
|
86
|
+
const capability = session.view.capabilities[handle.id];
|
|
87
|
+
const output = capability.output === void 0 ? void 0 : bundle(capability.output, types);
|
|
88
|
+
return {
|
|
89
|
+
name: handle.id,
|
|
90
|
+
description: capability.description,
|
|
91
|
+
inputSchema: bundle(capability.input ?? { type: "object" }, types),
|
|
92
|
+
...output?.type === "object" ? { outputSchema: output } : {},
|
|
93
|
+
annotations: { readOnlyHint: capability.readOnly === true }
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
function hasObjectOutput(session, id) {
|
|
98
|
+
const output = session.view.capabilities[id]?.output;
|
|
99
|
+
return output !== void 0 && bundle(output, session.view.$defs ?? {}).type === "object";
|
|
100
|
+
}
|
|
101
|
+
function reply(id, result) {
|
|
102
|
+
return new Response(JSON.stringify({
|
|
103
|
+
jsonrpc: "2.0",
|
|
104
|
+
id,
|
|
105
|
+
result
|
|
106
|
+
}), {
|
|
107
|
+
status: 200,
|
|
108
|
+
headers: { "Content-Type": "application/json" }
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
function error(status, id, code, message, data) {
|
|
112
|
+
const body = {
|
|
113
|
+
jsonrpc: "2.0",
|
|
114
|
+
id,
|
|
115
|
+
error: {
|
|
116
|
+
code,
|
|
117
|
+
message,
|
|
118
|
+
...data === void 0 ? {} : { data }
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
return new Response(JSON.stringify(body), {
|
|
122
|
+
status,
|
|
123
|
+
headers: { "Content-Type": "application/json" }
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
function rejection(reason) {
|
|
127
|
+
const { status, code } = MCP_REJECTIONS[reason];
|
|
128
|
+
const headers = new Headers({ "Cache-Control": "no-store" });
|
|
129
|
+
if (reason === "method") headers.set("Allow", "POST");
|
|
130
|
+
if (code === void 0) return new Response(null, {
|
|
131
|
+
status,
|
|
132
|
+
headers
|
|
133
|
+
});
|
|
134
|
+
headers.set("Content-Type", "application/json");
|
|
135
|
+
return new Response(JSON.stringify({
|
|
136
|
+
jsonrpc: "2.0",
|
|
137
|
+
id: null,
|
|
138
|
+
error: {
|
|
139
|
+
code,
|
|
140
|
+
message: messages[reason]
|
|
141
|
+
}
|
|
142
|
+
}), {
|
|
143
|
+
status,
|
|
144
|
+
headers
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const messages = {
|
|
148
|
+
method: "Use POST.",
|
|
149
|
+
origin: "This origin is not allowed.",
|
|
150
|
+
"media-type": "Use application/json.",
|
|
151
|
+
"too-large": "The request body is too large.",
|
|
152
|
+
"not-utf8": "The request body is not UTF-8 JSON."
|
|
153
|
+
};
|
|
154
|
+
//#endregion
|
|
155
|
+
export { mcpHandler };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Availability, CatalogDocument, Outcome, Result } from "@acid-cat/spec-ts";
|
|
2
|
+
import type { App, Observable, ToJsonSchema } from "./app.js";
|
|
3
|
+
export interface Handle {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly description: string;
|
|
6
|
+
readonly audience: readonly string[];
|
|
7
|
+
readonly availability: Observable<Availability>;
|
|
8
|
+
/** The state of the capability, if it has one. Each value is a copy. */
|
|
9
|
+
readonly snapshot?: Observable<unknown>;
|
|
10
|
+
/** A call with an input that is already valid. A call failure rejects with a `CallFailureError`. */
|
|
11
|
+
execute(input: unknown, options?: {
|
|
12
|
+
readonly signal?: AbortSignal | undefined;
|
|
13
|
+
}): Promise<Result<unknown, unknown>>;
|
|
14
|
+
/** A call with an input that is not trusted. A call failure rejects with a `CallFailureError`. */
|
|
15
|
+
invokeExternal(input: unknown, options?: {
|
|
16
|
+
readonly signal?: AbortSignal | undefined;
|
|
17
|
+
}): Promise<Result<unknown, unknown>>;
|
|
18
|
+
}
|
|
19
|
+
export interface Session {
|
|
20
|
+
readonly target: string;
|
|
21
|
+
/** The view of the target. Bindings take names, descriptions and schemas from it. */
|
|
22
|
+
readonly view: CatalogDocument;
|
|
23
|
+
/** The handles of the capabilities in the view, by id. */
|
|
24
|
+
readonly handles: ReadonlyMap<string, Handle>;
|
|
25
|
+
/** A call with an input that is not trusted. The outcome is in its JSON form. The promise never rejects. */
|
|
26
|
+
invoke(id: string, input: unknown, options?: {
|
|
27
|
+
readonly signal?: AbortSignal | undefined;
|
|
28
|
+
}): Promise<Outcome<unknown, unknown>>;
|
|
29
|
+
/** An event each time the availability of a capability changes. */
|
|
30
|
+
onChange(listener: () => void): {
|
|
31
|
+
unsubscribe(): void;
|
|
32
|
+
};
|
|
33
|
+
/** The signal of the session. It aborts when the session closes. */
|
|
34
|
+
readonly signal: AbortSignal;
|
|
35
|
+
/** Close the session: accept no new calls, cancel the calls in progress, and wait for them. */
|
|
36
|
+
dispose(): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
export interface SessionOptions<Context> {
|
|
39
|
+
readonly target: string;
|
|
40
|
+
/** The services that each `run` receives. */
|
|
41
|
+
readonly context: Context;
|
|
42
|
+
readonly toJsonSchema: ToJsonSchema;
|
|
43
|
+
/** Record a defect. The default writes it to the console. */
|
|
44
|
+
readonly onDefect?: ((id: string, cause: unknown) => void) | undefined;
|
|
45
|
+
}
|
|
46
|
+
/** Open a session for a target. */
|
|
47
|
+
export declare function createSession<Context>(app: App<Context>, options: SessionOptions<Context>): Session;
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { catalogOf } from "./app.js";
|
|
2
|
+
import { AVAILABLE, CallFailureError, callFailure, isCallFailure, viewOf } from "@acid-cat/spec-ts";
|
|
3
|
+
//#region src/session.ts
|
|
4
|
+
/**
|
|
5
|
+
* Sessions and handles (spec/invocation.md, spec/bindings/typescript.md).
|
|
6
|
+
*/
|
|
7
|
+
const always = {
|
|
8
|
+
getSnapshot: () => AVAILABLE,
|
|
9
|
+
subscribe: () => ({ unsubscribe() {} })
|
|
10
|
+
};
|
|
11
|
+
/** Open a session for a target. */
|
|
12
|
+
function createSession(app, options) {
|
|
13
|
+
const view = viewOf(catalogOf(app, { toJsonSchema: options.toJsonSchema }), options.target);
|
|
14
|
+
const lifetime = new AbortController();
|
|
15
|
+
const running = /* @__PURE__ */ new Set();
|
|
16
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
17
|
+
const subscriptions = [];
|
|
18
|
+
const onDefect = options.onDefect ?? ((id, cause) => console.error(`A capability failed with a defect: ${id}`, cause));
|
|
19
|
+
const served = /* @__PURE__ */ new Map();
|
|
20
|
+
for (const capability of app.capabilities) {
|
|
21
|
+
if (!(capability.id in view.capabilities)) continue;
|
|
22
|
+
const availability = capability.availability?.(options.context) ?? always;
|
|
23
|
+
subscriptions.push(availability.subscribe(() => listeners.forEach((listener) => listener())));
|
|
24
|
+
served.set(capability.id, {
|
|
25
|
+
capability,
|
|
26
|
+
availability
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/** Run a call. The result is a result or a call failure. */
|
|
30
|
+
async function call(id, input, trusted, signal) {
|
|
31
|
+
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
|
+
const availability = entry.availability.getSnapshot();
|
|
35
|
+
if (!availability.available) return callFailure("unavailable", availability.reason);
|
|
36
|
+
const { capability } = entry;
|
|
37
|
+
let decoded = input ?? {};
|
|
38
|
+
if (!trusted && capability.input !== void 0) {
|
|
39
|
+
const validated = await capability.input["~standard"].validate(input);
|
|
40
|
+
if (validated.issues !== void 0) {
|
|
41
|
+
const message = validated.issues.map((issue) => `${(issue.path ?? []).map((key) => typeof key === "object" ? String(key.key) : String(key)).join(".") || "input"}: ${issue.message}`).join("; ");
|
|
42
|
+
return callFailure("invalid-input", message);
|
|
43
|
+
}
|
|
44
|
+
decoded = validated.value;
|
|
45
|
+
}
|
|
46
|
+
const controller = new AbortController();
|
|
47
|
+
const abort = () => controller.abort();
|
|
48
|
+
lifetime.signal.addEventListener("abort", abort);
|
|
49
|
+
signal?.addEventListener("abort", abort);
|
|
50
|
+
const cancelled = new Promise((resolve) => controller.signal.addEventListener("abort", () => resolve(callFailure("cancelled", "The call was cancelled."))));
|
|
51
|
+
if (lifetime.signal.aborted || signal?.aborted) controller.abort();
|
|
52
|
+
const work = Promise.resolve().then(() => capability.run(decoded, {
|
|
53
|
+
signal: controller.signal,
|
|
54
|
+
context: options.context
|
|
55
|
+
})).then((result) => checkResult(capability, result)).catch((cause) => {
|
|
56
|
+
onDefect(id, cause);
|
|
57
|
+
return callFailure("internal", "The capability failed.");
|
|
58
|
+
});
|
|
59
|
+
const outcome = Promise.race([work, cancelled]);
|
|
60
|
+
running.add(outcome);
|
|
61
|
+
try {
|
|
62
|
+
return await outcome;
|
|
63
|
+
} finally {
|
|
64
|
+
running.delete(outcome);
|
|
65
|
+
lifetime.signal.removeEventListener("abort", abort);
|
|
66
|
+
signal?.removeEventListener("abort", abort);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const handles = /* @__PURE__ */ new Map();
|
|
70
|
+
for (const [id, { capability, availability }] of served) {
|
|
71
|
+
const reject = async (outcome) => {
|
|
72
|
+
const settled = await outcome;
|
|
73
|
+
if (isCallFailure(settled)) throw new CallFailureError(settled);
|
|
74
|
+
return settled;
|
|
75
|
+
};
|
|
76
|
+
const state = capability.state?.source(options.context);
|
|
77
|
+
handles.set(id, {
|
|
78
|
+
id,
|
|
79
|
+
description: capability.description,
|
|
80
|
+
audience: capability.audience,
|
|
81
|
+
availability,
|
|
82
|
+
...state === void 0 ? {} : { snapshot: detached(state) },
|
|
83
|
+
execute: (input, callOptions) => reject(call(id, input, true, callOptions?.signal)),
|
|
84
|
+
invokeExternal: (input, callOptions) => reject(call(id, input, false, callOptions?.signal))
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
target: options.target,
|
|
89
|
+
view,
|
|
90
|
+
handles,
|
|
91
|
+
signal: lifetime.signal,
|
|
92
|
+
invoke: (id, input, callOptions) => call(id, input, false, callOptions?.signal),
|
|
93
|
+
onChange(listener) {
|
|
94
|
+
listeners.add(listener);
|
|
95
|
+
return { unsubscribe: () => listeners.delete(listener) };
|
|
96
|
+
},
|
|
97
|
+
async dispose() {
|
|
98
|
+
lifetime.abort();
|
|
99
|
+
subscriptions.forEach((subscription) => subscription.unsubscribe());
|
|
100
|
+
await Promise.allSettled([...running]);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** An observable whose values are copies, so that a client cannot change the data of the provider. */
|
|
105
|
+
function detached(source) {
|
|
106
|
+
return {
|
|
107
|
+
getSnapshot: () => structuredClone(source.getSnapshot()),
|
|
108
|
+
subscribe: (listener) => source.subscribe((value) => listener(structuredClone(value)))
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** Check a result against the output schema or the failure schemas. A result that does not agree is a defect. */
|
|
112
|
+
async function checkResult(capability, result) {
|
|
113
|
+
if (result.ok) {
|
|
114
|
+
if (capability.output === void 0) return {
|
|
115
|
+
ok: true,
|
|
116
|
+
value: result.value ?? null
|
|
117
|
+
};
|
|
118
|
+
const checked = await capability.output["~standard"].validate(result.value);
|
|
119
|
+
if (checked.issues !== void 0) throw new Error(`The output does not agree with its schema: ${checked.issues[0]?.message}`);
|
|
120
|
+
return {
|
|
121
|
+
ok: true,
|
|
122
|
+
value: checked.value
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const tag = result.error._tag;
|
|
126
|
+
const schema = tag === void 0 ? void 0 : capability.failures?.[tag];
|
|
127
|
+
if (schema === void 0) throw new Error(`The failure ${String(tag)} is not in failures.`);
|
|
128
|
+
const checked = await schema["~standard"].validate(result.error);
|
|
129
|
+
if (checked.issues !== void 0) throw new Error(`The failure ${tag} does not agree with its schema: ${checked.issues[0]?.message}`);
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
error: {
|
|
133
|
+
_tag: tag,
|
|
134
|
+
...checked.value
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
//#endregion
|
|
139
|
+
export { createSession };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Standard Schema interface (https://standardschema.dev), version 1. Zod, Valibot, ArkType and other libraries
|
|
3
|
+
* implement it. The interface is copied here, as its authors recommend, so that this package has no dependency.
|
|
4
|
+
*/
|
|
5
|
+
export interface StandardSchemaV1<Input = unknown, Output = Input> {
|
|
6
|
+
readonly "~standard": StandardSchemaV1.Props<Input, Output>;
|
|
7
|
+
}
|
|
8
|
+
export declare namespace StandardSchemaV1 {
|
|
9
|
+
interface Props<Input = unknown, Output = Input> {
|
|
10
|
+
readonly version: 1;
|
|
11
|
+
readonly vendor: string;
|
|
12
|
+
readonly validate: (value: unknown) => Result<Output> | Promise<Result<Output>>;
|
|
13
|
+
readonly types?: Types<Input, Output> | undefined;
|
|
14
|
+
}
|
|
15
|
+
type Result<Output> = SuccessResult<Output> | FailureResult;
|
|
16
|
+
interface SuccessResult<Output> {
|
|
17
|
+
readonly value: Output;
|
|
18
|
+
readonly issues?: undefined;
|
|
19
|
+
}
|
|
20
|
+
interface FailureResult {
|
|
21
|
+
readonly issues: ReadonlyArray<Issue>;
|
|
22
|
+
}
|
|
23
|
+
interface Issue {
|
|
24
|
+
readonly message: string;
|
|
25
|
+
readonly path?: ReadonlyArray<PropertyKey | PathSegment> | undefined;
|
|
26
|
+
}
|
|
27
|
+
interface PathSegment {
|
|
28
|
+
readonly key: PropertyKey;
|
|
29
|
+
}
|
|
30
|
+
interface Types<Input = unknown, Output = Input> {
|
|
31
|
+
readonly input: Input;
|
|
32
|
+
readonly output: Output;
|
|
33
|
+
}
|
|
34
|
+
type InferInput<Schema extends StandardSchemaV1> = NonNullable<Schema["~standard"]["types"]>["input"];
|
|
35
|
+
type InferOutput<Schema extends StandardSchemaV1> = NonNullable<Schema["~standard"]["types"]>["output"];
|
|
36
|
+
}
|
package/dist/webmcp.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Observable } from "./app.js";
|
|
2
|
+
import type { Session } from "./session.js";
|
|
3
|
+
export interface ModelContextTool {
|
|
4
|
+
readonly name: string;
|
|
5
|
+
readonly description: string;
|
|
6
|
+
readonly inputSchema: object;
|
|
7
|
+
readonly execute: (input: unknown, options?: {
|
|
8
|
+
readonly signal?: AbortSignal;
|
|
9
|
+
}) => Promise<unknown>;
|
|
10
|
+
}
|
|
11
|
+
export interface ModelContext {
|
|
12
|
+
readonly registerTool: (tool: ModelContextTool, options: {
|
|
13
|
+
readonly signal: AbortSignal;
|
|
14
|
+
}) => unknown;
|
|
15
|
+
}
|
|
16
|
+
export type Phase = "loading" | "reconciling" | "ready" | "failed" | "closing" | "disposed";
|
|
17
|
+
export interface Status {
|
|
18
|
+
readonly phase: Phase;
|
|
19
|
+
readonly message: string | null;
|
|
20
|
+
}
|
|
21
|
+
export interface Installation {
|
|
22
|
+
readonly status: Observable<Status>;
|
|
23
|
+
/** Try the last failed update again. */
|
|
24
|
+
retry(): void;
|
|
25
|
+
/** Remove all tools, and wait for the calls in progress. */
|
|
26
|
+
dispose(): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
/** The WebMCP host of the page, if there is one (spec/bindings/webmcp.md#host). */
|
|
29
|
+
export declare function findHost(): ModelContext | undefined;
|
|
30
|
+
/** Register the capabilities of a session as tools. If there is no host, do nothing and return `undefined`. */
|
|
31
|
+
export declare function installWebMcp(session: Session, options?: {
|
|
32
|
+
readonly host?: ModelContext | undefined;
|
|
33
|
+
}): Installation | undefined;
|
package/dist/webmcp.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { CallFailureError, bundle, isCallFailure } from "@acid-cat/spec-ts";
|
|
2
|
+
//#region src/webmcp.ts
|
|
3
|
+
/**
|
|
4
|
+
* The WebMCP binding (spec/bindings/webmcp.md): register capabilities as WebMCP tools in a web page.
|
|
5
|
+
*/
|
|
6
|
+
/** The WebMCP host of the page, if there is one (spec/bindings/webmcp.md#host). */
|
|
7
|
+
function findHost() {
|
|
8
|
+
const scope = globalThis;
|
|
9
|
+
for (const candidate of [scope.document?.modelContext, scope.navigator?.modelContext]) if (typeof candidate?.registerTool === "function") return candidate;
|
|
10
|
+
}
|
|
11
|
+
/** Register the capabilities of a session as tools. If there is no host, do nothing and return `undefined`. */
|
|
12
|
+
function installWebMcp(session, options = {}) {
|
|
13
|
+
const host = options.host ?? findHost();
|
|
14
|
+
if (host === void 0) return void 0;
|
|
15
|
+
let status = {
|
|
16
|
+
phase: "loading",
|
|
17
|
+
message: null
|
|
18
|
+
};
|
|
19
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
20
|
+
const setStatus = (next) => {
|
|
21
|
+
status = next;
|
|
22
|
+
listeners.forEach((listener) => listener(next));
|
|
23
|
+
};
|
|
24
|
+
const lifetime = new AbortController();
|
|
25
|
+
const registered = /* @__PURE__ */ new Map();
|
|
26
|
+
const calls = /* @__PURE__ */ new Set();
|
|
27
|
+
const types = session.view.$defs ?? {};
|
|
28
|
+
let running = false;
|
|
29
|
+
let dirty = false;
|
|
30
|
+
const toolFor = (id) => {
|
|
31
|
+
const capability = session.view.capabilities[id];
|
|
32
|
+
return {
|
|
33
|
+
name: id,
|
|
34
|
+
description: capability.description,
|
|
35
|
+
inputSchema: bundle(capability.input ?? { type: "object" }, types),
|
|
36
|
+
execute: (input, callOptions) => {
|
|
37
|
+
const signal = AbortSignal.any([lifetime.signal, ...callOptions?.signal ? [callOptions.signal] : []]);
|
|
38
|
+
const call = session.invoke(id, input, { signal }).then((outcome) => {
|
|
39
|
+
if (isCallFailure(outcome)) throw new CallFailureError(outcome);
|
|
40
|
+
return outcome;
|
|
41
|
+
});
|
|
42
|
+
calls.add(call);
|
|
43
|
+
call.finally(() => calls.delete(call)).catch(() => void 0);
|
|
44
|
+
return call;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
/** One update. If it fails, the tools stay as they were before the update. */
|
|
49
|
+
async function pass() {
|
|
50
|
+
const wanted = new Set([...session.handles.values()].filter((handle) => handle.availability.getSnapshot().available).map((handle) => handle.id));
|
|
51
|
+
const added = /* @__PURE__ */ new Map();
|
|
52
|
+
try {
|
|
53
|
+
for (const id of wanted) {
|
|
54
|
+
if (registered.has(id)) continue;
|
|
55
|
+
const controller = new AbortController();
|
|
56
|
+
added.set(id, controller);
|
|
57
|
+
await host.registerTool(toolFor(id), { signal: AbortSignal.any([controller.signal, lifetime.signal]) });
|
|
58
|
+
}
|
|
59
|
+
} catch (error) {
|
|
60
|
+
for (const controller of added.values()) controller.abort();
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
for (const [id, controller] of added) registered.set(id, controller);
|
|
64
|
+
for (const [id, controller] of registered) if (!wanted.has(id)) {
|
|
65
|
+
controller.abort();
|
|
66
|
+
registered.delete(id);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Run updates one at a time. A request during an update causes one more update. */
|
|
70
|
+
async function reconcile() {
|
|
71
|
+
if (lifetime.signal.aborted) return;
|
|
72
|
+
if (running) {
|
|
73
|
+
dirty = true;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
running = true;
|
|
77
|
+
try {
|
|
78
|
+
do {
|
|
79
|
+
dirty = false;
|
|
80
|
+
setStatus({
|
|
81
|
+
phase: "reconciling",
|
|
82
|
+
message: null
|
|
83
|
+
});
|
|
84
|
+
try {
|
|
85
|
+
await pass();
|
|
86
|
+
setStatus({
|
|
87
|
+
phase: "ready",
|
|
88
|
+
message: null
|
|
89
|
+
});
|
|
90
|
+
} catch (error) {
|
|
91
|
+
setStatus({
|
|
92
|
+
phase: "failed",
|
|
93
|
+
message: error instanceof Error ? error.message : "A tool could not be registered."
|
|
94
|
+
});
|
|
95
|
+
dirty = false;
|
|
96
|
+
}
|
|
97
|
+
} while (dirty && !lifetime.signal.aborted);
|
|
98
|
+
} finally {
|
|
99
|
+
running = false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const subscription = session.onChange(() => void reconcile());
|
|
103
|
+
reconcile();
|
|
104
|
+
return {
|
|
105
|
+
status: {
|
|
106
|
+
getSnapshot: () => status,
|
|
107
|
+
subscribe(listener) {
|
|
108
|
+
listeners.add(listener);
|
|
109
|
+
return { unsubscribe: () => listeners.delete(listener) };
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
retry: () => void reconcile(),
|
|
113
|
+
async dispose() {
|
|
114
|
+
setStatus({
|
|
115
|
+
phase: "closing",
|
|
116
|
+
message: null
|
|
117
|
+
});
|
|
118
|
+
subscription.unsubscribe();
|
|
119
|
+
lifetime.abort();
|
|
120
|
+
registered.clear();
|
|
121
|
+
await Promise.allSettled([...calls]);
|
|
122
|
+
setStatus({
|
|
123
|
+
phase: "disposed",
|
|
124
|
+
message: null
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
//#endregion
|
|
130
|
+
export { findHost, installWebMcp };
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acid-cat/core-ts",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Serve AcidCat capabilities in plain TypeScript, with no Effect dependency.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"acidcat",
|
|
7
|
+
"mcp",
|
|
8
|
+
"webmcp"
|
|
9
|
+
],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/Hebilicious/acidcat.git",
|
|
14
|
+
"directory": "packages/core-ts"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/Hebilicious/acidcat/tree/main/packages/core-ts#readme",
|
|
17
|
+
"bugs": "https://github.com/Hebilicious/acidcat/issues",
|
|
18
|
+
"type": "module",
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=24"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@acid-cat/catalog": "0.2.0",
|
|
36
|
+
"@acid-cat/spec-ts": "0.2.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@acid-cat/cli": "0.2.0",
|
|
40
|
+
"@types/node": "26.6.5",
|
|
41
|
+
"esbuild": "0.28.2",
|
|
42
|
+
"playwright": "1.64.0",
|
|
43
|
+
"typescript": "7.0.2",
|
|
44
|
+
"zod": "4.6.7"
|
|
45
|
+
}
|
|
6
46
|
}
|