diff --git a/packages/kernel-utils/CHANGELOG.md b/packages/kernel-utils/CHANGELOG.md index e03168b0f..56c5e767b 100644 --- a/packages/kernel-utils/CHANGELOG.md +++ b/packages/kernel-utils/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add a `./described` export with a combinator namespace `S` (`S.string`/`S.number`/`S.boolean`/`S.arrayOf`/`S.record`/`S.object`/`S.nothing` leaves, plus `S.arg`/`S.method`/`S.interface`) that authors an `@endo/patterns` interface guard and a matching `MethodSchema` from a single source, so a discoverable exo's enforced shape and its `__getDescription__` hint cannot drift ([#958](https://github.com/MetaMask/ocap-kernel/pull/958)) +- Add an optional `required` field to `MethodSchema` (mirroring `required` on object `JsonSchema`) naming which arguments are required, and a `{ required }` option on `methodArgsToStruct` that validates unlisted arguments as optional, so a method's argument schema can faithfully represent the optional trailing arguments its guard already allows ([#958](https://github.com/MetaMask/ocap-kernel/pull/958)) - Add `getLibp2pRelayHome()` to the `./nodejs` exports, returning the libp2p relay's bookkeeping directory (default `~/.libp2p-relay`, overridable via `$LIBP2P_RELAY_HOME`) — kept separate from `$OCAP_HOME` so one relay can serve daemons with different OCAP_HOMEs ([#952](https://github.com/MetaMask/ocap-kernel/pull/952)) - `startRelay()` accepts an optional `publicIp` that is fed to libp2p's `appendAnnounce`, so a relay running on a NAT-backed host can announce its publicly-reachable IPv4 alongside its bound NIC addresses ([#952](https://github.com/MetaMask/ocap-kernel/pull/952)) diff --git a/packages/kernel-utils/package.json b/packages/kernel-utils/package.json index b57ed11fb..9ea10f398 100644 --- a/packages/kernel-utils/package.json +++ b/packages/kernel-utils/package.json @@ -59,6 +59,16 @@ "default": "./dist/discoverable.cjs" } }, + "./described": { + "import": { + "types": "./dist/described.d.mts", + "default": "./dist/described.mjs" + }, + "require": { + "types": "./dist/described.d.cts", + "default": "./dist/described.cjs" + } + }, "./libp2p": { "import": { "types": "./dist/libp2p-relay.d.mts", diff --git a/packages/kernel-utils/src/described.test.ts b/packages/kernel-utils/src/described.test.ts new file mode 100644 index 000000000..25abbbd90 --- /dev/null +++ b/packages/kernel-utils/src/described.test.ts @@ -0,0 +1,201 @@ +import { + matches, + getInterfaceGuardPayload, + getMethodGuardPayload, +} from '@endo/patterns'; +import { describe, expect, it } from 'vitest'; + +import { S } from './described.ts'; + +type MethodGuardPayload = { + argGuards: unknown[]; + optionalArgGuards?: unknown[]; + returnGuard: unknown; +}; + +const payloadOf = (guard: unknown): MethodGuardPayload => + getMethodGuardPayload(guard as never) as unknown as MethodGuardPayload; + +describe('leaves', () => { + it.each([ + { + name: 'string', + described: S.string('a word'), + schema: { type: 'string', description: 'a word' }, + ok: 'hello', + bad: 42, + }, + { + name: 'number', + described: S.number(), + schema: { type: 'number' }, + ok: 42, + bad: 'hello', + }, + { + name: 'boolean', + described: S.boolean(), + schema: { type: 'boolean' }, + ok: true, + bad: 1, + }, + ])( + 'builds a $name leaf whose pattern and schema agree', + ({ described, schema, ok, bad }) => { + expect(described.schema).toStrictEqual(schema); + expect(matches(ok, described.pattern)).toBe(true); + expect(matches(bad, described.pattern)).toBe(false); + }, + ); + + it('builds an arrayOf leaf', () => { + const described = S.arrayOf(S.number(), 'the summands'); + expect(described.schema).toStrictEqual({ + type: 'array', + items: { type: 'number' }, + description: 'the summands', + }); + expect(matches([1, 2, 3], described.pattern)).toBe(true); + expect(matches(['a'], described.pattern)).toBe(false); + }); + + it('builds an open record leaf that allows any keys', () => { + const described = S.record('attachments'); + expect(described.schema).toStrictEqual({ + type: 'object', + properties: {}, + additionalProperties: true, + description: 'attachments', + }); + expect(matches({ anything: 1, goes: 'here' }, described.pattern)).toBe( + true, + ); + expect(matches(42, described.pattern)).toBe(false); + }); + + it('builds a closed object leaf with required and optional properties', () => { + const described = S.object( + { id: S.string(), label: S.string() }, + { optional: ['label'] }, + ); + expect(described.schema).toStrictEqual({ + type: 'object', + properties: { id: { type: 'string' }, label: { type: 'string' } }, + required: ['id'], + additionalProperties: false, + }); + expect(matches({ id: 'x' }, described.pattern)).toBe(true); + expect(matches({ id: 'x', label: 'y' }, described.pattern)).toBe(true); + expect(matches({ label: 'y' }, described.pattern)).toBe(false); + }); + + it('rejects extra keys on a closed object leaf', () => { + const described = S.object({ id: S.string() }); + expect(matches({ id: 'x' }, described.pattern)).toBe(true); + expect(matches({ id: 'x', extra: 1 }, described.pattern)).toBe(false); + }); + + it('builds a void return leaf with no schema', () => { + const described = S.nothing(); + expect(described.schema).toBeUndefined(); + expect(matches(undefined, described.pattern)).toBe(true); + expect(matches('something', described.pattern)).toBe(false); + }); +}); + +describe('S.method', () => { + it('builds a guard and schema from named args', () => { + const method = S.method( + 'Add a list of numbers.', + [S.arg('summands', S.arrayOf(S.number()))], + S.number('The sum of the numbers.'), + ); + expect(method.schema).toStrictEqual({ + description: 'Add a list of numbers.', + args: { summands: { type: 'array', items: { type: 'number' } } }, + required: ['summands'], + returns: { type: 'number', description: 'The sum of the numbers.' }, + }); + const payload = payloadOf(method.guard); + expect(payload.argGuards).toHaveLength(1); + expect(payload.optionalArgGuards ?? []).toHaveLength(0); + }); + + it('omits `returns` from the schema for a void method', () => { + const method = S.method( + 'Return a final response.', + [S.arg('final', S.string())], + S.nothing(), + ); + expect(method.schema.returns).toBeUndefined(); + expect('returns' in method.schema).toBe(false); + }); + + it('places optional args in the guard as trailing optionals', () => { + const method = S.method( + 'Return a final response.', + [ + S.arg('final', S.string()), + S.arg('attachments', S.record(), { optional: true }), + ], + S.nothing(), + ); + const payload = payloadOf(method.guard); + expect(payload.argGuards).toHaveLength(1); + expect(payload.optionalArgGuards).toHaveLength(1); + expect(method.schema.args).toStrictEqual({ + final: { type: 'string' }, + attachments: { + type: 'object', + properties: {}, + additionalProperties: true, + }, + }); + // The optional arg is omitted from `required`, so the schema's enforced + // shape matches the guard's optional trailing arg. + expect(method.schema.required).toStrictEqual(['final']); + }); + + it('handles a no-arg method', () => { + const method = S.method('Get the moon phase.', [], S.string()); + expect(method.schema.args).toStrictEqual({}); + expect(payloadOf(method.guard).argGuards).toHaveLength(0); + }); + + it('throws when an optional argument precedes a required one', () => { + expect(() => + S.method( + 'bad', + [S.arg('a', S.string(), { optional: true }), S.arg('b', S.string())], + S.nothing(), + ), + ).toThrow(/optional arguments must be trailing/u); + }); +}); + +describe('S.interface', () => { + it('collects method guards and schemas, defaulting unlisted methods to passable', () => { + const { interfaceGuard, schemas } = S.interface('Math', { + add: S.method( + 'Add a list of numbers.', + [S.arg('summands', S.arrayOf(S.number()))], + S.number('The sum of the numbers.'), + ), + count: S.method( + 'Count characters.', + [S.arg('word', S.string('The string to measure.'))], + S.number(), + ), + }); + + expect(Object.keys(schemas)).toStrictEqual(['add', 'count']); + const payload = getInterfaceGuardPayload(interfaceGuard) as unknown as { + interfaceName: string; + methodGuards: Record; + defaultGuards?: string; + }; + expect(payload.interfaceName).toBe('Math'); + expect(Object.keys(payload.methodGuards)).toStrictEqual(['add', 'count']); + expect(payload.defaultGuards).toBe('passable'); + }); +}); diff --git a/packages/kernel-utils/src/described.ts b/packages/kernel-utils/src/described.ts new file mode 100644 index 000000000..2b235d02a --- /dev/null +++ b/packages/kernel-utils/src/described.ts @@ -0,0 +1,307 @@ +import { M } from '@endo/patterns'; +import type { InterfaceGuard, MethodGuard, Pattern } from '@endo/patterns'; + +import type { JsonSchema, MethodSchema } from './schema.ts'; + +/** + * A described value: an `@endo/patterns` {@link Pattern} (the enforced shape) paired + * with the {@link JsonSchema} that hangs descriptive text on that shape. + * + * The pattern is the source of truth for the invocable shape; the schema is a + * semantic-hint projection. Authoring both from one leaf is what makes their + * conformance a construction invariant rather than an after-the-fact check. + */ +export type Described = { + pattern: Pattern; + schema: JsonSchema; +}; + +/** + * Like {@link Described}, but the schema may be absent — used for a method's + * return position, where a `void` return has a pattern ({@link M.undefined}) but + * no JSON Schema counterpart (JSON Schema cannot express `void`/`undefined`). + */ +export type DescribedReturn = { + pattern: Pattern; + schema: JsonSchema | undefined; +}; + +/** + * A named positional method parameter: a {@link Described} value plus the name + * under which it is hung in the method's {@link MethodSchema.args} and, after + * discovery, the key by which a caller supplies it. + */ +export type NamedArg = { + name: string; + described: Described; + optional: boolean; +}; + +/** + * A described method: the {@link MethodGuard} that enforces its call shape and + * the {@link MethodSchema} that describes it. Both are projected from the same + * authored leaves, so they cannot drift. + */ +export type DescribedMethod = { + guard: MethodGuard; + schema: MethodSchema; +}; + +/** + * A described interface: the {@link InterfaceGuard} that the exo membrane + * enforces, and the per-method {@link MethodSchema} map to pass as the + * `__getDescription__` payload. Splat both into `makeDiscoverableExo`. + */ +export type DescribedInterface = { + interfaceGuard: InterfaceGuard; + schemas: Record; +}; + +const withDescription = ( + schema: JsonSchema, + description: string | undefined, +): JsonSchema => + description === undefined ? schema : { ...schema, description }; + +/** + * A string leaf: matches a string; describes `{ type: 'string' }`. + * + * @param description - Optional human/LLM-facing description. + * @returns The described string. + */ +const string = (description?: string): Described => + harden({ + pattern: M.string(), + schema: withDescription({ type: 'string' }, description), + }); + +/** + * A number leaf: matches a number; describes `{ type: 'number' }`. + * + * @param description - Optional human/LLM-facing description. + * @returns The described number. + */ +const number = (description?: string): Described => + harden({ + pattern: M.number(), + schema: withDescription({ type: 'number' }, description), + }); + +/** + * A boolean leaf: matches a boolean; describes `{ type: 'boolean' }`. + * + * @param description - Optional human/LLM-facing description. + * @returns The described boolean. + */ +const boolean = (description?: string): Described => + harden({ + pattern: M.boolean(), + schema: withDescription({ type: 'boolean' }, description), + }); + +/** + * An array leaf: matches an array whose elements match `items`; describes + * `{ type: 'array', items }`. + * + * @param items - The described element type. + * @param description - Optional human/LLM-facing description. + * @returns The described array. + */ +const arrayOf = (items: Described, description?: string): Described => + harden({ + pattern: M.arrayOf(items.pattern), + schema: withDescription( + { type: 'array', items: items.schema }, + description, + ), + }); + +/** + * An open object leaf: matches any record (extra keys allowed); describes + * `{ type: 'object', properties: {}, additionalProperties: true }`. + * + * Use when the shape is genuinely open (e.g. free-form attachments). + * + * @param description - Optional human/LLM-facing description. + * @returns The described open object. + */ +const record = (description?: string): Described => + harden({ + pattern: M.record(), + schema: withDescription( + { type: 'object', properties: {}, additionalProperties: true }, + description, + ), + }); + +/** + * A closed/shaped object leaf: matches a record with exactly the given + * properties (extra keys are rejected), where keys not listed in `optional` are + * required. Describes `{ type: 'object', properties, required, + * additionalProperties: false }`. + * + * @param properties - The described properties, keyed by name. + * @param options - Options bag. + * @param options.optional - Property names that may be omitted. + * @param options.description - Optional human/LLM-facing description. + * @returns The described object. + */ +const object = ( + properties: Record, + options: { optional?: string[]; description?: string } = {}, +): Described => { + const { optional = [], description } = options; + const optionalSet = new Set(optional); + const requiredPatterns: Record = {}; + const optionalPatterns: Record = {}; + const schemaProperties: Record = {}; + const required: string[] = []; + for (const [key, described] of Object.entries(properties)) { + schemaProperties[key] = described.schema; + if (optionalSet.has(key)) { + optionalPatterns[key] = described.pattern; + } else { + requiredPatterns[key] = described.pattern; + required.push(key); + } + } + return harden({ + // The empty-record rest pattern closes the record: keys beyond those listed + // are rejected, matching the schema's `additionalProperties: false`. + pattern: M.splitRecord(requiredPatterns, optionalPatterns, {}), + schema: withDescription( + { + type: 'object', + properties: schemaProperties, + required, + additionalProperties: false, + }, + description, + ), + }); +}; + +/** + * The void return leaf: matches `undefined` (an async method that resolves to + * nothing); has no JSON Schema counterpart. + * + * @returns The described void return. + */ +const nothing = (): DescribedReturn => + harden({ pattern: M.undefined(), schema: undefined }); + +/** + * Name a positional method parameter. + * + * @param name - The argument name (its key in {@link MethodSchema.args}). + * @param described - The described value at this position. + * @param options - Options bag. + * @param options.optional - Whether the argument may be omitted. Optional + * arguments must be trailing (enforced by {@link describedMethod}). + * @returns The named argument. + */ +const arg = ( + name: string, + described: Described, + options: { optional?: boolean } = {}, +): NamedArg => harden({ name, described, optional: options.optional ?? false }); + +/** + * Describe a method: build the {@link MethodGuard} (async, via `M.callWhen`, + * since discoverable-exo methods are invoked across an eventual-send boundary) + * and the matching {@link MethodSchema} from the same arguments. + * + * @param description - The method's description. + * @param args - The positional, named arguments. Optional arguments must all be + * trailing — `M.call(...).optional(...)` is positional, so an optional argument + * before a required one cannot be expressed. + * @param returns - The described return value (use {@link nothing} for `void`). + * @returns The described method. + */ +const describedMethod = ( + description: string, + args: NamedArg[], + returns: DescribedReturn, +): DescribedMethod => { + const firstOptional = args.findIndex((each) => each.optional); + if ( + firstOptional !== -1 && + args.slice(firstOptional).some((each) => !each.optional) + ) { + throw new Error( + 'describedMethod: optional arguments must be trailing (a required argument cannot follow an optional one).', + ); + } + + const required = args.filter((each) => !each.optional); + const optional = args.filter((each) => each.optional); + const base = M.callWhen(...required.map((each) => each.described.pattern)); + const guard = + optional.length > 0 + ? base + .optional(...optional.map((each) => each.described.pattern)) + .returns(returns.pattern) + : base.returns(returns.pattern); + + const schemaArgs: Record = {}; + for (const each of args) { + schemaArgs[each.name] = each.described.schema; + } + const schema: MethodSchema = { + description, + args: schemaArgs, + required: required.map((each) => each.name), + ...(returns.schema === undefined ? {} : { returns: returns.schema }), + }; + + return harden({ guard, schema }); +}; + +/** + * Describe an interface: collect method guards into an {@link InterfaceGuard} + * and method schemas into the `__getDescription__` payload. + * + * The guard uses `defaultGuards: 'passable'` so the `__getDescription__` method + * that `makeDiscoverableExo` injects (and which is not listed here) is allowed. + * + * @param name - The interface name. + * @param methods - The described methods, keyed by method name. + * @returns The interface guard and the per-method schema map. + */ +const describedInterface = ( + name: string, + methods: Record, +): DescribedInterface => { + const methodGuards: Record = {}; + const schemas: Record = {}; + for (const [methodName, method] of Object.entries(methods)) { + methodGuards[methodName] = method.guard; + schemas[methodName] = method.schema; + } + const interfaceGuard = M.interface(name, methodGuards, { + defaultGuards: 'passable', + }); + return harden({ interfaceGuard, schemas }); +}; + +/** + * Combinators for authoring an `@endo/patterns` guard and a {@link MethodSchema} + * description from a single source, so the two cannot drift. + * + * Leaves (`string`, `number`, `boolean`, `arrayOf`, `record`, `object`, + * `nothing`) each yield a `{ pattern, schema }` pair; `arg` names a positional + * parameter; `method` and `interface` assemble them. + */ +// eslint-disable-next-line id-length -- `S` is the intended terse public namespace, mirroring `@endo/patterns`'s `M`. +export const S = harden({ + string, + number, + boolean, + arrayOf, + record, + object, + nothing, + arg, + method: describedMethod, + interface: describedInterface, +}); diff --git a/packages/kernel-utils/src/index.test.ts b/packages/kernel-utils/src/index.test.ts index cc1985bc4..d3b32f36c 100644 --- a/packages/kernel-utils/src/index.test.ts +++ b/packages/kernel-utils/src/index.test.ts @@ -11,6 +11,7 @@ describe('index', () => { 'DEFAULT_MAX_RETRY_ATTEMPTS', 'EmptyJsonArray', 'GET_DESCRIPTION', + 'S', 'abortableDelay', 'calculateReconnectionBackoff', 'delay', diff --git a/packages/kernel-utils/src/index.ts b/packages/kernel-utils/src/index.ts index bc895d4a1..e7c57b202 100644 --- a/packages/kernel-utils/src/index.ts +++ b/packages/kernel-utils/src/index.ts @@ -2,6 +2,14 @@ export { prettifySmallcaps } from './prettify-smallcaps.ts'; export { makeDefaultInterface, makeDefaultExo } from './exo.ts'; export { GET_DESCRIPTION, makeDiscoverableExo } from './discoverable.ts'; export type { DiscoverableExo } from './discoverable.ts'; +export { S } from './described.ts'; +export type { + Described, + DescribedReturn, + NamedArg, + DescribedMethod, + DescribedInterface, +} from './described.ts'; export type { JsonSchema, MethodSchema } from './schema.ts'; export { jsonSchemaToStruct, diff --git a/packages/kernel-utils/src/json-schema-to-struct.test.ts b/packages/kernel-utils/src/json-schema-to-struct.test.ts index 3d25a7d21..8530c21c1 100644 --- a/packages/kernel-utils/src/json-schema-to-struct.test.ts +++ b/packages/kernel-utils/src/json-schema-to-struct.test.ts @@ -63,6 +63,19 @@ describe('methodArgsToStruct', () => { expect(() => assert({ a: 1 }, struct)).toThrow(/path: b/u); }); + it('treats args absent from `required` as optional', () => { + const struct = methodArgsToStruct( + { + final: { type: 'string' }, + attachments: { type: 'object', properties: {} }, + }, + { required: ['final'] }, + ); + assert({ final: 'done' }, struct); + assert({ final: 'done', attachments: { note: 1 } }, struct); + expect(() => assert({ attachments: {} }, struct)).toThrow(/final/u); + }); + it('accepts an empty args map', () => { assert({}, methodArgsToStruct({})); }); diff --git a/packages/kernel-utils/src/json-schema-to-struct.ts b/packages/kernel-utils/src/json-schema-to-struct.ts index fb591a10e..944cb0dfe 100644 --- a/packages/kernel-utils/src/json-schema-to-struct.ts +++ b/packages/kernel-utils/src/json-schema-to-struct.ts @@ -96,13 +96,19 @@ export function jsonSchemaToStruct(schema: JsonSchema): Struct { /** * Build a Superstruct object struct for a method/capability `args` map - * (name → per-argument {@link JsonSchema}). All listed arguments are required. + * (name → per-argument {@link JsonSchema}). Arguments not named in + * `options.required` are validated as optional; an absent `options.required` + * treats every argument as required. * * @param args - Same shape as {@link MethodSchema.args}. + * @param options - Options bag. + * @param options.required - Names of the required arguments. Mirrors + * {@link MethodSchema.required}; absent means all arguments are required. * @returns A struct that validates a plain object with one field per declared argument. */ export function methodArgsToStruct( args: Record, + options: { required?: string[] } = {}, ): Struct> { const entries = Object.entries(args); if (entries.length === 0) { @@ -113,8 +119,12 @@ export function methodArgsToStruct( return true; }) as Struct>; } + const required = new Set(options.required ?? Object.keys(args)); const shape = Object.fromEntries( - entries.map(([name, jsonSchema]) => [name, jsonSchemaToStruct(jsonSchema)]), + entries.map(([name, jsonSchema]) => { + const fieldStruct = jsonSchemaToStruct(jsonSchema); + return [name, required.has(name) ? fieldStruct : optional(fieldStruct)]; + }), ); return object(shape) as Struct>; } diff --git a/packages/kernel-utils/src/schema.ts b/packages/kernel-utils/src/schema.ts index 2da1c06c3..b3534b8e1 100644 --- a/packages/kernel-utils/src/schema.ts +++ b/packages/kernel-utils/src/schema.ts @@ -50,6 +50,12 @@ export type MethodSchema = { * Each argument includes its type and description. */ args: Record; + /** + * Names of the required arguments. Mirrors {@link ObjectJsonSchema.required}: + * an argument not listed here may be omitted by the caller, and an absent + * `required` means every argument in `args` is required. + */ + required?: string[]; /** * Return value schema, including type and description. */ diff --git a/packages/service-discovery-types/src/index.test.ts b/packages/service-discovery-types/src/index.test.ts index 52ed724a7..3f2402ca2 100644 --- a/packages/service-discovery-types/src/index.test.ts +++ b/packages/service-discovery-types/src/index.test.ts @@ -228,6 +228,30 @@ describe('methodSchemaToMethodSpec', () => { }); }); + it('marks args absent from `required` as optional parameters', () => { + const spec: MethodSpec = methodSchemaToMethodSpec({ + description: 'send a message', + args: { + text: { type: 'string' }, + attachments: { type: 'object', properties: {} }, + }, + required: ['text'], + returns: { type: 'string' }, + }); + expect(spec).toStrictEqual({ + description: 'send a message', + parameters: [ + { description: 'text', type: { kind: 'string' } }, + { + description: 'attachments', + type: { kind: 'object', spec: { properties: {} } }, + optional: true, + }, + ], + returnType: { kind: 'string' }, + }); + }); + it('defaults returnType to void when unspecified', () => { expect( methodSchemaToMethodSpec({ description: 'do a thing', args: {} }), diff --git a/packages/service-discovery-types/src/method-schema-convert.ts b/packages/service-discovery-types/src/method-schema-convert.ts index 72daba329..e2900d68d 100644 --- a/packages/service-discovery-types/src/method-schema-convert.ts +++ b/packages/service-discovery-types/src/method-schema-convert.ts @@ -11,8 +11,6 @@ * use the iteration order of the args record, and we drop the names. The * names are preserved as `ValueSpec.description` if no description was * otherwise present, so they remain human-readable. - * - `MethodSchema` does not mark individual args as optional; the converter - * treats all parameters as required. * - `JsonSchema` has no notion of `remotable`, `null`, `void`, `bigint`, * `unknown`, or `union`. The converter never emits those kinds. */ @@ -94,17 +92,24 @@ export function jsonSchemaToObjectSpec( * Because `MethodSchema.args` is a named record while `MethodSpec.parameters` * is a positional array, the parameters are emitted in the iteration order of * the args record, and each parameter's name is preserved in its - * `description` field when the source did not supply one. + * `description` field when the source did not supply one. An argument absent + * from `schema.required` becomes an optional parameter; an absent `required` + * treats every parameter as required. * * @param schema - The source MethodSchema. * @returns The equivalent MethodSpec. */ export function methodSchemaToMethodSpec(schema: MethodSchema): MethodSpec { + const required = new Set(schema.required ?? Object.keys(schema.args)); const parameters: ValueSpec[] = []; for (const [name, argSchema] of Object.entries(schema.args)) { const type = jsonSchemaToTypeSpec(argSchema); const description = argSchema.description ?? name; - parameters.push({ description, type }); + const parameter: ValueSpec = { description, type }; + if (!required.has(name)) { + parameter.optional = true; + } + parameters.push(parameter); } const returnType: TypeSpec = schema.returns ? jsonSchemaToTypeSpec(schema.returns)