@fungi.computer/mycelium 0.0.0-stage → 0.0.2

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.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +303 -2
  3. package/THIRD_PARTY_NOTICES +2 -0
  4. package/dist/access.d.ts +129 -0
  5. package/dist/access.js +166 -0
  6. package/dist/capability-source.d.ts +44 -0
  7. package/dist/capability-source.js +1 -0
  8. package/dist/compiler.d.ts +28 -0
  9. package/dist/compiler.js +221 -0
  10. package/dist/define.d.ts +8 -0
  11. package/dist/define.js +7 -0
  12. package/dist/effect.d.ts +89 -0
  13. package/dist/effect.js +42 -0
  14. package/dist/errors.d.ts +48 -0
  15. package/dist/errors.js +62 -0
  16. package/dist/execute-tool.d.ts +22 -0
  17. package/dist/execute-tool.js +163 -0
  18. package/dist/image-collector.d.ts +47 -0
  19. package/dist/image-collector.js +39 -0
  20. package/dist/index.d.ts +149 -0
  21. package/dist/index.js +22 -0
  22. package/dist/internal/build-identity.d.ts +1 -0
  23. package/dist/internal/build-identity.js +15 -0
  24. package/dist/internal/build-identity.node.d.ts +1 -0
  25. package/dist/internal/build-identity.node.js +42 -0
  26. package/dist/internal/execution-lifecycle.d.ts +44 -0
  27. package/dist/internal/execution-lifecycle.js +328 -0
  28. package/dist/internal/mcp-principal.d.ts +14 -0
  29. package/dist/internal/mcp-principal.js +17 -0
  30. package/dist/mcp-catalog.d.ts +23 -0
  31. package/dist/mcp-catalog.js +110 -0
  32. package/dist/mcp.d.ts +92 -0
  33. package/dist/mcp.js +250 -0
  34. package/dist/operation-schema.d.ts +47 -0
  35. package/dist/operation-schema.js +50 -0
  36. package/dist/promise-facade.d.ts +3 -0
  37. package/dist/promise-facade.js +82 -0
  38. package/dist/registration.d.ts +74 -0
  39. package/dist/registration.js +157 -0
  40. package/dist/remote.d.ts +33 -0
  41. package/dist/remote.js +126 -0
  42. package/dist/runtime.d.ts +51 -0
  43. package/dist/runtime.js +159 -0
  44. package/dist/sandbox-boundary.d.ts +44 -0
  45. package/dist/sandbox-boundary.js +325 -0
  46. package/dist/sandbox-failure-event.d.ts +15 -0
  47. package/dist/sandbox-failure-event.js +34 -0
  48. package/dist/sandbox-failure.d.ts +128 -0
  49. package/dist/sandbox-failure.js +161 -0
  50. package/dist/sandbox-guest.d.ts +34 -0
  51. package/dist/sandbox-guest.js +131 -0
  52. package/dist/sandbox-wire.d.ts +76 -0
  53. package/dist/sandbox-wire.js +190 -0
  54. package/dist/sandbox.d.ts +41 -0
  55. package/dist/sandbox.js +1 -0
  56. package/dist/semaphore-transition.d.ts +7 -0
  57. package/dist/semaphore-transition.js +9 -0
  58. package/dist/source-runtime.d.ts +28 -0
  59. package/dist/source-runtime.js +162 -0
  60. package/dist/testing.d.ts +11 -0
  61. package/dist/testing.js +19 -0
  62. package/dist/workers/index.d.ts +9 -0
  63. package/dist/workers/index.js +90 -0
  64. package/docs/api/README.md +25 -0
  65. package/docs/api/define.md +20 -0
  66. package/docs/api/effect.md +245 -0
  67. package/docs/api/index.md +1081 -0
  68. package/docs/api/mcp.md +239 -0
  69. package/docs/api/remote.md +108 -0
  70. package/docs/api/sandbox-boundary.md +325 -0
  71. package/docs/api/sandbox-failure.md +1133 -0
  72. package/docs/api/sandbox-guest.md +191 -0
  73. package/docs/api/sandbox-wire.md +117 -0
  74. package/docs/api/sandbox.md +81 -0
  75. package/docs/api/semaphore-transition.md +40 -0
  76. package/docs/api/testing.md +72 -0
  77. package/docs/api/workers.md +55 -0
  78. package/package.json +132 -3
@@ -0,0 +1,328 @@
1
+ import { sandboxFailure, isSandboxFailure, sandboxOperatorValue, sandboxValueKind, } from "../sandbox-boundary.js";
2
+ import { sandboxBuildSha } from "#build-identity";
3
+ import * as Metric from "effect/Metric";
4
+ import { referenceSandboxFailure, DeadlineExceeded, Cancelled, ResultTooLarge, ResultNotJson, CapabilityFailed, } from "../sandbox-failure.js";
5
+ import { ForageError } from "@fungi.computer/forage";
6
+ import * as Cause from "effect/Cause";
7
+ import * as Clock from "effect/Clock";
8
+ import * as Effect from "effect/Effect";
9
+ import * as Fiber from "effect/Fiber";
10
+ import { z } from "zod";
11
+ import { InvalidOperationInput, MyceliumDefinitionError, } from "../errors.js";
12
+ const jsonValueSchema = z.json();
13
+ const jsonScalarSchema = z.union([
14
+ z.null(),
15
+ z.string(),
16
+ z.boolean(),
17
+ z.number(),
18
+ ]);
19
+ const jsonRecordSchema = z.record(z.string(), z.unknown());
20
+ const executionOptionsSchema = z
21
+ .object({
22
+ timeoutMs: z.number().int().min(1).max(900_000).optional(),
23
+ abortGraceMs: z.number().int().min(1).max(30_000).optional(),
24
+ })
25
+ .strict()
26
+ .optional();
27
+ const maximumJsonDepth = 64;
28
+ const maximumSerializedJsonBytes = 256 * 1024;
29
+ const maximumBackingExecutions = 8;
30
+ const utf8 = new TextEncoder();
31
+ const resumeIdle = (notify) => {
32
+ notify(Effect.succeed(undefined));
33
+ };
34
+ export function normalizeExecutionPolicy(value) {
35
+ const parsed = executionOptionsSchema.safeParse(value);
36
+ if (!parsed.success) {
37
+ throw new MyceliumDefinitionError("Execution policy is invalid");
38
+ }
39
+ return Object.freeze({
40
+ timeoutMs: parsed.data?.timeoutMs ?? 180_000,
41
+ abortGraceMs: parsed.data?.abortGraceMs ?? 5_000,
42
+ });
43
+ }
44
+ export function createBackingWorkTracker() {
45
+ let active = 0;
46
+ return Object.freeze({
47
+ createGroup: () => {
48
+ let groupActive = 0;
49
+ const waiters = new Set();
50
+ const awaitIdle = Effect.callback((resume) => {
51
+ if (groupActive === 0) {
52
+ resume(Effect.succeed(undefined));
53
+ return Effect.sync(() => undefined);
54
+ }
55
+ waiters.add(resume);
56
+ return Effect.sync(() => waiters.delete(resume));
57
+ });
58
+ return Object.freeze({
59
+ start: (effect) => Effect.uninterruptibleMask((restore) => Effect.sync(() => {
60
+ if (active >= maximumBackingExecutions)
61
+ return false;
62
+ active += 1;
63
+ groupActive += 1;
64
+ return true;
65
+ }).pipe(Effect.flatMap((reserved) => reserved
66
+ ? Effect.forkDetach(restore(effect).pipe(Effect.ensuring(Effect.sync(() => {
67
+ active -= 1;
68
+ groupActive -= 1;
69
+ if (groupActive === 0) {
70
+ const pending = [...waiters];
71
+ waiters.clear();
72
+ pending.forEach(resumeIdle);
73
+ }
74
+ }))))
75
+ : Effect.fail(new CapabilityFailed({
76
+ namespace: "execute",
77
+ operation: "admission",
78
+ cause: {
79
+ kind: "Unavailable",
80
+ message: "Sandbox execution capacity unavailable",
81
+ },
82
+ }))))),
83
+ awaitIdle,
84
+ });
85
+ },
86
+ });
87
+ }
88
+ function normalizeJson(value, depth = 0) {
89
+ if (depth > maximumJsonDepth) {
90
+ throw new Error("JSON value exceeds the nesting bound");
91
+ }
92
+ const scalar = jsonScalarSchema.safeParse(value);
93
+ if (scalar.success)
94
+ return scalar.data;
95
+ if (Array.isArray(value))
96
+ return value.map((item) => normalizeJson(item, depth + 1));
97
+ const record = jsonRecordSchema.safeParse(value);
98
+ if (!record.success)
99
+ throw record.error;
100
+ return Object.fromEntries(Object.entries(record.data)
101
+ .filter(([, item]) => item !== undefined)
102
+ .map(([key, item]) => [key, normalizeJson(item, depth + 1)]));
103
+ }
104
+ function jsonValue(value) {
105
+ return jsonValueSchema.parse(normalizeJson(value));
106
+ }
107
+ function serializeSandboxValue(value) {
108
+ const text = JSON.stringify(jsonValue(value));
109
+ const size = utf8.encode(text).byteLength;
110
+ if (size > maximumSerializedJsonBytes) {
111
+ throw new ResultTooLarge({ size });
112
+ }
113
+ return Object.freeze({ text });
114
+ }
115
+ function guardedBindings(raw, admit) {
116
+ return Object.freeze(Object.fromEntries(Object.entries(raw).map(([namespace, operations]) => [
117
+ namespace,
118
+ Object.freeze(Object.fromEntries(Object.entries(operations).map(([name, operation]) => [
119
+ name,
120
+ async (input) => {
121
+ admit();
122
+ try {
123
+ return jsonValue(await operation(input));
124
+ }
125
+ catch (cause) {
126
+ if (isSandboxFailure(cause))
127
+ throw cause;
128
+ if (cause instanceof ForageError)
129
+ throw new CapabilityFailed({
130
+ namespace,
131
+ operation: name,
132
+ cause: { kind: "Forage", message: cause.message },
133
+ rawCause: cause,
134
+ });
135
+ if (cause instanceof InvalidOperationInput)
136
+ throw new CapabilityFailed({
137
+ namespace,
138
+ operation: name,
139
+ cause: {
140
+ kind: "InvalidInput",
141
+ message: `Invalid operation input: ${z.prettifyError({ issues: cause.issues })}; use forage.describe`,
142
+ },
143
+ rawCause: cause,
144
+ });
145
+ // Capability owners/adapters may admit their own typed diagnostics (Files, etc.).
146
+ throw cause;
147
+ }
148
+ },
149
+ ]))),
150
+ ])));
151
+ }
152
+ function waitForAbort(signal) {
153
+ return Effect.callback((resume) => {
154
+ const onAbort = resume.bind(undefined, Effect.void);
155
+ if (signal.aborted)
156
+ onAbort();
157
+ else
158
+ signal.addEventListener("abort", onAbort, { once: true });
159
+ return Effect.sync(signal.removeEventListener.bind(signal, "abort", onAbort));
160
+ });
161
+ }
162
+ function forwardAbort(source, onAbort) {
163
+ if (source === undefined)
164
+ return () => undefined;
165
+ const forward = onAbort.bind(undefined);
166
+ if (source.aborted) {
167
+ forward();
168
+ return () => undefined;
169
+ }
170
+ source.addEventListener("abort", forward, { once: true });
171
+ return source.removeEventListener.bind(source, "abort", forward);
172
+ }
173
+ function executionFailure(cancellation, timeoutMs, elapsedMs) {
174
+ return cancellation === "deadline_exceeded"
175
+ ? new DeadlineExceeded({ limitMs: timeoutMs, elapsedMs: elapsedMs ?? 0 })
176
+ : new Cancelled({ by: "caller" });
177
+ }
178
+ /** Wall time since `startedAt`, read through Effect's Clock so tests using TestClock stay deterministic. */
179
+ function elapsedSince(startedAt) {
180
+ return Clock.currentTimeMillis.pipe(Effect.map((now) => now - startedAt));
181
+ }
182
+ export function runExecution(request) {
183
+ return Clock.currentTimeMillis.pipe(Effect.flatMap((startedAt) => Effect.suspend(() => {
184
+ const executionId = request.executionId;
185
+ const controller = new AbortController();
186
+ let cancellation;
187
+ const cancel = (phase) => {
188
+ if (cancellation === undefined) {
189
+ cancellation = phase;
190
+ controller.abort();
191
+ }
192
+ };
193
+ const admit = () => {
194
+ if (controller.signal.aborted)
195
+ throw executionFailure(cancellation, request.policy.timeoutMs);
196
+ };
197
+ const detachCaller = forwardAbort(request.callerSignal, () => {
198
+ cancel("cancelled");
199
+ });
200
+ const invocation = {
201
+ executionId,
202
+ deadlineAt: startedAt + request.policy.timeoutMs,
203
+ signal: controller.signal,
204
+ images: request.images,
205
+ };
206
+ if (request.toolCallId !== undefined)
207
+ Object.assign(invocation, { toolCallId: request.toolCallId });
208
+ const context = Object.freeze(invocation);
209
+ const sandboxExecution = Effect.suspend(() => {
210
+ if (controller.signal.aborted)
211
+ return elapsedSince(startedAt).pipe(Effect.flatMap((elapsedMs) => Effect.fail(executionFailure(cancellation, request.policy.timeoutMs, elapsedMs))));
212
+ return request.sandbox
213
+ .execute(Object.freeze({
214
+ executionId,
215
+ code: request.code,
216
+ timeoutMs: request.policy.timeoutMs,
217
+ bindings: guardedBindings(request.createBindings(context), admit),
218
+ // B3c (adr-guest-code-broker addendum): `buildGuestBindings`
219
+ // classifies every compiled alias by its STABLE MODULE ID,
220
+ // never its Team-chosen alias, so the sandbox needs both of
221
+ // these to reach a retained operation at all.
222
+ modules: request.modules,
223
+ namespacePaths: request.namespacePaths,
224
+ sessionId: request.sessionId,
225
+ }), controller.signal)
226
+ .pipe(Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause)
227
+ ? Effect.failCause(cause)
228
+ : Effect.fail(sandboxFailure(Cause.squash(cause)))));
229
+ });
230
+ const interruptBacking = (child) => Fiber.interrupt(child).pipe(Effect.timeoutOrElse({
231
+ duration: request.policy.abortGraceMs,
232
+ orElse: () => Effect.fail(cancellation === "deadline_exceeded"
233
+ ? new DeadlineExceeded({
234
+ limitMs: request.policy.timeoutMs,
235
+ elapsedMs: request.policy.timeoutMs,
236
+ acknowledged: false,
237
+ })
238
+ : new Cancelled({ by: "caller", acknowledged: false })),
239
+ }));
240
+ let child;
241
+ let reported = false;
242
+ const reportFailure = (cause) => {
243
+ reported = true;
244
+ return elapsedSince(startedAt).pipe(Effect.flatMap((elapsedMs) => {
245
+ const normalized = sandboxFailure(cause);
246
+ const timed = normalized instanceof DeadlineExceeded
247
+ ? new DeadlineExceeded({
248
+ limitMs: normalized.limitMs,
249
+ elapsedMs,
250
+ acknowledged: normalized.acknowledged,
251
+ })
252
+ : normalized;
253
+ const failure = referenceSandboxFailure(timed, executionId);
254
+ const fields = z
255
+ .record(z.string(), z.json())
256
+ .parse(sandboxOperatorValue(timed));
257
+ fields.ref = executionId;
258
+ const event = {
259
+ kind: "sandbox_failure",
260
+ ref: executionId,
261
+ executionId,
262
+ adapter: request.sandbox.adapter ?? "effect",
263
+ buildSha: request.sandbox.buildSha ?? sandboxBuildSha(),
264
+ tag: failure._tag,
265
+ fields,
266
+ healthAlert: failure._tag === "Unknown",
267
+ };
268
+ const report = Effect.suspend(() => request.sandbox.onFailure?.(event) ?? Effect.void);
269
+ return Effect.logError("sandbox_failure", event).pipe(Effect.annotateLogs({
270
+ executionId,
271
+ ref: executionId,
272
+ adapter: event.adapter,
273
+ tag: event.tag,
274
+ buildSha: event.buildSha,
275
+ }), Effect.andThen(failure._tag === "Unknown"
276
+ ? Metric.update(Metric.withAttributes(Metric.counter("sandbox_unknown"), { adapter: event.adapter }), 1).pipe(Effect.andThen(Effect.logError("sandbox_health_alert").pipe(Effect.annotateLogs({
277
+ ref: executionId,
278
+ adapter: event.adapter,
279
+ }))))
280
+ : Effect.void), Effect.andThen(report.pipe(Effect.catchCause((reportCause) => Effect.logError("sandbox_failure_record_failed", reportCause)))), Effect.as(failure));
281
+ }));
282
+ };
283
+ const run = Effect.gen(function* () {
284
+ if (controller.signal.aborted) {
285
+ const elapsedMs = yield* elapsedSince(startedAt);
286
+ return yield* Effect.fail(executionFailure(cancellation, request.policy.timeoutMs, elapsedMs));
287
+ }
288
+ child = yield* request.backingWork.start(sandboxExecution);
289
+ const first = yield* Effect.raceFirst(Fiber.await(child).pipe(Effect.map((exit) => Object.freeze({ kind: "settled", exit }))), Effect.raceFirst(Effect.sleep(request.policy.timeoutMs).pipe(Effect.as(Object.freeze({ kind: "deadline" }))), waitForAbort(controller.signal).pipe(Effect.as(Object.freeze({ kind: "cancelled" })))));
290
+ if (first.kind === "settled") {
291
+ return yield* Effect.matchCauseEffectEager(first.exit, {
292
+ onFailure: (cause) =>
293
+ // A cancellation or deadline was already decided before this
294
+ // fiber settled: the sandbox's own failure is very likely a
295
+ // side effect of that abort tearing it down (e.g. a rejected
296
+ // in-flight fetch once disposal starts), not independent
297
+ // signal. Report the actionable cancellation cause instead of
298
+ // whatever generic failure teardown produced.
299
+ cancellation === undefined
300
+ ? Effect.failCause(cause)
301
+ : elapsedSince(startedAt).pipe(Effect.flatMap((elapsedMs) => Effect.fail(executionFailure(cancellation, request.policy.timeoutMs, elapsedMs)))),
302
+ onSuccess: (value) => Effect.try({
303
+ try: () => serializeSandboxValue(value),
304
+ catch: (cause) => isSandboxFailure(cause)
305
+ ? cause
306
+ : new ResultNotJson({
307
+ kind: sandboxValueKind(value),
308
+ rawCause: cause,
309
+ }),
310
+ }),
311
+ });
312
+ }
313
+ if (first.kind === "deadline")
314
+ cancel("deadline_exceeded");
315
+ const elapsedMs = yield* elapsedSince(startedAt);
316
+ return yield* interruptBacking(child).pipe(Effect.andThen(Effect.fail(executionFailure(cancellation, request.policy.timeoutMs, elapsedMs))));
317
+ }).pipe(Effect.onInterrupt(() => {
318
+ cancel("cancelled");
319
+ const cleanup = child === undefined
320
+ ? Effect.void
321
+ : Fiber.interrupt(child).pipe(Effect.timeoutOption(request.policy.abortGraceMs), Effect.asVoid);
322
+ return cleanup.pipe(Effect.andThen(Effect.suspend(() => reported
323
+ ? Effect.void
324
+ : reportFailure(new Cancelled({ by: "operator" })).pipe(Effect.asVoid))));
325
+ }));
326
+ return run.pipe(Effect.catch((cause) => reportFailure(cause).pipe(Effect.flatMap(Effect.fail))), Effect.ensuring(Effect.sync(detachCaller)));
327
+ })));
328
+ }
@@ -0,0 +1,14 @@
1
+ import type { HostActor } from "@fungi.computer/puffball/app-method-runtime";
2
+ /** Authenticated credential identity, scoped by its host. Never a bearer token. */
3
+ export type McpPrincipal = Readonly<{
4
+ actor: Extract<HostActor, {
5
+ kind: "member";
6
+ }>;
7
+ role: "owner" | "member";
8
+ }> | Readonly<{
9
+ actor: Exclude<HostActor, {
10
+ kind: "member";
11
+ }>;
12
+ }>;
13
+ /** Compare complete admitted identities, including a canonical member's role. */
14
+ export declare function sameMcpPrincipal(left: McpPrincipal, right: McpPrincipal): boolean;
@@ -0,0 +1,17 @@
1
+ /** Compare complete admitted identities, including a canonical member's role. */
2
+ export function sameMcpPrincipal(left, right) {
3
+ const actor = left.actor;
4
+ const other = right.actor;
5
+ switch (actor.kind) {
6
+ case "member":
7
+ return (other.kind === "member" &&
8
+ actor.memberId === other.memberId &&
9
+ "role" in left &&
10
+ "role" in right &&
11
+ left.role === right.role);
12
+ case "agent":
13
+ return other.kind === "agent" && actor.agentId === other.agentId;
14
+ case "team-key":
15
+ return other.kind === "team-key" && actor.keyId === other.keyId;
16
+ }
17
+ }
@@ -0,0 +1,23 @@
1
+ import { type CapabilitySource, type Grants, type Sandbox } from "./index.js";
2
+ import type { McpCatalogProvider, McpPrincipal } from "./mcp.js";
3
+ /** Current installed capabilities and explicit grants for one admitted principal. */
4
+ export type McpCatalogSnapshot = Readonly<{
5
+ instructions: string;
6
+ sources: readonly CapabilitySource[];
7
+ /** Every permitted module is named; an omitted module is refused. */
8
+ grants: Grants;
9
+ /**
10
+ * Granted modules whose writes a person already approved for this principal,
11
+ * such as an owner's stored approval of a key's App tools. Their writes run
12
+ * without a per-call elicitation; every other write still asks.
13
+ */
14
+ approvedWrites?: readonly string[];
15
+ }>;
16
+ /**
17
+ * Adapt principal-scoped capability sources and a sandbox to the shared MCP server.
18
+ * Connections are process-local. Durable hosts supply their own catalog provider.
19
+ */
20
+ export declare function createMcpCatalog(options: Readonly<{
21
+ load: (principal: McpPrincipal) => Promise<McpCatalogSnapshot>;
22
+ sandbox: Sandbox;
23
+ }>): McpCatalogProvider;
@@ -0,0 +1,110 @@
1
+ import { sameMcpPrincipal } from "./internal/mcp-principal.js";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Stream from "effect/Stream";
4
+ import { Mycelium, } from "./index.js";
5
+ /**
6
+ * Adapt principal-scoped capability sources and a sandbox to the shared MCP server.
7
+ * Connections are process-local. Durable hosts supply their own catalog provider.
8
+ */
9
+ export function createMcpCatalog(options) {
10
+ const sessions = new Map();
11
+ return {
12
+ open(principal, input) {
13
+ let principalSessions = 0;
14
+ for (const session of sessions.values()) {
15
+ if (sameMcpPrincipal(session.principal, principal))
16
+ principalSessions++;
17
+ }
18
+ if (principalSessions >= input.maxOpenSessions)
19
+ return Promise.resolve(null);
20
+ let snapshot;
21
+ let requestedRefresh = 0;
22
+ let appliedRefresh = 0;
23
+ const refresh = async () => {
24
+ const revision = ++requestedRefresh;
25
+ const current = await options.load(principal);
26
+ // A slower, older storage read cannot restore authority after revocation.
27
+ if (revision > appliedRefresh) {
28
+ snapshot = current;
29
+ appliedRefresh = revision;
30
+ }
31
+ };
32
+ async function withLease(access, use) {
33
+ if (snapshot === undefined)
34
+ throw new Error("MCP catalog was not acquired");
35
+ const acquired = snapshot;
36
+ const runtime = await Mycelium.make({
37
+ sources: acquired.sources,
38
+ session: { sessionId: input.id },
39
+ sandbox: options.sandbox,
40
+ access: {
41
+ ...access,
42
+ defaultGrant: "none",
43
+ sessionGrants: () => snapshot?.grants ?? {},
44
+ approvedWrites: () => snapshot?.approvedWrites ?? [],
45
+ },
46
+ });
47
+ try {
48
+ const lease = await runtime.acquire();
49
+ try {
50
+ return await use(lease, acquired);
51
+ }
52
+ finally {
53
+ await lease.release();
54
+ }
55
+ }
56
+ finally {
57
+ await runtime.close();
58
+ }
59
+ }
60
+ const session = {
61
+ id: input.id,
62
+ principal,
63
+ refresh,
64
+ describe: () => withLease({}, (lease, acquired) => Promise.resolve({
65
+ instructions: acquired.instructions,
66
+ tools: [
67
+ lease.executeTool.descriptor,
68
+ ...lease.tools.map(({ descriptor }) => descriptor),
69
+ ],
70
+ })),
71
+ call: (call, context) => withLease(context.access, async (lease) => {
72
+ const tool = [lease.executeTool, ...lease.tools].find(({ descriptor }) => descriptor.name === call.name);
73
+ if (tool === undefined)
74
+ throw new Error("Unknown MCP tool");
75
+ let result;
76
+ await Effect.runPromise(Effect.gen(function* () {
77
+ const prepared = yield* tool.prepare({
78
+ type: "toolCall",
79
+ ...call,
80
+ });
81
+ yield* prepared.execute().pipe(Stream.runForEach((part) => {
82
+ if (part.type === "result") {
83
+ result = part.result;
84
+ return Effect.void;
85
+ }
86
+ return Effect.promise(async () => {
87
+ await context.onPart?.(part);
88
+ });
89
+ }));
90
+ }), { signal: context.signal });
91
+ if (result === undefined)
92
+ throw new Error("MCP tool returned no result");
93
+ return result;
94
+ }),
95
+ disconnect: () => {
96
+ sessions.delete(input.id);
97
+ return Promise.resolve();
98
+ },
99
+ };
100
+ sessions.set(input.id, session);
101
+ return Promise.resolve(session);
102
+ },
103
+ resume(principal, id) {
104
+ const session = sessions.get(id);
105
+ return Promise.resolve(session !== undefined && sameMcpPrincipal(session.principal, principal)
106
+ ? session
107
+ : null);
108
+ },
109
+ };
110
+ }
package/dist/mcp.d.ts ADDED
@@ -0,0 +1,92 @@
1
+ import { sameMcpPrincipal, type McpPrincipal } from "./internal/mcp-principal.js";
2
+ export { sameMcpPrincipal, type McpPrincipal };
3
+ export { createMcpCatalog, type McpCatalogSnapshot } from "./mcp-catalog.js";
4
+ import type { MuleTool, MuleToolExecutionPart, MuleToolResult, ToolCall } from "@fungi.computer/mule/effect";
5
+ import { type EventStore } from "@modelcontextprotocol/server";
6
+ import type { AccessOptions } from "./access.js";
7
+ /** Admission is required on every HTTP request, including notifications. */
8
+ export type McpAuthentication = Readonly<{
9
+ kind: "admitted";
10
+ principal: McpPrincipal;
11
+ }> | Readonly<{
12
+ kind: "refused";
13
+ status: 401 | 403 | 404 | 429;
14
+ retryAfter?: number;
15
+ }>;
16
+ /** A JSON tool call submitted to the host's execution owner. */
17
+ export type McpToolCall = Readonly<{
18
+ id: string;
19
+ name: string;
20
+ arguments: ToolCall["arguments"];
21
+ }>;
22
+ /** Request-scoped hooks carried into the same Mycelium runtime as hosted tools. */
23
+ export type McpExecutionContext = Readonly<{
24
+ signal: AbortSignal;
25
+ /** Forward non-terminal tool output through this request's progress channel. */
26
+ onPart?: (part: Exclude<MuleToolExecutionPart, {
27
+ type: "result";
28
+ }>) => Promise<void>;
29
+ /** Apply to Mycelium access options for this call only; never persist callbacks. */
30
+ access: Pick<AccessOptions, "confirm"> & Readonly<{
31
+ confirmWrites: true;
32
+ /** Recheck the open connection and refresh authority before each operation. */
33
+ beforeCall: (signal: AbortSignal) => Promise<void>;
34
+ }>;
35
+ }>;
36
+ /** A principal-bound catalog and its existing execution owner. */
37
+ export type McpCatalogSession = Readonly<{
38
+ id: string;
39
+ principal: McpPrincipal;
40
+ /** Refresh current grants before listing, execution and after approval. */
41
+ refresh: () => Promise<void>;
42
+ /** Return the existing execution owner's current instructions and tool descriptors. */
43
+ describe: () => Promise<{
44
+ instructions: string;
45
+ tools: readonly MuleTool["descriptor"][];
46
+ }>;
47
+ /** Execute through Mycelium, applying context.access to operations and direct tools. */
48
+ call: (call: McpToolCall, context: McpExecutionContext) => Promise<MuleToolResult>;
49
+ /** Release the open connection; retained work belongs to the host. */
50
+ disconnect: () => Promise<void>;
51
+ }>;
52
+ /** Host catalog adapter. All lookups and acquisitions are principal-bound. */
53
+ export type McpCatalogProvider = Readonly<{
54
+ /** Atomically reserve a connection, or return null at the supplied bound. */
55
+ open: (principal: McpPrincipal, input: Readonly<{
56
+ id: string;
57
+ title: string;
58
+ /** Maximum concurrent sessions for this admitted principal. */
59
+ maxOpenSessions: number;
60
+ }>) => Promise<McpCatalogSession | null>;
61
+ /** Return an open connection only; the MCP server also checks its principal. */
62
+ resume: (principal: McpPrincipal, id: string) => Promise<McpCatalogSession | null>;
63
+ }>;
64
+ /** HTTP MCP lifecycle. Closing transport streams preserves host-owned Sessions. */
65
+ export type Mcp = Readonly<{
66
+ /** Authenticate and serve one MCP HTTP request. */
67
+ handleRequest: (request: Request) => Promise<Response>;
68
+ /** End transport streams and refuse new requests; retained Sessions belong to the catalog. */
69
+ close: () => Promise<void>;
70
+ }>;
71
+ /** Durable connection identity, without credentials or live execution callbacks. */
72
+ export type McpSessionMetadata = Readonly<{
73
+ /** The host-owned catalog Session identity. */
74
+ id: string;
75
+ /** The complete admitted principal, including a member's role. */
76
+ principal: McpPrincipal;
77
+ }>;
78
+ /** SDK-supported persistence; live protocol callbacks are never reconstructed. */
79
+ export type McpPersistence = Readonly<{
80
+ /** Supply the SDK's event storage for one host-owned catalog Session. */
81
+ eventStore: (sessionId: string) => EventStore;
82
+ /** Persist canonical metadata after the SDK allocates its protocol session ID. */
83
+ initialized: (id: string, session: McpSessionMetadata) => Promise<void>;
84
+ /** Expire SDK metadata and events when the SDK closes that session. */
85
+ closed: (id: string, session: McpSessionMetadata) => Promise<void>;
86
+ }>;
87
+ /** Serve the host's principal-bound Mycelium catalog through one MCP implementation. */
88
+ export declare function createMcp(options: Readonly<{
89
+ catalog: McpCatalogProvider;
90
+ authenticate: (request: Request) => Promise<McpAuthentication>;
91
+ persistence?: McpPersistence;
92
+ }>): Mcp;