// Composition parameters and exports (both hosts; Rust: rust/host/src/params.rs, same words). // parameters: [{ key, label?, type?, required?, description?, secret?, minimum?, maximum?, values?: { default?, enum? } }] // type: a module.json field type (mcp FIELD_TYPES), default "string". values holds the JSON values (an object, so // the record stays a lexicon `unknown`). A secret has no default and comes from `secrets`, never `inputs`. // instance input: { "$param": "" } and { "$host": "" } anywhere in it; bindParameters fills them. // exports: [{ nsid, instance, entry }]: the entry that serves a contract. const TYPES = ["string", "integer", "number", "boolean", "object", "array"]; const KEY = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/; const NSID = /^[a-zA-Z][a-zA-Z0-9-]*(\.[a-zA-Z0-9-]+){2,}$/; const ENTRY = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/; const ABSENT = Symbol("absent"); const plain = (v) => v !== null && typeof v === "object" && !Array.isArray(v); const holder = (v) => plain(v) && Object.keys(v).length === 1 && (typeof v.$param === "string" || typeof v.$host === "string"); const typed = (type, v) => type === "string" ? typeof v === "string" : type === "integer" ? Number.isInteger(v) : type === "number" ? typeof v === "number" && Number.isFinite(v) : type === "boolean" ? typeof v === "boolean" : type === "array" ? Array.isArray(v) : plain(v); /** Why `v` is not a value of parameter `p`, or undefined. */ function wrong(p, v) { const type = p.type ?? "string"; if (!typed(type, v)) return `must be of type ${type}`; const choices = p.values?.enum; if (choices && !choices.some((c) => JSON.stringify(c) === JSON.stringify(v))) return `must be one of ${JSON.stringify(choices)}`; if (typeof v === "number" && p.minimum !== undefined && v < p.minimum) return `must be at least ${p.minimum}`; if (typeof v === "number" && p.maximum !== undefined && v > p.maximum) return `must be at most ${p.maximum}`; return undefined; } function holders(value, fn) { if (holder(value)) return fn(value); if (Array.isArray(value)) value.forEach((v) => holders(v, fn)); else if (plain(value)) Object.values(value).forEach((v) => holders(v, fn)); } /** validateComposition's part for parameters, placeholders and exports. */ export function validateParameters(manifest, fail) { if (manifest.parameters !== undefined && !Array.isArray(manifest.parameters)) fail("parameters must be a list"); const keys = new Set(); for (const p of manifest.parameters ?? []) { if (!KEY.test(p?.key ?? "") || keys.has(p.key)) fail(`parameter key ${p?.key} must be unique and match ${KEY}`); keys.add(p.key); const type = p.type ?? "string"; if (!TYPES.includes(type)) fail(`parameter ${p.key}: type ${type} is not one of ${TYPES.join(", ")}`); for (const k of ["minimum", "maximum"]) if (p[k] !== undefined && !Number.isInteger(p[k])) fail(`parameter ${p.key}: ${k} must be an integer`); if (p.values !== undefined && !plain(p.values)) fail(`parameter ${p.key}: values must be an object`); const values = p.values ?? {}; if (values.enum !== undefined && !(Array.isArray(values.enum) && values.enum.length)) fail(`parameter ${p.key}: values.enum must be a non-empty list`); if (p.secret === true && "default" in values) fail(`parameter ${p.key}: a secret has no default`); for (const v of [...(values.enum ?? []), ...("default" in values ? [values.default] : [])]) { const why = wrong(p, v); if (why) fail(`parameter ${p.key}: ${JSON.stringify(v)} ${why}`); } } for (const inst of manifest.instances) { holders(inst.input, (h) => { if (h.$param !== undefined && !keys.has(h.$param)) fail(`instance ${inst.name}: $param ${h.$param} is not a parameter`); if (h.$host !== undefined && !KEY.test(h.$host)) fail(`instance ${inst.name}: $host ${h.$host} must match ${KEY}`); }); } if (manifest.exports !== undefined && !Array.isArray(manifest.exports)) fail("exports must be a list"); const instances = new Set(manifest.instances.map((i) => i.name)); const nsids = new Set(); for (const e of manifest.exports ?? []) { if (!NSID.test(e?.nsid ?? "") || nsids.has(e.nsid)) fail(`export ${e?.nsid} must be a unique NSID`); nsids.add(e.nsid); if (!instances.has(e.instance) || !ENTRY.test(e.entry ?? "")) fail(`export ${e.nsid}: instance must be an instance and entry an export name`); } } /** * An instance's input against its module's config (module.json `config`, fields as in actions): every key one * of its fields, of its type (a $param's parameter of that type; a $host value is the host's); a required field * given. A module without config takes no input. */ export function checkInput(inst, mod, parameters, fail) { const input = inst.input ?? {}; if (!plain(input)) fail(`instance ${inst.name}: input must be an object`); const fields = new Map((mod.config ?? []).map((f) => [f.key, f])); for (const [key, v] of Object.entries(input)) { const field = fields.get(key); if (!field) fail(`instance ${inst.name}: input ${key} is not in ${inst.module}'s config${fields.size ? ` (${[...fields.keys()].join(", ")})` : ""}`); const type = field.type ?? "string"; if (holder(v)) { const p = v.$param !== undefined && (parameters ?? []).find((x) => x.key === v.$param); if (p && (p.type ?? "string") !== type) fail(`instance ${inst.name}: input ${key} is ${type}, but parameter ${p.key} is ${p.type ?? "string"}`); } else if (!typed(type, v)) fail(`instance ${inst.name}: input ${key} must be of type ${type}`); } for (const f of fields.values()) if (f.required === true && !Object.hasOwn(input, f.key)) fail(`instance ${inst.name}: input ${f.key} is required by ${inst.module}'s config`); } /** * The manifest with its placeholders filled: `inputs` (parameter values), `secrets` (secret parameters' values), * `host` (values only the host gives). Missing values take their default; a required one without either fails; * an optional one without is dropped (null in a list). Throws "composition: ..." on the first problem. */ export function bindParameters(manifest, { inputs = {}, secrets = {}, host = {} } = {}) { const fail = (message) => { throw new Error(`composition: ${message}`); }; const declared = new Map((manifest.parameters ?? []).map((p) => [p.key, p])); for (const k of Object.keys(inputs)) { if (!declared.has(k)) fail(`parameter ${k} is not declared`); if (declared.get(k).secret === true) fail(`parameter ${k} is a secret: give it in secrets, not inputs`); } for (const k of Object.keys(secrets)) if (declared.get(k)?.secret !== true) fail(`secret ${k} is not a secret parameter`); const values = new Map(); for (const p of declared.values()) { const given = p.secret === true ? secrets : inputs; const v = Object.hasOwn(given, p.key) ? given[p.key] : p.values?.default; if (v === undefined) { if (p.required === true) fail(`parameter ${p.key} is required`); continue; } const why = wrong(p, v); if (why) fail(`parameter ${p.key} ${why}`); values.set(p.key, v); } const swap = (value) => { if (holder(value)) { if (value.$host !== undefined) return Object.hasOwn(host, value.$host) ? host[value.$host] : fail(`host value ${value.$host} is not given by this host`); return values.has(value.$param) ? values.get(value.$param) : ABSENT; } if (Array.isArray(value)) return value.map((v) => ((v = swap(v)) === ABSENT ? null : v)); if (plain(value)) return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, swap(v)]).filter(([, v]) => v !== ABSENT)); return value; }; const instances = manifest.instances.map((inst) => { if (inst.input === undefined) return inst; const input = swap(inst.input); const { input: _, ...rest } = inst; return input === ABSENT ? rest : { ...inst, input }; }); return { ...manifest, instances }; } /** runCompose's guard: a placeholder still in an instance's input fails the run. */ export function checkBound(manifest) { for (const inst of manifest.instances) { holders(inst.input, (h) => { throw new Error(`composition: ${h.$param !== undefined ? `parameter ${h.$param}` : `host value ${h.$host}`} is not bound`); }); } } /** The instance and entry that serve contract `nsid`, or undefined. */ export const exportOf = (manifest, nsid) => (manifest.exports ?? []).find((e) => e.nsid === nsid);