@acid-cat/core-ts 0.1.0 → 0.3.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/dist/mcp.js ADDED
@@ -0,0 +1,159 @@
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, {
47
+ ...options,
48
+ instructions: options.instructions ?? app.description
49
+ }, request.signal);
50
+ } finally {
51
+ await session.dispose();
52
+ }
53
+ });
54
+ };
55
+ }
56
+ async function answer(session, message, options, signal) {
57
+ const id = message.id ?? null;
58
+ const complete = (result, list = false) => reply(id, {
59
+ _meta: { "io.modelcontextprotocol/serverInfo": {
60
+ name: options.name,
61
+ version: options.version
62
+ } },
63
+ resultType: "complete",
64
+ ...list ? {
65
+ ttlMs: 0,
66
+ cacheScope: "private"
67
+ } : {},
68
+ ...result
69
+ });
70
+ switch (message.method) {
71
+ case "server/discover": return complete({
72
+ supportedVersions: [MCP_PROTOCOL_VERSION],
73
+ capabilities: { tools: { listChanged: false } },
74
+ ...options.instructions === void 0 ? {} : { instructions: options.instructions }
75
+ }, true);
76
+ case "tools/list": return complete({ tools: listTools(session) }, true);
77
+ case "tools/call": {
78
+ const name = message.params?.name ?? "";
79
+ const handle = session.handles.get(name);
80
+ if (handle === void 0 || !handle.availability.getSnapshot().available) return error(200, id, MCP_UNKNOWN_TOOL, `Tool '${name}' not found`);
81
+ const outcome = await session.invoke(name, message.params?.arguments ?? {}, { signal });
82
+ return complete(mcpToolResult(outcome, hasObjectOutput(session, name)));
83
+ }
84
+ default: return error(404, id, -32601, `Method not found: ${message.method}`);
85
+ }
86
+ }
87
+ function listTools(session) {
88
+ const types = session.view.$defs ?? {};
89
+ return [...session.handles.values()].filter((handle) => handle.availability.getSnapshot().available).map((handle) => {
90
+ const capability = session.view.capabilities[handle.id];
91
+ const output = capability.output === void 0 ? void 0 : bundle(capability.output, types);
92
+ return {
93
+ name: handle.id,
94
+ description: capability.description,
95
+ inputSchema: bundle(capability.input ?? { type: "object" }, types),
96
+ ...output?.type === "object" ? { outputSchema: output } : {},
97
+ annotations: { readOnlyHint: capability.readOnly === true }
98
+ };
99
+ });
100
+ }
101
+ function hasObjectOutput(session, id) {
102
+ const output = session.view.capabilities[id]?.output;
103
+ return output !== void 0 && bundle(output, session.view.$defs ?? {}).type === "object";
104
+ }
105
+ function reply(id, result) {
106
+ return new Response(JSON.stringify({
107
+ jsonrpc: "2.0",
108
+ id,
109
+ result
110
+ }), {
111
+ status: 200,
112
+ headers: { "Content-Type": "application/json" }
113
+ });
114
+ }
115
+ function error(status, id, code, message, data) {
116
+ const body = {
117
+ jsonrpc: "2.0",
118
+ id,
119
+ error: {
120
+ code,
121
+ message,
122
+ ...data === void 0 ? {} : { data }
123
+ }
124
+ };
125
+ return new Response(JSON.stringify(body), {
126
+ status,
127
+ headers: { "Content-Type": "application/json" }
128
+ });
129
+ }
130
+ function rejection(reason) {
131
+ const { status, code } = MCP_REJECTIONS[reason];
132
+ const headers = new Headers({ "Cache-Control": "no-store" });
133
+ if (reason === "method") headers.set("Allow", "POST");
134
+ if (code === void 0) return new Response(null, {
135
+ status,
136
+ headers
137
+ });
138
+ headers.set("Content-Type", "application/json");
139
+ return new Response(JSON.stringify({
140
+ jsonrpc: "2.0",
141
+ id: null,
142
+ error: {
143
+ code,
144
+ message: messages[reason]
145
+ }
146
+ }), {
147
+ status,
148
+ headers
149
+ });
150
+ }
151
+ const messages = {
152
+ method: "Use POST.",
153
+ origin: "This origin is not allowed.",
154
+ "media-type": "Use application/json.",
155
+ "too-large": "The request body is too large.",
156
+ "not-utf8": "The request body is not UTF-8 JSON."
157
+ };
158
+ //#endregion
159
+ export { mcpHandler };
@@ -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 };
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,6 @@
1
1
  {
2
2
  "name": "@acid-cat/core-ts",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Serve AcidCat capabilities in plain TypeScript, with no Effect dependency.",
5
5
  "keywords": [
6
6
  "acidcat",
@@ -32,11 +32,11 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@acid-cat/catalog": "0.1.0",
36
- "@acid-cat/spec-ts": "0.1.0"
35
+ "@acid-cat/catalog": "0.3.0",
36
+ "@acid-cat/spec-ts": "0.3.0"
37
37
  },
38
38
  "devDependencies": {
39
- "@acid-cat/cli": "0.1.0",
39
+ "@acid-cat/cli": "0.3.0",
40
40
  "@types/node": "26.6.5",
41
41
  "esbuild": "0.28.2",
42
42
  "playwright": "1.64.0",