@acid-cat/core-ts 0.0.0-stage → 0.1.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 +67 -2
- package/dist/app.d.ts +85 -0
- package/dist/cli.d.ts +18 -0
- package/dist/http.d.ts +17 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +610 -0
- package/dist/mcp.d.ts +15 -0
- package/dist/session.d.ts +47 -0
- package/dist/standard-schema.d.ts +36 -0
- package/dist/webmcp.d.ts +33 -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,68 @@
|
|
|
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
|
+
## Examples and tests
|
|
64
|
+
|
|
65
|
+
- [`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.
|
|
66
|
+
- [`examples/aircon-cli.ts`](examples/aircon-cli.ts) is its command line: `node examples/aircon-cli.ts aircon power set --on`.
|
|
67
|
+
- [`examples/apis`](../../examples/apis/README.md) builds GraphQL, Cap'n Web and Elysia APIs on it, each beside the HTTP binding.
|
|
68
|
+
- `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/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/http.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { App, ToJsonSchema } from "./app.js";
|
|
2
|
+
export interface HttpOptions<Context> {
|
|
3
|
+
/** The origins that can call the API. A request with no `Origin` header is accepted. */
|
|
4
|
+
readonly allowedOrigins: readonly string[];
|
|
5
|
+
readonly context: Context;
|
|
6
|
+
readonly toJsonSchema: ToJsonSchema;
|
|
7
|
+
/** The target that the API serves. The default is `"api"`. */
|
|
8
|
+
readonly target?: string | undefined;
|
|
9
|
+
/** The path before `/capabilities` and `/catalog`, for example `"/api"`. The default is `""`. */
|
|
10
|
+
readonly base?: string | undefined;
|
|
11
|
+
/** Serve `GET {base}/catalog`. The default is `true`. */
|
|
12
|
+
readonly serveCatalog?: boolean | 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 API. Each request has its own session. */
|
|
17
|
+
export declare function httpHandler<Context>(app: App<Context>, options: HttpOptions<Context>): (request: Request) => Promise<Response>;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
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 } from "./mcp.js";
|
|
7
|
+
export { httpHandler } from "./http.js";
|
|
8
|
+
export type { HttpOptions } from "./http.js";
|
|
9
|
+
export { main as cliMain, runCli } from "./cli.js";
|
|
10
|
+
export type { CliIo, CliOptions } from "./cli.js";
|
|
11
|
+
export { findHost, installWebMcp } from "./webmcp.js";
|
|
12
|
+
export type { Installation, ModelContext, ModelContextTool, Phase, Status } from "./webmcp.js";
|
|
13
|
+
export type { StandardSchemaV1 } from "./standard-schema.js";
|
|
14
|
+
export { AVAILABLE, CallFailureError, fail, ok, unavailable } from "@acid-cat/spec-ts";
|
|
15
|
+
export type { Availability, CallFailure, CallFailureKind, Outcome, Result } from "@acid-cat/spec-ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
// src/app.ts
|
|
2
|
+
import { canonicalText } from "@acid-cat/catalog";
|
|
3
|
+
var defineCapability = (capability) => capability;
|
|
4
|
+
var capabilitiesFor = () => (capability) => capability;
|
|
5
|
+
var defineApp = (app) => app;
|
|
6
|
+
function catalogOf(app, options) {
|
|
7
|
+
const types = {};
|
|
8
|
+
const toJson = (schema, where) => {
|
|
9
|
+
const { $schema: _, $defs, ...json2 } = options.toJsonSchema(schema);
|
|
10
|
+
for (const [name, definition] of Object.entries($defs ?? {})) {
|
|
11
|
+
const existing = types[name];
|
|
12
|
+
if (existing !== void 0 && JSON.stringify(existing) !== JSON.stringify(definition)) {
|
|
13
|
+
throw new Error(`${where}: two different schemas have the name ${name}. Give each one its own name.`);
|
|
14
|
+
}
|
|
15
|
+
types[name] = definition;
|
|
16
|
+
}
|
|
17
|
+
return json2;
|
|
18
|
+
};
|
|
19
|
+
const capabilities = {};
|
|
20
|
+
for (const capability of app.capabilities) {
|
|
21
|
+
if (capability.id in capabilities) throw new Error(`The capability ${capability.id} is declared two times.`);
|
|
22
|
+
const failures = Object.fromEntries(
|
|
23
|
+
Object.entries(capability.failures ?? {}).map(([tag, schema]) => [tag, toJson(schema, capability.id)])
|
|
24
|
+
);
|
|
25
|
+
capabilities[capability.id] = {
|
|
26
|
+
description: capability.description,
|
|
27
|
+
audience: capability.audience,
|
|
28
|
+
...capability.input === void 0 ? {} : { input: toJson(capability.input, capability.id) },
|
|
29
|
+
...capability.output === void 0 ? {} : { output: toJson(capability.output, capability.id) },
|
|
30
|
+
...Object.keys(failures).length === 0 ? {} : { failures },
|
|
31
|
+
...capability.readOnly === true ? { readOnly: true } : {},
|
|
32
|
+
...capability.state === void 0 ? {} : { state: toJson(capability.state.schema, capability.id) }
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
acidcat: "0.1",
|
|
37
|
+
...app.title === void 0 ? {} : { title: app.title },
|
|
38
|
+
...app.description === void 0 ? {} : { description: app.description },
|
|
39
|
+
targets: Object.fromEntries(
|
|
40
|
+
Object.entries(app.targets).map(([name, target]) => [
|
|
41
|
+
name,
|
|
42
|
+
{
|
|
43
|
+
bindings: target.bindings,
|
|
44
|
+
...target.description === void 0 ? {} : { description: target.description },
|
|
45
|
+
...target.options === void 0 ? {} : { options: target.options }
|
|
46
|
+
}
|
|
47
|
+
])
|
|
48
|
+
),
|
|
49
|
+
...Object.keys(types).length === 0 ? {} : { $defs: types },
|
|
50
|
+
capabilities
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
var renderCatalog = (app, options) => canonicalText(catalogOf(app, options));
|
|
54
|
+
|
|
55
|
+
// src/session.ts
|
|
56
|
+
import { AVAILABLE, CallFailureError, callFailure, isCallFailure, viewOf } from "@acid-cat/spec-ts";
|
|
57
|
+
var always = { getSnapshot: () => AVAILABLE, subscribe: () => ({ unsubscribe() {
|
|
58
|
+
} }) };
|
|
59
|
+
function createSession(app, options) {
|
|
60
|
+
const view = viewOf(catalogOf(app, { toJsonSchema: options.toJsonSchema }), options.target);
|
|
61
|
+
const lifetime = new AbortController();
|
|
62
|
+
const running = /* @__PURE__ */ new Set();
|
|
63
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
64
|
+
const subscriptions = [];
|
|
65
|
+
const onDefect = options.onDefect ?? ((id, cause) => console.error(`A capability failed with a defect: ${id}`, cause));
|
|
66
|
+
const served = /* @__PURE__ */ new Map();
|
|
67
|
+
for (const capability of app.capabilities) {
|
|
68
|
+
if (!(capability.id in view.capabilities)) continue;
|
|
69
|
+
const availability = capability.availability?.(options.context) ?? always;
|
|
70
|
+
subscriptions.push(availability.subscribe(() => listeners.forEach((listener) => listener())));
|
|
71
|
+
served.set(capability.id, { capability, availability });
|
|
72
|
+
}
|
|
73
|
+
async function call(id, input, trusted, signal) {
|
|
74
|
+
if (lifetime.signal.aborted || signal?.aborted) return callFailure("cancelled", "The call was cancelled.");
|
|
75
|
+
const entry = served.get(id);
|
|
76
|
+
if (entry === void 0) return callFailure("unknown-capability", `There is no capability ${id}.`);
|
|
77
|
+
const availability = entry.availability.getSnapshot();
|
|
78
|
+
if (!availability.available) return callFailure("unavailable", availability.reason);
|
|
79
|
+
const { capability } = entry;
|
|
80
|
+
let decoded = input ?? {};
|
|
81
|
+
if (!trusted && capability.input !== void 0) {
|
|
82
|
+
const validated = await capability.input["~standard"].validate(input);
|
|
83
|
+
if (validated.issues !== void 0) {
|
|
84
|
+
const message = validated.issues.map((issue) => `${(issue.path ?? []).map((key) => typeof key === "object" ? String(key.key) : String(key)).join(".") || "input"}: ${issue.message}`).join("; ");
|
|
85
|
+
return callFailure("invalid-input", message);
|
|
86
|
+
}
|
|
87
|
+
decoded = validated.value;
|
|
88
|
+
}
|
|
89
|
+
const controller = new AbortController();
|
|
90
|
+
const abort = () => controller.abort();
|
|
91
|
+
lifetime.signal.addEventListener("abort", abort);
|
|
92
|
+
signal?.addEventListener("abort", abort);
|
|
93
|
+
const cancelled = new Promise(
|
|
94
|
+
(resolve2) => controller.signal.addEventListener("abort", () => resolve2(callFailure("cancelled", "The call was cancelled.")))
|
|
95
|
+
);
|
|
96
|
+
if (lifetime.signal.aborted || signal?.aborted) controller.abort();
|
|
97
|
+
const work = Promise.resolve().then(() => capability.run(decoded, { signal: controller.signal, context: options.context })).then((result) => checkResult(capability, result)).catch((cause) => {
|
|
98
|
+
onDefect(id, cause);
|
|
99
|
+
return callFailure("internal", "The capability failed.");
|
|
100
|
+
});
|
|
101
|
+
const outcome = Promise.race([work, cancelled]);
|
|
102
|
+
running.add(outcome);
|
|
103
|
+
try {
|
|
104
|
+
return await outcome;
|
|
105
|
+
} finally {
|
|
106
|
+
running.delete(outcome);
|
|
107
|
+
lifetime.signal.removeEventListener("abort", abort);
|
|
108
|
+
signal?.removeEventListener("abort", abort);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const handles = /* @__PURE__ */ new Map();
|
|
112
|
+
for (const [id, { capability, availability }] of served) {
|
|
113
|
+
const reject = async (outcome) => {
|
|
114
|
+
const settled = await outcome;
|
|
115
|
+
if (isCallFailure(settled)) throw new CallFailureError(settled);
|
|
116
|
+
return settled;
|
|
117
|
+
};
|
|
118
|
+
const state = capability.state?.source(options.context);
|
|
119
|
+
handles.set(id, {
|
|
120
|
+
id,
|
|
121
|
+
description: capability.description,
|
|
122
|
+
audience: capability.audience,
|
|
123
|
+
availability,
|
|
124
|
+
...state === void 0 ? {} : { snapshot: detached(state) },
|
|
125
|
+
execute: (input, callOptions) => reject(call(id, input, true, callOptions?.signal)),
|
|
126
|
+
invokeExternal: (input, callOptions) => reject(call(id, input, false, callOptions?.signal))
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
target: options.target,
|
|
131
|
+
view,
|
|
132
|
+
handles,
|
|
133
|
+
signal: lifetime.signal,
|
|
134
|
+
invoke: (id, input, callOptions) => call(id, input, false, callOptions?.signal),
|
|
135
|
+
onChange(listener) {
|
|
136
|
+
listeners.add(listener);
|
|
137
|
+
return { unsubscribe: () => listeners.delete(listener) };
|
|
138
|
+
},
|
|
139
|
+
async dispose() {
|
|
140
|
+
lifetime.abort();
|
|
141
|
+
subscriptions.forEach((subscription) => subscription.unsubscribe());
|
|
142
|
+
await Promise.allSettled([...running]);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function detached(source) {
|
|
147
|
+
return {
|
|
148
|
+
getSnapshot: () => structuredClone(source.getSnapshot()),
|
|
149
|
+
subscribe: (listener) => source.subscribe((value) => listener(structuredClone(value)))
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
async function checkResult(capability, result) {
|
|
153
|
+
if (result.ok) {
|
|
154
|
+
if (capability.output === void 0) return { ok: true, value: result.value ?? null };
|
|
155
|
+
const checked2 = await capability.output["~standard"].validate(result.value);
|
|
156
|
+
if (checked2.issues !== void 0) throw new Error(`The output does not agree with its schema: ${checked2.issues[0]?.message}`);
|
|
157
|
+
return { ok: true, value: checked2.value };
|
|
158
|
+
}
|
|
159
|
+
const tag = result.error._tag;
|
|
160
|
+
const schema = tag === void 0 ? void 0 : capability.failures?.[tag];
|
|
161
|
+
if (schema === void 0) throw new Error(`The failure ${String(tag)} is not in failures.`);
|
|
162
|
+
const checked = await schema["~standard"].validate(result.error);
|
|
163
|
+
if (checked.issues !== void 0) throw new Error(`The failure ${tag} does not agree with its schema: ${checked.issues[0]?.message}`);
|
|
164
|
+
return { ok: false, error: { _tag: tag, ...checked.value } };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// src/mcp.ts
|
|
168
|
+
import { bundle, MCP_PROTOCOL_VERSION, MCP_REJECTIONS, MCP_UNKNOWN_TOOL, mcpToolResult, readBounded, screenRequest } from "@acid-cat/spec-ts";
|
|
169
|
+
function mcpHandler(app, options) {
|
|
170
|
+
const maximumBytes = options.maximumBytes ?? 1048576;
|
|
171
|
+
return async (request) => {
|
|
172
|
+
const screened = screenRequest(request, {
|
|
173
|
+
methods: ["POST"],
|
|
174
|
+
allowedOrigins: options.allowedOrigins,
|
|
175
|
+
mediaType: "application/json",
|
|
176
|
+
maximumBytes
|
|
177
|
+
});
|
|
178
|
+
if (screened !== void 0) return rejection(screened);
|
|
179
|
+
const body = await readBounded(request, maximumBytes);
|
|
180
|
+
if ("rejection" in body) return rejection(body.rejection);
|
|
181
|
+
let message;
|
|
182
|
+
try {
|
|
183
|
+
message = JSON.parse(body.text);
|
|
184
|
+
} catch {
|
|
185
|
+
return rejection("not-utf8");
|
|
186
|
+
}
|
|
187
|
+
const version = request.headers.get("MCP-Protocol-Version");
|
|
188
|
+
const id = message.id ?? null;
|
|
189
|
+
if (version === null) return error(400, id, -32020, "MCP-Protocol-Version header is required");
|
|
190
|
+
if (version !== MCP_PROTOCOL_VERSION) {
|
|
191
|
+
return error(400, id, -32022, `Unsupported protocol version ${version}`, { supported: [MCP_PROTOCOL_VERSION] });
|
|
192
|
+
}
|
|
193
|
+
if (message.id === void 0) return new Response(null, { status: 200, headers: { "Content-Type": "application/json" } });
|
|
194
|
+
const metaVersion = message.params?._meta?.["io.modelcontextprotocol/protocolVersion"];
|
|
195
|
+
if (metaVersion === void 0) return error(400, id, -32602, "Required request metadata is missing");
|
|
196
|
+
if (metaVersion !== version) return error(400, id, -32020, "MCP-Protocol-Version header does not match request metadata");
|
|
197
|
+
const session = createSession(app, {
|
|
198
|
+
target: options.target ?? "mcp",
|
|
199
|
+
context: options.context,
|
|
200
|
+
toJsonSchema: options.toJsonSchema
|
|
201
|
+
});
|
|
202
|
+
try {
|
|
203
|
+
return await answer(session, message, options, request.signal);
|
|
204
|
+
} finally {
|
|
205
|
+
await session.dispose();
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
async function answer(session, message, options, signal) {
|
|
210
|
+
const id = message.id ?? null;
|
|
211
|
+
const complete = (result, list = false) => reply(id, {
|
|
212
|
+
_meta: { "io.modelcontextprotocol/serverInfo": { name: options.name, version: options.version } },
|
|
213
|
+
resultType: "complete",
|
|
214
|
+
...list ? { ttlMs: 0, cacheScope: "private" } : {},
|
|
215
|
+
...result
|
|
216
|
+
});
|
|
217
|
+
switch (message.method) {
|
|
218
|
+
case "server/discover":
|
|
219
|
+
return complete({ supportedVersions: [MCP_PROTOCOL_VERSION], capabilities: { tools: { listChanged: false } } }, true);
|
|
220
|
+
case "tools/list":
|
|
221
|
+
return complete({ tools: listTools(session) }, true);
|
|
222
|
+
case "tools/call": {
|
|
223
|
+
const name = message.params?.name ?? "";
|
|
224
|
+
const handle = session.handles.get(name);
|
|
225
|
+
if (handle === void 0 || !handle.availability.getSnapshot().available) {
|
|
226
|
+
return error(200, id, MCP_UNKNOWN_TOOL, `Tool '${name}' not found`);
|
|
227
|
+
}
|
|
228
|
+
const outcome = await session.invoke(name, message.params?.arguments ?? {}, { signal });
|
|
229
|
+
return complete(mcpToolResult(outcome, hasObjectOutput(session, name)));
|
|
230
|
+
}
|
|
231
|
+
default:
|
|
232
|
+
return error(404, id, -32601, `Method not found: ${message.method}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function listTools(session) {
|
|
236
|
+
const types = session.view.$defs ?? {};
|
|
237
|
+
return [...session.handles.values()].filter((handle) => handle.availability.getSnapshot().available).map((handle) => {
|
|
238
|
+
const capability = session.view.capabilities[handle.id];
|
|
239
|
+
const output = capability.output === void 0 ? void 0 : bundle(capability.output, types);
|
|
240
|
+
return {
|
|
241
|
+
name: handle.id,
|
|
242
|
+
description: capability.description,
|
|
243
|
+
inputSchema: bundle(capability.input ?? { type: "object" }, types),
|
|
244
|
+
...output?.type === "object" ? { outputSchema: output } : {},
|
|
245
|
+
annotations: { readOnlyHint: capability.readOnly === true }
|
|
246
|
+
};
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
function hasObjectOutput(session, id) {
|
|
250
|
+
const output = session.view.capabilities[id]?.output;
|
|
251
|
+
return output !== void 0 && bundle(output, session.view.$defs ?? {}).type === "object";
|
|
252
|
+
}
|
|
253
|
+
function reply(id, result) {
|
|
254
|
+
return new Response(JSON.stringify({ jsonrpc: "2.0", id, result }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
255
|
+
}
|
|
256
|
+
function error(status, id, code, message, data) {
|
|
257
|
+
const body = { jsonrpc: "2.0", id, error: { code, message, ...data === void 0 ? {} : { data } } };
|
|
258
|
+
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
|
259
|
+
}
|
|
260
|
+
function rejection(reason) {
|
|
261
|
+
const { status, code } = MCP_REJECTIONS[reason];
|
|
262
|
+
const headers = new Headers({ "Cache-Control": "no-store" });
|
|
263
|
+
if (reason === "method") headers.set("Allow", "POST");
|
|
264
|
+
if (code === void 0) return new Response(null, { status, headers });
|
|
265
|
+
headers.set("Content-Type", "application/json");
|
|
266
|
+
return new Response(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code, message: messages[reason] } }), { status, headers });
|
|
267
|
+
}
|
|
268
|
+
var messages = {
|
|
269
|
+
method: "Use POST.",
|
|
270
|
+
origin: "This origin is not allowed.",
|
|
271
|
+
"media-type": "Use application/json.",
|
|
272
|
+
"too-large": "The request body is too large.",
|
|
273
|
+
"not-utf8": "The request body is not UTF-8 JSON."
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// src/http.ts
|
|
277
|
+
import { callFailure as callFailure2, httpStatus, isCallFailure as isCallFailure2, readBounded as readBounded2, screenRequest as screenRequest2 } from "@acid-cat/spec-ts";
|
|
278
|
+
function httpHandler(app, options) {
|
|
279
|
+
const maximumBytes = options.maximumBytes ?? 1048576;
|
|
280
|
+
return async (request) => {
|
|
281
|
+
const screened = screenRequest2(request, {
|
|
282
|
+
methods: ["GET", "POST"],
|
|
283
|
+
allowedOrigins: options.allowedOrigins,
|
|
284
|
+
mediaType: "application/json",
|
|
285
|
+
maximumBytes
|
|
286
|
+
});
|
|
287
|
+
if (screened !== void 0) return rejection2(screened);
|
|
288
|
+
const path = new URL(request.url).pathname.slice((options.base ?? "").length);
|
|
289
|
+
const session = createSession(app, { target: options.target ?? "api", context: options.context, toJsonSchema: options.toJsonSchema });
|
|
290
|
+
try {
|
|
291
|
+
if (request.method === "GET" && path === "/capabilities") {
|
|
292
|
+
return json(
|
|
293
|
+
200,
|
|
294
|
+
[...session.handles.values()].map((handle) => ({ id: handle.id, ...handle.availability.getSnapshot() }))
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
if (request.method === "GET" && path === "/catalog" && options.serveCatalog !== false) return json(200, session.view);
|
|
298
|
+
const id = request.method === "POST" && path.startsWith("/capabilities/") ? path.slice("/capabilities/".length) : void 0;
|
|
299
|
+
if (id === void 0) return json(404, callFailure2("unknown-capability", "There is no such route."));
|
|
300
|
+
const body = await readBounded2(request, maximumBytes);
|
|
301
|
+
if ("rejection" in body) return rejection2(body.rejection);
|
|
302
|
+
let input;
|
|
303
|
+
try {
|
|
304
|
+
input = body.text === "" ? {} : JSON.parse(body.text);
|
|
305
|
+
} catch {
|
|
306
|
+
return json(422, callFailure2("invalid-input", "The request body is not JSON."));
|
|
307
|
+
}
|
|
308
|
+
const outcome = await session.invoke(decodeURIComponent(id), input, { signal: request.signal });
|
|
309
|
+
return json(httpStatus(outcome), isCallFailure2(outcome) ? { kind: outcome.kind, message: outcome.message } : outcome);
|
|
310
|
+
} finally {
|
|
311
|
+
await session.dispose();
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
var json = (status, body) => new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
|
316
|
+
var statuses = {
|
|
317
|
+
method: 405,
|
|
318
|
+
origin: 403,
|
|
319
|
+
"media-type": 415,
|
|
320
|
+
"too-large": 413,
|
|
321
|
+
"not-utf8": 400
|
|
322
|
+
};
|
|
323
|
+
function rejection2(reason) {
|
|
324
|
+
const headers = new Headers({ "Cache-Control": "no-store" });
|
|
325
|
+
if (reason === "method") headers.set("Allow", "GET, POST");
|
|
326
|
+
return new Response(null, { status: statuses[reason], headers });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// src/cli.ts
|
|
330
|
+
import { bundle as bundle2, CLI_EXIT_CODES, cliExitCode, commandPath, isCallFailure as isCallFailure3, REFERENCE } from "@acid-cat/spec-ts";
|
|
331
|
+
var UsageError = class extends Error {
|
|
332
|
+
exitCode;
|
|
333
|
+
constructor(message, exitCode) {
|
|
334
|
+
super(message);
|
|
335
|
+
this.exitCode = exitCode;
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
function main(app, options) {
|
|
339
|
+
const interrupt = new AbortController();
|
|
340
|
+
process.once("SIGINT", () => interrupt.abort());
|
|
341
|
+
void runCli(app, options, process.argv.slice(2), {
|
|
342
|
+
stdout: (text) => process.stdout.write(`${text}
|
|
343
|
+
`),
|
|
344
|
+
stderr: (text) => process.stderr.write(`${text}
|
|
345
|
+
`),
|
|
346
|
+
signal: interrupt.signal
|
|
347
|
+
}).then((code) => {
|
|
348
|
+
process.exitCode = code;
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
async function runCli(app, options, args, io) {
|
|
352
|
+
const session = createSession(app, { target: options.target ?? "cli", context: options.context, toJsonSchema: options.toJsonSchema });
|
|
353
|
+
const types = session.view.$defs ?? {};
|
|
354
|
+
const commands = Object.keys(session.view.capabilities).map((id) => ({ id, path: commandPath(id) }));
|
|
355
|
+
for (const { path } of commands) {
|
|
356
|
+
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.`);
|
|
357
|
+
}
|
|
358
|
+
const print = (outcome) => {
|
|
359
|
+
if (isCallFailure3(outcome)) io.stderr(JSON.stringify({ kind: outcome.kind, message: outcome.message }));
|
|
360
|
+
else io.stdout(JSON.stringify(outcome.ok ? outcome.value : outcome.error, null, 2));
|
|
361
|
+
return cliExitCode(outcome);
|
|
362
|
+
};
|
|
363
|
+
try {
|
|
364
|
+
const [first, ...rest] = args;
|
|
365
|
+
if (first === void 0 || first === "--help" || first === "-h") {
|
|
366
|
+
io.stdout(help(options.name, commands.map(({ path }) => path.join(" "))));
|
|
367
|
+
return CLI_EXIT_CODES.value;
|
|
368
|
+
}
|
|
369
|
+
if (first === "--version" || first === "-v") {
|
|
370
|
+
io.stdout(options.version);
|
|
371
|
+
return CLI_EXIT_CODES.value;
|
|
372
|
+
}
|
|
373
|
+
if (first === "list") {
|
|
374
|
+
io.stdout(
|
|
375
|
+
JSON.stringify(
|
|
376
|
+
[...session.handles.values()].map((handle) => ({
|
|
377
|
+
id: handle.id,
|
|
378
|
+
description: handle.description,
|
|
379
|
+
...handle.availability.getSnapshot(),
|
|
380
|
+
usage: `${options.name} ${commandPath(handle.id).join(" ")}`
|
|
381
|
+
})),
|
|
382
|
+
null,
|
|
383
|
+
2
|
|
384
|
+
)
|
|
385
|
+
);
|
|
386
|
+
return CLI_EXIT_CODES.value;
|
|
387
|
+
}
|
|
388
|
+
if (first === "call") {
|
|
389
|
+
const [id, text = "{}"] = rest;
|
|
390
|
+
if (id === void 0) throw new UsageError("Give the id of a capability.", CLI_EXIT_CODES["invalid-input"]);
|
|
391
|
+
let input;
|
|
392
|
+
try {
|
|
393
|
+
input = JSON.parse(text);
|
|
394
|
+
} catch {
|
|
395
|
+
return print({ kind: "invalid-input", message: "The input is not JSON." });
|
|
396
|
+
}
|
|
397
|
+
return print(await session.invoke(id, input, { signal: io.signal }));
|
|
398
|
+
}
|
|
399
|
+
const match = commands.filter(({ path }) => path.every((word, index) => args[index] === word)).sort((a, b) => b.path.length - a.path.length)[0];
|
|
400
|
+
if (match === void 0) throw new UsageError(`There is no command ${args.filter((arg) => !arg.startsWith("-")).join(" ")}.`, CLI_EXIT_CODES["unknown-capability"]);
|
|
401
|
+
const capability = session.view.capabilities[match.id];
|
|
402
|
+
const schema = bundle2(capability.input ?? { type: "object" }, types);
|
|
403
|
+
const flagArgs = args.slice(match.path.length);
|
|
404
|
+
if (flagArgs.includes("--help") || flagArgs.includes("-h")) {
|
|
405
|
+
io.stdout(usage(options.name, match.path, capability.description, schema));
|
|
406
|
+
return CLI_EXIT_CODES.value;
|
|
407
|
+
}
|
|
408
|
+
return print(await session.invoke(match.id, parseFlags(flagArgs, schema), { signal: io.signal }));
|
|
409
|
+
} catch (error2) {
|
|
410
|
+
if (!(error2 instanceof UsageError)) throw error2;
|
|
411
|
+
io.stderr(error2.message);
|
|
412
|
+
return error2.exitCode;
|
|
413
|
+
} finally {
|
|
414
|
+
await session.dispose();
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function parseFlags(args, schema) {
|
|
418
|
+
const properties = schema.properties ?? {};
|
|
419
|
+
const definitions = schema.$defs ?? {};
|
|
420
|
+
const input = {};
|
|
421
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
422
|
+
const arg = args[index];
|
|
423
|
+
if (!arg.startsWith("--")) throw new UsageError(`Unexpected argument ${arg}.`, CLI_EXIT_CODES["invalid-input"]);
|
|
424
|
+
const [name, inline] = arg.slice(2).split(/=(.*)/s, 2);
|
|
425
|
+
const property = properties[name];
|
|
426
|
+
if (property === void 0) throw new UsageError(`Unknown flag --${name}.`, CLI_EXIT_CODES["invalid-input"]);
|
|
427
|
+
const type = resolve(property, definitions);
|
|
428
|
+
if (type.type === "boolean") {
|
|
429
|
+
input[name] = inline === void 0 ? true : inline === "true";
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
const text = inline ?? args[index += 1];
|
|
433
|
+
if (text === void 0) throw new UsageError(`The flag --${name} needs a value.`, CLI_EXIT_CODES["invalid-input"]);
|
|
434
|
+
input[name] = valueOf(name, text, type);
|
|
435
|
+
}
|
|
436
|
+
for (const name of schema.required ?? []) {
|
|
437
|
+
if (!(name in input)) throw new UsageError(`The flag --${name} is required.`, CLI_EXIT_CODES["invalid-input"]);
|
|
438
|
+
}
|
|
439
|
+
return input;
|
|
440
|
+
}
|
|
441
|
+
function valueOf(name, text, type) {
|
|
442
|
+
if (Array.isArray(type.enum) && type.enum.every((value) => typeof value === "string")) {
|
|
443
|
+
if (!type.enum.includes(text)) throw new UsageError(`The flag --${name} must be one of: ${type.enum.join(", ")}.`, CLI_EXIT_CODES["invalid-input"]);
|
|
444
|
+
return text;
|
|
445
|
+
}
|
|
446
|
+
if (type.type === "integer" || type.type === "number") {
|
|
447
|
+
const number = Number(text);
|
|
448
|
+
if (text.trim() === "" || !Number.isFinite(number) || type.type === "integer" && !Number.isInteger(number)) {
|
|
449
|
+
throw new UsageError(`The flag --${name} must be a ${type.type}.`, CLI_EXIT_CODES["invalid-input"]);
|
|
450
|
+
}
|
|
451
|
+
return number;
|
|
452
|
+
}
|
|
453
|
+
if (type.type === "string") return text;
|
|
454
|
+
try {
|
|
455
|
+
return JSON.parse(text);
|
|
456
|
+
} catch {
|
|
457
|
+
throw new UsageError(`The flag --${name} must be JSON.`, CLI_EXIT_CODES["invalid-input"]);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
function resolve(property, definitions) {
|
|
461
|
+
const name = typeof property.$ref === "string" ? REFERENCE.exec(property.$ref)?.[1] : void 0;
|
|
462
|
+
return name === void 0 ? property : definitions[name] ?? property;
|
|
463
|
+
}
|
|
464
|
+
function help(name, paths) {
|
|
465
|
+
return [
|
|
466
|
+
`Usage: ${name} <command> [flags]`,
|
|
467
|
+
"",
|
|
468
|
+
"Commands:",
|
|
469
|
+
...paths.map((path) => ` ${path}`),
|
|
470
|
+
" call <id> [input]",
|
|
471
|
+
" list"
|
|
472
|
+
].join("\n");
|
|
473
|
+
}
|
|
474
|
+
function usage(name, path, description, schema) {
|
|
475
|
+
const properties = Object.keys(schema.properties ?? {});
|
|
476
|
+
const required = new Set(schema.required ?? []);
|
|
477
|
+
const flags = properties.map((property) => required.has(property) ? `--${property} <value>` : `[--${property} <value>]`);
|
|
478
|
+
return [`Usage: ${name} ${[...path, ...flags].join(" ")}`, "", description].join("\n");
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// src/webmcp.ts
|
|
482
|
+
import { bundle as bundle3, CallFailureError as CallFailureError2, isCallFailure as isCallFailure4 } from "@acid-cat/spec-ts";
|
|
483
|
+
function findHost() {
|
|
484
|
+
const scope = globalThis;
|
|
485
|
+
for (const candidate of [scope.document?.modelContext, scope.navigator?.modelContext]) {
|
|
486
|
+
if (typeof candidate?.registerTool === "function") return candidate;
|
|
487
|
+
}
|
|
488
|
+
return void 0;
|
|
489
|
+
}
|
|
490
|
+
function installWebMcp(session, options = {}) {
|
|
491
|
+
const host = options.host ?? findHost();
|
|
492
|
+
if (host === void 0) return void 0;
|
|
493
|
+
let status = { phase: "loading", message: null };
|
|
494
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
495
|
+
const setStatus = (next) => {
|
|
496
|
+
status = next;
|
|
497
|
+
listeners.forEach((listener) => listener(next));
|
|
498
|
+
};
|
|
499
|
+
const lifetime = new AbortController();
|
|
500
|
+
const registered = /* @__PURE__ */ new Map();
|
|
501
|
+
const calls = /* @__PURE__ */ new Set();
|
|
502
|
+
const types = session.view.$defs ?? {};
|
|
503
|
+
let running = false;
|
|
504
|
+
let dirty = false;
|
|
505
|
+
const toolFor = (id) => {
|
|
506
|
+
const capability = session.view.capabilities[id];
|
|
507
|
+
return {
|
|
508
|
+
name: id,
|
|
509
|
+
description: capability.description,
|
|
510
|
+
inputSchema: bundle3(capability.input ?? { type: "object" }, types),
|
|
511
|
+
execute: (input, callOptions) => {
|
|
512
|
+
const signal = AbortSignal.any([lifetime.signal, ...callOptions?.signal ? [callOptions.signal] : []]);
|
|
513
|
+
const call = session.invoke(id, input, { signal }).then((outcome) => {
|
|
514
|
+
if (isCallFailure4(outcome)) throw new CallFailureError2(outcome);
|
|
515
|
+
return outcome;
|
|
516
|
+
});
|
|
517
|
+
calls.add(call);
|
|
518
|
+
void call.finally(() => calls.delete(call)).catch(() => void 0);
|
|
519
|
+
return call;
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
};
|
|
523
|
+
async function pass() {
|
|
524
|
+
const wanted = new Set([...session.handles.values()].filter((handle) => handle.availability.getSnapshot().available).map((handle) => handle.id));
|
|
525
|
+
const added = /* @__PURE__ */ new Map();
|
|
526
|
+
try {
|
|
527
|
+
for (const id of wanted) {
|
|
528
|
+
if (registered.has(id)) continue;
|
|
529
|
+
const controller = new AbortController();
|
|
530
|
+
added.set(id, controller);
|
|
531
|
+
await host.registerTool(toolFor(id), { signal: AbortSignal.any([controller.signal, lifetime.signal]) });
|
|
532
|
+
}
|
|
533
|
+
} catch (error2) {
|
|
534
|
+
for (const controller of added.values()) controller.abort();
|
|
535
|
+
throw error2;
|
|
536
|
+
}
|
|
537
|
+
for (const [id, controller] of added) registered.set(id, controller);
|
|
538
|
+
for (const [id, controller] of registered) {
|
|
539
|
+
if (!wanted.has(id)) {
|
|
540
|
+
controller.abort();
|
|
541
|
+
registered.delete(id);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
async function reconcile() {
|
|
546
|
+
if (lifetime.signal.aborted) return;
|
|
547
|
+
if (running) {
|
|
548
|
+
dirty = true;
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
running = true;
|
|
552
|
+
try {
|
|
553
|
+
do {
|
|
554
|
+
dirty = false;
|
|
555
|
+
setStatus({ phase: "reconciling", message: null });
|
|
556
|
+
try {
|
|
557
|
+
await pass();
|
|
558
|
+
setStatus({ phase: "ready", message: null });
|
|
559
|
+
} catch (error2) {
|
|
560
|
+
setStatus({ phase: "failed", message: error2 instanceof Error ? error2.message : "A tool could not be registered." });
|
|
561
|
+
dirty = false;
|
|
562
|
+
}
|
|
563
|
+
} while (dirty && !lifetime.signal.aborted);
|
|
564
|
+
} finally {
|
|
565
|
+
running = false;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
const subscription = session.onChange(() => void reconcile());
|
|
569
|
+
void reconcile();
|
|
570
|
+
return {
|
|
571
|
+
status: {
|
|
572
|
+
getSnapshot: () => status,
|
|
573
|
+
subscribe(listener) {
|
|
574
|
+
listeners.add(listener);
|
|
575
|
+
return { unsubscribe: () => listeners.delete(listener) };
|
|
576
|
+
}
|
|
577
|
+
},
|
|
578
|
+
retry: () => void reconcile(),
|
|
579
|
+
async dispose() {
|
|
580
|
+
setStatus({ phase: "closing", message: null });
|
|
581
|
+
subscription.unsubscribe();
|
|
582
|
+
lifetime.abort();
|
|
583
|
+
registered.clear();
|
|
584
|
+
await Promise.allSettled([...calls]);
|
|
585
|
+
setStatus({ phase: "disposed", message: null });
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// src/index.ts
|
|
591
|
+
import { AVAILABLE as AVAILABLE2, CallFailureError as CallFailureError3, fail, ok, unavailable } from "@acid-cat/spec-ts";
|
|
592
|
+
export {
|
|
593
|
+
AVAILABLE2 as AVAILABLE,
|
|
594
|
+
CallFailureError3 as CallFailureError,
|
|
595
|
+
capabilitiesFor,
|
|
596
|
+
catalogOf,
|
|
597
|
+
main as cliMain,
|
|
598
|
+
createSession,
|
|
599
|
+
defineApp,
|
|
600
|
+
defineCapability,
|
|
601
|
+
fail,
|
|
602
|
+
findHost,
|
|
603
|
+
httpHandler,
|
|
604
|
+
installWebMcp,
|
|
605
|
+
mcpHandler,
|
|
606
|
+
ok,
|
|
607
|
+
renderCatalog,
|
|
608
|
+
runCli,
|
|
609
|
+
unavailable
|
|
610
|
+
};
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { App, ToJsonSchema } from "./app.js";
|
|
2
|
+
export interface McpOptions<Context> {
|
|
3
|
+
readonly name: string;
|
|
4
|
+
readonly version: string;
|
|
5
|
+
/** The origins that can call the server. A request with no `Origin` header is accepted. */
|
|
6
|
+
readonly allowedOrigins: readonly string[];
|
|
7
|
+
readonly context: Context;
|
|
8
|
+
readonly toJsonSchema: ToJsonSchema;
|
|
9
|
+
/** The target that the server serves. The default is `"mcp"`. */
|
|
10
|
+
readonly target?: string | undefined;
|
|
11
|
+
/** The maximum size of a request body, in bytes. The default is 1 MiB. */
|
|
12
|
+
readonly maximumBytes?: number | undefined;
|
|
13
|
+
}
|
|
14
|
+
/** A web request handler for the MCP server. Each request has its own session. */
|
|
15
|
+
export declare function mcpHandler<Context>(app: App<Context>, options: McpOptions<Context>): (request: Request) => Promise<Response>;
|
|
@@ -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;
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acid-cat/core-ts",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.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.1.0",
|
|
36
|
+
"@acid-cat/spec-ts": "0.1.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@acid-cat/cli": "0.1.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
|
}
|