apps 1.0.0-beta.2 → 1.0.0-beta.3

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 (41) hide show
  1. package/dist/authoring/index.d.ts +222 -34
  2. package/dist/authoring/index.js +11 -1
  3. package/dist/{chunk-NFLNA2SC.js → chunk-4OZFMCTT.js} +7 -1
  4. package/dist/{chunk-LWKQDDLZ.js → chunk-A3H32P2J.js} +46 -1
  5. package/dist/chunk-F7GZYEAC.js +41 -0
  6. package/dist/{chunk-OGCPVWAZ.js → chunk-MUFMJRXR.js} +130 -13
  7. package/dist/chunk-S5KFP4JQ.js +20 -0
  8. package/dist/{chunk-646MS7DD.js → chunk-Y6LLTN7W.js} +7 -7
  9. package/dist/chunk-YUZ4TXOY.js +27 -0
  10. package/dist/http/index.d.ts +9 -0
  11. package/dist/http/index.js +6 -0
  12. package/dist/index.d.ts +3 -0
  13. package/dist/index.js +23 -1
  14. package/dist/manifest/contract.d.ts +8 -0
  15. package/dist/manifest/index.js +3 -3
  16. package/dist/mcp/client.d.ts +9 -0
  17. package/dist/mcp/index.d.ts +11 -0
  18. package/dist/mcp/index.js +7 -0
  19. package/dist/openapi/index.d.ts +14 -0
  20. package/dist/openapi/index.js +7 -0
  21. package/dist/openapi/mapper.d.ts +7 -0
  22. package/dist/runner/index.js +29 -15
  23. package/dist/runner/protocol.d.ts +18 -5
  24. package/dist/runner/protocol.js +5 -3
  25. package/dist/runtime/index.d.ts +42 -5
  26. package/dist/runtime/index.js +3 -1
  27. package/dist/workerd/index.d.ts +1 -1
  28. package/dist/workerd/index.js +1 -1
  29. package/package.json +13 -6
  30. package/src/authoring/index.ts +362 -44
  31. package/src/http/index.ts +26 -0
  32. package/src/index.ts +3 -0
  33. package/src/manifest/contract.ts +9 -0
  34. package/src/mcp/client.ts +59 -0
  35. package/src/mcp/index.ts +42 -0
  36. package/src/openapi/index.ts +70 -0
  37. package/src/openapi/mapper.ts +25 -0
  38. package/src/runner/index.ts +35 -12
  39. package/src/runner/protocol.ts +32 -7
  40. package/src/runtime/index.ts +209 -22
  41. package/src/workerd/index.ts +2 -0
@@ -0,0 +1,42 @@
1
+ /** fromMCP sugar over the authored dynamic-tools primitive. */
2
+
3
+ import {
4
+ defineDynamicTools,
5
+ type AuthComposeFn,
6
+ type DynamicToolSet,
7
+ type OperationSelection,
8
+ type Requirement,
9
+ } from "../authoring/index";
10
+ import { mcpCall, mcpDiscover } from "./client";
11
+
12
+ export type FromMCPOptions<Reqs extends Readonly<Record<string, Requirement>>> = {
13
+ readonly requirements: Reqs;
14
+ readonly auth?: AuthComposeFn<NoInfer<Reqs>>;
15
+ readonly operations?: OperationSelection;
16
+ };
17
+
18
+ export function fromMCP<const Reqs extends Readonly<Record<string, Requirement>>>(
19
+ endpoint: string,
20
+ options: FromMCPOptions<Reqs>,
21
+ ): DynamicToolSet;
22
+ export function fromMCP(
23
+ endpoint: string,
24
+ options?: { readonly operations?: OperationSelection },
25
+ ): DynamicToolSet;
26
+ export function fromMCP(
27
+ endpoint: string,
28
+ options?: {
29
+ readonly requirements?: Readonly<Record<string, Requirement>>;
30
+ readonly auth?: AuthComposeFn<never>;
31
+ readonly operations?: OperationSelection;
32
+ },
33
+ ): DynamicToolSet {
34
+ const auth = options?.auth;
35
+ const operations = options?.operations;
36
+ return defineDynamicTools({
37
+ description: `Tools served by the MCP server at ${endpoint}.`,
38
+ ...(options?.requirements === undefined ? {} : { requirements: options.requirements }),
39
+ discover: (ctx) => mcpDiscover(endpoint, ctx, auth, operations),
40
+ call: (tool, input, ctx) => mcpCall(endpoint, tool.name, input, ctx, auth),
41
+ });
42
+ }
@@ -0,0 +1,70 @@
1
+ /** fromOpenAPI sugar over the authored dynamic-tools primitive. */
2
+
3
+ import {
4
+ defineDynamicTools,
5
+ recordAuthoringProblem,
6
+ requirementSpecOf,
7
+ type AuthComposeFn,
8
+ type DynamicToolSet,
9
+ type OperationSelection,
10
+ type Requirement,
11
+ type RequiresAuthCompose,
12
+ } from "../authoring/index";
13
+ import { openapiCall, openapiDiscover } from "./mapper";
14
+
15
+ export type FromOpenAPIOptions<Reqs extends Readonly<Record<string, Requirement>>> = {
16
+ readonly requirements: Reqs;
17
+ readonly auth?: AuthComposeFn<NoInfer<Reqs>>;
18
+ readonly operations?: OperationSelection;
19
+ } & (RequiresAuthCompose<Reqs> extends true
20
+ ? { readonly auth: AuthComposeFn<NoInfer<Reqs>> }
21
+ : unknown);
22
+
23
+ const enforceSecretsPlacement = (
24
+ requirements: Readonly<Record<string, Requirement>> | undefined,
25
+ auth: AuthComposeFn<never> | undefined,
26
+ ): void => {
27
+ if (auth !== undefined) return;
28
+ for (const value of Object.values(requirements ?? {})) {
29
+ const spec = requirementSpecOf(value);
30
+ const methods = spec?.grant === undefined
31
+ ? Object.values(spec?.service.auth ?? {})
32
+ : [spec.service.auth?.[spec.grant]].filter((method) => method !== undefined);
33
+ if (methods.some((method) => method.kind === "secrets"))
34
+ recordAuthoringProblem(
35
+ "fromOpenAPI: the service offers a secrets() auth method, so 'auth' must state how the credential rides",
36
+ );
37
+ }
38
+ };
39
+
40
+ export type OpenAPIDocumentInput = string | Readonly<Record<string, unknown>>;
41
+
42
+ export function fromOpenAPI<const Reqs extends Readonly<Record<string, Requirement>>>(
43
+ spec: OpenAPIDocumentInput,
44
+ options: FromOpenAPIOptions<Reqs>,
45
+ ): DynamicToolSet;
46
+ export function fromOpenAPI(
47
+ spec: OpenAPIDocumentInput,
48
+ options?: { readonly operations?: OperationSelection },
49
+ ): DynamicToolSet;
50
+ export function fromOpenAPI(
51
+ spec: OpenAPIDocumentInput,
52
+ options?: {
53
+ readonly requirements?: Readonly<Record<string, Requirement>>;
54
+ readonly auth?: AuthComposeFn<never>;
55
+ readonly operations?: OperationSelection;
56
+ },
57
+ ): DynamicToolSet {
58
+ enforceSecretsPlacement(options?.requirements, options?.auth);
59
+ const auth = options?.auth;
60
+ const operations = options?.operations;
61
+ return defineDynamicTools({
62
+ description:
63
+ typeof spec === "string" && spec.startsWith("https://")
64
+ ? `Operations from the OpenAPI document at ${spec}.`
65
+ : "Operations from an OpenAPI document shipped with the app.",
66
+ ...(options?.requirements === undefined ? {} : { requirements: options.requirements }),
67
+ discover: (ctx) => openapiDiscover(spec, ctx, auth, operations),
68
+ call: (tool, input, ctx) => openapiCall(tool, input, ctx, auth),
69
+ });
70
+ }
@@ -0,0 +1,25 @@
1
+ /** Stub-contract OpenAPI discovery and call mapper. */
2
+
3
+ import type {
4
+ AuthComposeFn,
5
+ DiscoveredToolSpec,
6
+ OperationSelection,
7
+ } from "../authoring/index";
8
+
9
+ export const openapiDiscover = async (
10
+ _spec: string | Readonly<Record<string, unknown>>,
11
+ _ctx: unknown,
12
+ _auth: AuthComposeFn<never> | undefined,
13
+ _operations: OperationSelection | undefined,
14
+ ): Promise<readonly DiscoveredToolSpec[]> => {
15
+ throw new Error("openapi mapper not built yet");
16
+ };
17
+
18
+ export const openapiCall = async (
19
+ _tool: { readonly name: string; readonly detail: unknown },
20
+ _input: unknown,
21
+ _ctx: unknown,
22
+ _auth: AuthComposeFn<never> | undefined,
23
+ ): Promise<unknown> => {
24
+ throw new Error("openapi mapper not built yet");
25
+ };
@@ -4,8 +4,13 @@ import {
4
4
  type LoadedApp,
5
5
  type PublishError,
6
6
  } from "../manifest/index";
7
- import { callTool, discoverTool, type CallOutcome } from "../runtime/index";
8
- import { CallError, type AppRunner } from "./protocol";
7
+ import {
8
+ callTool,
9
+ discoverTool,
10
+ type CallOutcome,
11
+ type DiscoverOutcome,
12
+ } from "../runtime/index";
13
+ import { CallError, DiscoverError, type AppRunner } from "./protocol";
9
14
 
10
15
  export * from "./protocol";
11
16
 
@@ -30,28 +35,46 @@ export const localRunner = (appDir: string): AppRunner => {
30
35
  loaded().pipe(
31
36
  Effect.map(({ registry }) => registry),
32
37
  Effect.mapError(
33
- (error) => new CallError({ target, reason: `app failed to load (${error.message})` }),
38
+ (error) => new CallError({
39
+ target,
40
+ kind: "handler",
41
+ reason: `app failed to load (${error.message})`,
42
+ }),
34
43
  ),
35
44
  );
36
45
 
37
46
  const settle = (target: string, outcome: CallOutcome): Effect.Effect<unknown, CallError> =>
38
47
  outcome.ok
39
48
  ? Effect.succeed(outcome.value)
40
- : Effect.fail(new CallError({ target, reason: outcome.reason }));
49
+ : Effect.fail(new CallError({
50
+ target,
51
+ kind: outcome.kind,
52
+ reason: outcome.reason,
53
+ }));
54
+
55
+ const settleDiscovery = (
56
+ segment: string,
57
+ outcome: DiscoverOutcome,
58
+ ) => outcome.ok
59
+ ? Effect.succeed(outcome.value)
60
+ : Effect.fail(new DiscoverError({ segment, reason: outcome.reason }));
41
61
 
42
62
  return {
43
63
  manifest: () => Effect.map(loaded(), ({ result }) => result),
44
- call: (name, input, ctx) =>
45
- registryFor(name).pipe(
46
- Effect.flatMap((registry) => Effect.promise(() => callTool(registry, name, input, ctx))),
47
- Effect.flatMap((outcome) => settle(name, outcome)),
64
+ call: (address, input, ctx, detail) =>
65
+ registryFor(address).pipe(
66
+ Effect.flatMap((registry) =>
67
+ Effect.promise(() => callTool(registry, address, input, ctx, detail)),
68
+ ),
69
+ Effect.flatMap((outcome) => settle(address, outcome)),
48
70
  ),
49
- discover: (name, binding) =>
50
- registryFor(name).pipe(
71
+ discover: (segment, ctx) =>
72
+ registryFor(segment).pipe(
73
+ Effect.mapError((error) => new DiscoverError({ segment, reason: error.reason })),
51
74
  Effect.flatMap((registry) =>
52
- Effect.promise(() => discoverTool(registry, name, binding)),
75
+ Effect.promise(() => discoverTool(registry, segment, ctx)),
53
76
  ),
54
- Effect.flatMap((outcome) => settle(name, outcome)),
77
+ Effect.flatMap((outcome) => settleDiscovery(segment, outcome)),
55
78
  ),
56
79
  };
57
80
  };
@@ -1,12 +1,26 @@
1
1
  import { Data, type Effect, FileSystem } from "effect";
2
2
  import type { AppManifestResult, PublishError } from "../manifest/contract";
3
- import type { ToolCtx, ToolCtxEntry } from "../runtime/index";
3
+ import type {
4
+ CallFailureKind,
5
+ DiscoveredTool,
6
+ ToolCtx,
7
+ ToolCtxEntry,
8
+ } from "../runtime/index";
4
9
 
5
- export type { ToolCtx, ToolCtxEntry } from "../runtime/index";
10
+ export type {
11
+ DiscoveredTool,
12
+ ToolCtx,
13
+ ToolCtxEntry,
14
+ ToolDescriptor,
15
+ } from "../runtime/index";
6
16
 
7
- /** A runner operation failure associated with its target. */
17
+ /** Which invocation phase failed. */
18
+ export type CallErrorKind = CallFailureKind;
19
+
20
+ /** A runner operation failure associated with its target and phase. */
8
21
  export class CallError extends Data.TaggedError("CallError")<{
9
22
  readonly target: string;
23
+ readonly kind: CallErrorKind;
10
24
  readonly reason: string;
11
25
  }> {
12
26
  override get message(): string {
@@ -14,16 +28,27 @@ export class CallError extends Data.TaggedError("CallError")<{
14
28
  }
15
29
  }
16
30
 
31
+ /** A discovery failure associated with one dynamic segment. */
32
+ export class DiscoverError extends Data.TaggedError("DiscoverError")<{
33
+ readonly segment: string;
34
+ readonly reason: string;
35
+ }> {
36
+ override get message(): string {
37
+ return `${this.segment}: ${this.reason}`;
38
+ }
39
+ }
40
+
17
41
  /** The platform protocol implemented by an app execution environment. */
18
42
  export interface AppRunner {
19
43
  manifest(): Effect.Effect<AppManifestResult, PublishError, FileSystem.FileSystem>;
20
44
  call(
21
- name: string,
45
+ address: string,
22
46
  input: unknown,
23
47
  ctx: ToolCtx,
48
+ detail?: unknown,
24
49
  ): Effect.Effect<unknown, CallError, FileSystem.FileSystem>;
25
50
  discover(
26
- name: string,
27
- binding: unknown,
28
- ): Effect.Effect<unknown, CallError, FileSystem.FileSystem>;
51
+ segment: string,
52
+ ctx: ToolCtx,
53
+ ): Effect.Effect<readonly DiscoveredTool[], DiscoverError, FileSystem.FileSystem>;
29
54
  }
@@ -12,12 +12,17 @@
12
12
  * and workerd all provide.
13
13
  */
14
14
 
15
- import type { Registry, ToolSpec } from "../authoring/index";
15
+ import type {
16
+ DiscoveredToolSpec,
17
+ DynamicToolsSpec,
18
+ Registry,
19
+ } from "../authoring/index";
16
20
  import type {
17
21
  AppManifestResult,
18
22
  CollectedClaim,
19
23
  CollectedRequirement,
20
24
  JsonSchemaObject,
25
+ ManifestDynamic,
21
26
  ManifestTool,
22
27
  } from "../manifest/contract";
23
28
 
@@ -26,7 +31,10 @@ import type {
26
31
  /** The capability exposed to one tool requirement role. */
27
32
  export interface ToolCtxEntry {
28
33
  readonly grant: string;
34
+ readonly kind: "bearer" | "basic" | "secrets" | "oauth";
29
35
  readonly token?: string;
36
+ readonly username?: string;
37
+ readonly password?: string;
30
38
  readonly secrets?: Readonly<Record<string, string>>;
31
39
  fetch(path: string, init?: RequestInit): Promise<Response>;
32
40
  }
@@ -41,12 +49,46 @@ export type ManifestOutcome =
41
49
  | { readonly ok: true; readonly result: AppManifestResult }
42
50
  | { readonly ok: false; readonly problems: readonly string[] };
43
51
 
52
+ /** Which invocation phase failed. */
53
+ export type CallFailureKind = "input" | "handler";
54
+
44
55
  /** One tool invocation's result envelope. */
45
56
  export type CallOutcome =
46
57
  | { readonly ok: true; readonly value: unknown }
58
+ | {
59
+ readonly ok: false;
60
+ readonly kind: CallFailureKind;
61
+ readonly reason: string;
62
+ };
63
+
64
+ /** Agent-facing descriptor compiled from one discovery result. */
65
+ export interface ToolDescriptor {
66
+ readonly description: string;
67
+ readonly inputSchema: JsonSchemaObject;
68
+ readonly annotations: {
69
+ readonly readOnly?: boolean;
70
+ readonly destructive?: boolean;
71
+ };
72
+ }
73
+
74
+ /** One tool emitted by a dynamic segment discovery. */
75
+ export interface DiscoveredTool {
76
+ readonly segment: string;
77
+ readonly name: string;
78
+ readonly descriptor: ToolDescriptor;
79
+ readonly detail: unknown;
80
+ }
81
+
82
+ /** One dynamic discovery result envelope. */
83
+ export type DiscoverOutcome =
84
+ | { readonly ok: true; readonly value: readonly DiscoveredTool[] }
47
85
  | { readonly ok: false; readonly reason: string };
48
86
 
49
- const failure = (reason: string): CallOutcome => ({ ok: false, reason });
87
+ const failure = (kind: CallFailureKind, reason: string): CallOutcome => ({
88
+ ok: false,
89
+ kind,
90
+ reason,
91
+ });
50
92
 
51
93
  const errorMessage = (error: unknown): string =>
52
94
  error instanceof Error ? error.message : String(error);
@@ -106,7 +148,7 @@ type InputSchemaCompilation =
106
148
  | { readonly value: JsonSchemaObject }
107
149
  | { readonly problem: string };
108
150
 
109
- const compileInputSchema = (input: object, where: string): InputSchemaCompilation => {
151
+ export const compileInputSchema = (input: object, where: string): InputSchemaCompilation => {
110
152
  const standard = standardJsonSchemaOf(input);
111
153
  if ("~standard" in input && typeof standard?.input !== "function")
112
154
  return {
@@ -152,6 +194,11 @@ const requirementSpecOf = (value: unknown): RequirementSpecShape | undefined =>
152
194
  ? ((value as Record<PropertyKey, unknown>)[requirementKey] as RequirementSpecShape)
153
195
  : undefined;
154
196
 
197
+ const segmentOf = (file: string): string => {
198
+ const name = file.slice(file.lastIndexOf("/") + 1);
199
+ return name.endsWith(".ts") ? name.slice(0, -3) : name;
200
+ };
201
+
155
202
  const canonicalJson = (value: unknown): string => {
156
203
  const sortKeys = (node: unknown): unknown => {
157
204
  if (Array.isArray(node)) return node.map(sortKeys);
@@ -170,6 +217,7 @@ interface CollectedManifest {
170
217
  readonly app: { readonly key: string; readonly label: string; readonly description: string };
171
218
  readonly services: readonly CollectedClaim[];
172
219
  readonly tools: readonly ManifestTool[];
220
+ readonly dynamic: readonly ManifestDynamic[];
173
221
  }
174
222
 
175
223
  type RegistryCollection =
@@ -229,6 +277,8 @@ const collectRegistry = (registry: Registry): RegistryCollection => {
229
277
  for (const [file, summary] of registry.toolFiles) {
230
278
  if (summary.definitions > 1 && summary.hasUnnamedTool)
231
279
  problems.push(`${file}: multi-tool files must name every tool`);
280
+ if (registry.dynamic.has(file))
281
+ problems.push(`${file}: a file claims a dynamic tool set or defines tools, never both`);
232
282
  }
233
283
 
234
284
  const tools: ManifestTool[] = [];
@@ -261,13 +311,47 @@ const collectRegistry = (registry: Registry): RegistryCollection => {
261
311
  }
262
312
  }
263
313
 
314
+ const dynamic: ManifestDynamic[] = [];
315
+ const seenSegments = new Map<string, string>();
316
+ for (const [file, registered] of registry.dynamic) {
317
+ const segment = segmentOf(file);
318
+ const where = `${file}: dynamic tool set '${segment}'`;
319
+ const existing = seenSegments.get(segment);
320
+ if (existing !== undefined) {
321
+ problems.push(`${where}: segment already claimed by ${existing}`);
322
+ continue;
323
+ }
324
+ seenSegments.set(segment, file);
325
+
326
+ const requires = Object.entries(registered.spec.requirements ?? {})
327
+ .flatMap(([role, requirement]) => {
328
+ const collected = collectRequirement(role, requirement, where);
329
+ return collected === undefined ? [] : [collected];
330
+ })
331
+ .sort((left, right) => left.role.localeCompare(right.role));
332
+ dynamic.push({
333
+ segment,
334
+ description: registered.spec.description,
335
+ requires,
336
+ file,
337
+ });
338
+ }
339
+
264
340
  if (problems.length > 0 || app === undefined) return { problems };
265
341
  const services = [...claims.values()].sort((left, right) =>
266
342
  left.origin.localeCompare(right.origin),
267
343
  );
268
344
  const sortedTools = [...tools].sort((left, right) => left.address.localeCompare(right.address));
345
+ const sortedDynamic = [...dynamic].sort((left, right) =>
346
+ left.segment.localeCompare(right.segment),
347
+ );
269
348
  const appResult = { key: app.key, label: app.label, description: app.description };
270
- const manifest = { app: appResult, services, tools: sortedTools } satisfies CollectedManifest;
349
+ const manifest = {
350
+ app: appResult,
351
+ services,
352
+ tools: sortedTools,
353
+ dynamic: sortedDynamic,
354
+ } satisfies CollectedManifest;
271
355
  return { problems: [], manifest, canonical: canonicalJson(manifest) };
272
356
  };
273
357
 
@@ -314,8 +398,8 @@ type ParsedInput =
314
398
  | { readonly ok: true; readonly value: unknown }
315
399
  | { readonly ok: false; readonly reason: string };
316
400
 
317
- const parseInput = async (spec: ToolSpec, input: unknown): Promise<ParsedInput> => {
318
- const validate = standardValidateOf(spec.input);
401
+ const parseInput = async (schema: object, input: unknown): Promise<ParsedInput> => {
402
+ const validate = standardValidateOf(schema);
319
403
  if (validate === undefined)
320
404
  return { ok: false, reason: "input schema does not implement Standard Schema validate" };
321
405
  try {
@@ -331,33 +415,136 @@ const parseInput = async (spec: ToolSpec, input: unknown): Promise<ParsedInput>
331
415
  }
332
416
  };
333
417
 
334
- /** Validate input and run one registered tool's handler. */
418
+ const splitDynamicAddress = (
419
+ address: string,
420
+ ): { readonly segment: string; readonly name: string } | undefined => {
421
+ const slash = address.indexOf("/");
422
+ if (slash <= 0 || slash === address.length - 1) return undefined;
423
+ return { segment: address.slice(0, slash), name: address.slice(slash + 1) };
424
+ };
425
+
426
+ const dynamicFor = (
427
+ registry: Registry,
428
+ segment: string,
429
+ ): DynamicToolsSpec | undefined => {
430
+ for (const [file, registered] of registry.dynamic)
431
+ if (segmentOf(file) === segment) return registered.spec;
432
+ return undefined;
433
+ };
434
+
435
+ /** Live schemas from the latest successful discovery, retained per loaded registry. */
436
+ const discoveredSpecs = new WeakMap<
437
+ Registry,
438
+ Map<string, ReadonlyMap<string, DiscoveredToolSpec>>
439
+ >();
440
+
441
+ /** Validate input and run one static or dynamic tool handler. */
335
442
  export const callTool = async (
336
443
  registry: Registry,
337
- name: string,
444
+ address: string,
338
445
  input: unknown,
339
446
  ctx: ToolCtx,
447
+ detail?: unknown,
340
448
  ): Promise<CallOutcome> => {
341
- const registered = registry.tools.get(name);
342
- if (registered === undefined) return failure("unknown tool");
343
- const parsed = await parseInput(registered.spec, input);
344
- if (!parsed.ok) return parsed;
449
+ const dynamic = splitDynamicAddress(address);
450
+ if (dynamic === undefined) {
451
+ if (detail !== undefined)
452
+ return failure("handler", "a static tool takes no discovery detail");
453
+ const registered = registry.tools.get(address);
454
+ if (registered === undefined) return failure("handler", "unknown tool");
455
+ const parsed = await parseInput(registered.spec.input, input);
456
+ if (!parsed.ok) return failure("input", parsed.reason);
457
+ try {
458
+ // SAFETY: ToolSpec intentionally erases authored handler inputs to keep
459
+ // authoring generic-free; validation and ToolCtx establish this call boundary.
460
+ const handler = registered.spec.handler as (
461
+ value: unknown,
462
+ toolCtx: ToolCtx,
463
+ ) => Promise<unknown>;
464
+ return { ok: true, value: await handler(parsed.value, ctx) };
465
+ } catch (error) {
466
+ return failure("handler", `handler failed (${errorMessage(error)})`);
467
+ }
468
+ }
469
+
470
+ if (detail === undefined)
471
+ return failure("handler", "a dynamic tool requires discovery detail");
472
+ const pair = dynamicFor(registry, dynamic.segment);
473
+ if (pair === undefined)
474
+ return failure("handler", `unknown dynamic tool set '${dynamic.segment}'`);
475
+ const discovered = discoveredSpecs.get(registry)?.get(dynamic.segment)?.get(dynamic.name);
476
+ if (discovered === undefined)
477
+ return failure(
478
+ "input",
479
+ `dynamic tool '${dynamic.name}' has no compiled discovery descriptor`,
480
+ );
481
+ const parsed = await parseInput(discovered.input, input);
482
+ if (!parsed.ok) return failure("input", parsed.reason);
345
483
  try {
346
- // SAFETY: ToolSpec intentionally erases authored handler inputs to keep
347
- // authoring generic-free; validation and ToolCtx establish this call boundary.
348
- const handler = registered.spec.handler as (
484
+ const call = pair.call as (
485
+ tool: { readonly name: string; readonly detail: unknown },
349
486
  value: unknown,
350
487
  toolCtx: ToolCtx,
351
488
  ) => Promise<unknown>;
352
- return { ok: true, value: await handler(parsed.value, ctx) };
489
+ return {
490
+ ok: true,
491
+ value: await call({ name: dynamic.name, detail }, parsed.value, ctx),
492
+ };
353
493
  } catch (error) {
354
- return failure(`handler failed (${errorMessage(error)})`);
494
+ return failure("handler", `dynamic call failed (${errorMessage(error)})`);
355
495
  }
356
496
  };
357
497
 
358
- /** Discover one dynamic tool set's current segment. Unbuilt: always fails. */
498
+ /** Discover and compile one dynamic segment. */
359
499
  export const discoverTool = async (
360
- _registry: Registry,
361
- _name: string,
362
- _binding: unknown,
363
- ): Promise<CallOutcome> => failure("no dynamic tools yet");
500
+ registry: Registry,
501
+ segment: string,
502
+ ctx: ToolCtx,
503
+ ): Promise<DiscoverOutcome> => {
504
+ const pair = dynamicFor(registry, segment);
505
+ if (pair === undefined)
506
+ return { ok: false, reason: `unknown dynamic tool set '${segment}'` };
507
+ discoveredSpecs.get(registry)?.delete(segment);
508
+
509
+ let emitted: readonly DiscoveredToolSpec[];
510
+ try {
511
+ const discover = pair.discover as (
512
+ toolCtx: ToolCtx,
513
+ ) => Promise<readonly DiscoveredToolSpec[]>;
514
+ emitted = await discover(ctx);
515
+ } catch (error) {
516
+ return { ok: false, reason: `discover failed (${errorMessage(error)})` };
517
+ }
518
+
519
+ const tools: DiscoveredTool[] = [];
520
+ const live = new Map<string, DiscoveredToolSpec>();
521
+ for (const tool of emitted) {
522
+ if (live.has(tool.name))
523
+ return { ok: false, reason: `discover emitted duplicate tool '${tool.name}'` };
524
+ const compiled = compileInputSchema(tool.input, `${segment}/${tool.name}`);
525
+ if ("problem" in compiled) return { ok: false, reason: compiled.problem };
526
+ live.set(tool.name, tool);
527
+ tools.push({
528
+ segment,
529
+ name: tool.name,
530
+ descriptor: {
531
+ description: tool.description,
532
+ inputSchema: compiled.value,
533
+ annotations: {
534
+ ...(tool.annotations?.readOnly === undefined
535
+ ? {}
536
+ : { readOnly: tool.annotations.readOnly }),
537
+ ...(tool.annotations?.destructive === undefined
538
+ ? {}
539
+ : { destructive: tool.annotations.destructive }),
540
+ },
541
+ },
542
+ detail: tool.detail ?? null,
543
+ });
544
+ }
545
+
546
+ const registryDiscoveries = discoveredSpecs.get(registry) ?? new Map();
547
+ registryDiscoveries.set(segment, live);
548
+ discoveredSpecs.set(registry, registryDiscoveries);
549
+ return { ok: true, value: tools.sort((left, right) => left.name.localeCompare(right.name)) };
550
+ };
@@ -9,6 +9,8 @@ export {
9
9
  discoverTool,
10
10
  manifestOf,
11
11
  type CallOutcome,
12
+ type DiscoverOutcome,
13
+ type DiscoveredTool,
12
14
  type ManifestOutcome,
13
15
  type ToolCtx,
14
16
  type ToolCtxEntry,