// file: plugins/plugin-skilled-openapi/src/sync/bundle-sync.service.ts import { bundleSkillToActions, compareBundleVersions, diffBundles, formatDiffSummary, resolveSkillLoadOrder, SkillDependencyCycleError, SkillDependencyMissingError, verifyBundleSignature, type AuthBinding, type BundleDiff, type BundlePullFailureListener, type BundlePushReceiver, type BundleSourceListener, type BundleStore, type OperationDescriptor, type ResolvedBundle, type ServiceDescriptor, type SignatureVerifyTelemetry, } from '@frontmcp/adapters/skills'; import { PublicMcpError, type FrontMcpLogger, type SkillContent, type SkillRegistryInterface } from '@frontmcp/sdk'; import { type HiddenOpEntry, type HiddenOpRegistry } from '../registry/hidden-op.registry'; import { type SignatureKey } from '../skilled-openapi.types'; import { type OperationToolFactory } from '../tools/operation-tool.factory'; import { describeLoadError, publicLoadFailureReason, type BundleLoadState, type BundleSyncOutcome, } from './bundle-load-state'; export interface BundleSyncOptions { requireSignature: boolean; trustedKeys: SignatureKey[]; /** * Optional telemetry hook for the signature verification path. When * provided, every signature check increments * `frontmcp_skills_signature_verifications_total{status}` and failures * additionally increment `frontmcp_skills_signature_failures_total{reason}`. * Wired by the plugin's DI factory when ObservabilityPlugin is installed; * `undefined` in tests / hosts without observability. */ telemetry?: SignatureVerifyTelemetry; /** * When true (default), each operation in the bundle is also registered as * an internal tool (visibility: 'internal') in the SDK's tool registry so * other tools / agents / CodeCall scripts / jobs can compose with it via * `this.callTool(name, args)` from `ExecutionContextBase`. Internal tools * are excluded from `tools/list` and rejected for external `tools/call`. * The meta-tools work either way; `execute_action` calls an operation * through its internal tool when there is one. */ exposeOperationsAsInternalTools: boolean; } export interface BundleApplyResult { applied: boolean; reason?: string; /** Set when the bundle is older than the one being served, from a source whose bundles only move forward. */ refusal?: 'outdated'; bundleId: string; bundleVersion: string; diff?: BundleDiff; } /** * Glue between a `BundleSource` (or any caller producing a `ResolvedBundle`) * and the SkillRegistry / HiddenOpRegistry. Enforces the v1.2 security gates * (signature) and applies the diff atomically. * * Atomicity contract: a bundle either fully applies (every added/changed * skill registered, every removed skill unregistered, hidden-op registry * fully synced) OR the active bundle stays unchanged. Failures emit a * structured `BundleApplyResult` with `applied: false` and a `reason`. * * Listeners can subscribe via the underlying BundleStore for downstream * effects (audit log, OTel attributes, /healthz updates). */ /** Minimal slice of a bundle source that {@link BundleSyncService} drives. */ interface StartableBundleSource { start(): Promise; onChange(listener: BundleSourceListener): () => void; onPullFailure?(listener: BundlePullFailureListener): () => void; refresh?(): Promise; keepBundle?(bundle: ResolvedBundle): Promise; readonly push?: BundlePushReceiver; readonly forwardOnly?: boolean; } export class BundleSyncService { private skillUnregisterByBundleId = new Map Promise>(); /** The configured bundle source; wired by the DI factory via {@link attachSource}. */ private source?: StartableBundleSource; private readyPromise?: Promise; /** Why the last load failed, in a form clients may read; cleared when a bundle is applied. */ private loadFailure?: string; /** What the last bundle sync came to: a bundle pulled or pushed, or a pull that failed. */ private lastSync?: BundleSyncOutcome; /** The last queued apply: the source's bundles and pushed ones apply one at a time, in the order they arrive. */ private applyQueue?: Promise; constructor( private readonly skillRegistry: SkillRegistryInterface, private readonly hiddenOps: HiddenOpRegistry, private readonly bundleStore: BundleStore, private readonly options: BundleSyncOptions, private readonly logger: FrontMcpLogger, /** * Optional factory that registers each operation as an internal SDK tool. * Wired by the plugin's DI factory when `exposeOperationsAsInternalTools` * is true and the SDK ToolRegistry is reachable from scope. Absent when * the host opted out OR when the plugin runs in a context without a * tool registry (e.g. unit tests for the sync service in isolation). */ private readonly operationToolFactory?: OperationToolFactory, ) {} /** * Wired by the plugin's DI factory. Stores the bundle source so {@link * ensureReady} can start it and await the first apply lazily, inside the * request that needs the catalog. Does NOT start the source — see the context * note on {@link bootstrap}. */ attachSource(source: StartableBundleSource): void { this.source = source; } /** * Ensure the source has started and its FIRST bundle has been applied, so * `scope.skills` and the hidden-op registry are populated before a meta-tool * (search_skill / load_skill / run_workflow) reads them. Memoized — the * underlying start + apply runs at most once per service instance. */ async ensureReady(): Promise { if (!this.source) return; this.readyPromise ??= this.bootstrap(this.source); return this.readyPromise; } /** * {@link ensureReady}, then refuse with a public error while no bundle is loaded, so a meta-tool says why the * server has no skills instead of answering as if it had none. */ async ensureLoaded(): Promise { await this.ensureReady(); const state = this.loadState(); if (state.status === 'loaded') return; const reason = state.status === 'failed' ? state.reason : 'the first bundle has not loaded yet'; throw new PublicMcpError(`No skill bundle is loaded: ${reason}`, 'SKILL_BUNDLE_NOT_LOADED', 503); } /** {@link ensureReady}, then what the last sync came to: on a first start, its pull. */ async start(): Promise { await this.ensureReady(); return this.syncOutcome(); } /** * Refresh the bundle as a host's scheduler does, such as a Worker's Cron run: the source pulls a fresh bundle, and * this resolves once it is applied and kept, or refused, with what the pull came to. A pull that fails resolves as * `failed`. When nothing has started the source yet, this starts it instead ({@link start}): its start pulls, * applies and keeps the bundle, so no second pull follows. */ async refresh(): Promise { if (this.readyPromise === undefined) return this.start(); await this.ensureReady(); try { await this.source?.refresh?.(); } catch (e) { this.recordPullFailure(describeLoadError(e), false); } return this.syncOutcome(); } /** What the last sync came to: a bundle pulled or pushed, or a pull that failed. Undefined before the first. */ lastSyncOutcome(): BundleSyncOutcome | undefined { return this.lastSync; } /** {@link lastSyncOutcome}; before the first sync, that the first bundle hasn't loaded, and why when loading failed. */ private syncOutcome(): BundleSyncOutcome { return ( this.lastSync ?? { status: 'failed', reason: this.loadFailure ?? 'the first bundle has not loaded yet', cacheFallback: false, at: new Date().toISOString(), } ); } /** The source's checks for pushed bundles, when it takes them (`saas` with `enableWebhook`). */ get pushReceiver(): BundlePushReceiver | undefined { return this.source?.push; } /** Apply a pushed bundle once the first bundle and those queued before it are applied. */ async applyPushed(bundle: ResolvedBundle): Promise { await this.ensureReady(); return this.enqueueApply(bundle); } /** The bundle being served, or why there is none: a failed load counts only while no bundle is loaded. */ loadState(): BundleLoadState { const active = this.bundleStore.current(); if (active) return { status: 'loaded', bundleId: active.bundleId, bundleVersion: active.version }; if (this.loadFailure !== undefined) return { status: 'failed', reason: this.loadFailure }; return { status: 'loading' }; } /** * Start the source and await its FIRST bundle apply. * * CRITICAL — every promise here (the `apply()`, the `firstApply` latch, and * the `onChange` wiring) is created inside the FIRST caller's context, which * is a request context (the meta-tools / list-tools hook call `ensureReady()` * while handling a request). On a stateless V8-isolate runtime (Cloudflare * Workers) this is mandatory two ways over: * 1. workerd cancels promise continuations that cross a request boundary, so * a `firstApply` created in the scope-init/factory context would never * resolve when awaited from a later request — it must be created here. * 2. There is no background event loop, so a deferred/fire-and-forget apply * (the old `setImmediate(source.start())`) can never complete after the * response is sent — the awaiting tool drives it to completion inline. * A failed first sync is logged and recorded instead of hanging or 500-ing * every call: {@link ensureLoaded} then answers why there are no skills. */ private async bootstrap(source: StartableBundleSource): Promise { let resolveFirst!: () => void; let firstSettled = false; const firstApply = new Promise((resolve) => { resolveFirst = resolve; }); // Wire onChange HERE (request context). The first emit drives `firstApply`; // later emits re-apply in order. The source waits for each apply, so a Cron // refresh ends once its bundle is applied and kept. source.onChange(async (bundle, origin) => { const applied = this.enqueueApply(bundle, origin?.pullFailure); if (!firstSettled) { firstSettled = true; void applied.then(() => resolveFirst()); } await applied; }); source.onPullFailure?.((reason) => this.recordPullFailure(reason, false)); try { await source.start(); // Only await the first apply when the source actually emitted during // start() — inline/static/npm and a normal SaaS pull do (start() awaits // the pull + notifies before returning). A source that returns from // start() WITHOUT emitting must NOT hang the request: `SaasPullSource` // short-circuits when a Cron refresh is already in-flight, and that // in-flight pull still applies via the listener above. Worst case this // first request sees an as-yet-unpopulated registry (the pre-fix behavior). if (firstSettled) await firstApply; } catch (e) { const reason = describeLoadError(e); this.logger.error(`[skilled-openapi] initial bundle sync failed: ${reason}`); this.recordLoadFailure(reason); this.recordPullFailure(reason, false); } } /** Record why no bundle could be loaded; clients read it through {@link ensureLoaded} and `/readyz`. */ recordLoadFailure(reason: string): void { this.loadFailure = publicLoadFailureReason(reason); } /** * Apply after every apply queued before: apply() mutates the registry, the bundle store and the unregister * handles, so two at once would leave them inconsistent. An applied bundle, pulled or pushed, is kept by the source * before the next apply starts, so the last good bundle is always the last one applied. Never rejects, so the queue * never breaks. `pullFailure` is set when the bundle is the cached one, served because the pull failed. */ private enqueueApply(bundle: ResolvedBundle, pullFailure?: string): Promise { const previous = this.applyQueue ?? Promise.resolve(undefined); const applied = previous.then(async () => { const result = await this.applyAndRecord(bundle); this.recordSync(result, pullFailure); if (result.applied) await this.keepAppliedBundle(bundle); return result; }); this.applyQueue = applied; return applied; } /** Record what became of a bundle a pull or a push brought; with `pullFailure`, of the cached one served instead. */ private recordSync(result: BundleApplyResult, pullFailure: string | undefined): void { if (pullFailure !== undefined) { this.recordPullFailure(pullFailure, result.applied); return; } const synced = { bundleId: result.bundleId, bundleVersion: result.bundleVersion, at: new Date().toISOString() }; if (result.applied) { this.lastSync = { status: result.diff?.isNoOp ? 'unchanged' : 'applied', ...synced }; } else { this.lastSync = { status: 'refused', reason: publicLoadFailureReason(result.reason ?? 'not applied'), ...synced }; } } /** Record a pull that failed; `cacheFallback` when the source served its cached bundle instead. */ private recordPullFailure(reason: string, cacheFallback: boolean): void { this.lastSync = { status: 'failed', reason: publicLoadFailureReason(reason), cacheFallback, at: new Date().toISOString(), }; } /** Keep an applied bundle as the last good one; a failure to keep it leaves the bundle applied. */ private async keepAppliedBundle(bundle: ResolvedBundle): Promise { if (!this.source?.keepBundle) return; try { await this.source.keepBundle(bundle); } catch (e) { this.logger.warn( `[bundle-sync] bundle ${bundle.bundleId}@${bundle.version} is applied, but keeping it as the last good bundle failed: ${(e as Error).message}`, ); } } /** {@link apply}, recording why a bundle was not applied as the load state reports it. */ private async applyAndRecord(bundle: ResolvedBundle): Promise { const label = `bundle ${bundle.bundleId}@${bundle.version}`; try { const result = await this.apply(bundle); if (result.applied) { this.loadFailure = undefined; } else { this.logger.warn(`[bundle-sync] ${label} not applied: ${result.reason}`); this.recordLoadFailure(`${label} was not applied: ${result.reason}`); } return result; } catch (e) { this.logger.error(`[bundle-sync] ${label} apply threw: ${(e as Error).message}`); this.recordLoadFailure(`${label} was not applied: ${describeLoadError(e)}`); return { applied: false, reason: describeLoadError(e), bundleId: bundle.bundleId, bundleVersion: bundle.version }; } } /** * Validate signature and apply the bundle. Returns a structured result; * never throws on validation/registration failure (caller logs + ignores). * Throws ONLY on programmer error (e.g. invalid argument). */ async apply(bundle: ResolvedBundle): Promise { if (!bundle) throw new Error('apply: bundle is required'); // Gate 0: pin guard. When operators have pinned the active version, // source-driven swaps accumulate in history elsewhere but are never // committed. We surface this as a structured non-error result so the // host can log it without paging anyone. The same-version case must // also short-circuit here — `bundleStore.swap()` throws BundlePinnedError // unconditionally while the store is pinned, so falling through would // turn a no-op into a rollback failure. if (this.bundleStore.isPinned()) { const pinned = this.bundleStore.pinned(); return { applied: false, reason: pinned === bundle.version ? `bundle store pinned to ${pinned}; ${bundle.version} already active` : `bundle store pinned to ${pinned}; ${bundle.version} not applied`, bundleId: bundle.bundleId, bundleVersion: bundle.version, }; } // Gate 1: signature verification. if (this.options.requireSignature) { const verifyResult = verifyBundleSignature(bundle, this.options.trustedKeys, this.options.telemetry); if (!verifyResult.ok) { this.logger.warn(`[bundle-sync] rejected bundle ${bundle.bundleId}@${bundle.version}: ${verifyResult.reason}`); return { applied: false, reason: verifyResult.reason, bundleId: bundle.bundleId, bundleVersion: bundle.version, }; } } // Gate 2: version order: a forward-only source never replaces the served bundle with an older one. const previous = this.bundleStore.current(); if (this.source?.forwardOnly && previous && !(compareBundleVersions(bundle, previous) >= 0)) { return { applied: false, refusal: 'outdated', reason: `older than the served bundle ${previous.bundleId}@${previous.version} (generatedAt ${previous.generatedAt})`, bundleId: bundle.bundleId, bundleVersion: bundle.version, }; } const diff = diffBundles(previous, bundle); // Snapshot every piece of state we mutate so a failure mid-apply can be // fully reversed: // - hiddenOps entries (the registry is rebuilt from scratch below). // - skillUnregisterByBundleId (so we can restore the prior unregister // handles if rollback re-registers replaced/removed skills). // - priorContents: SkillContent that was registered for skills in the // PREVIOUS bundle, keyed by id. When a skill is replaced or removed, // we use these snapshots to re-register the prior version on rollback. const snapshotEntries = [...this.hiddenOps.values()]; const priorHandles = new Map(this.skillUnregisterByBundleId); const priorContents: Map = previous ? new Map(previous.skills.map((s) => [s.id, this.toSkillContent(s, previous)] as const)) : new Map(); // Track everything we mutate during the staged apply so the catch block // can unwind it. `newHandles` is populated as each register call succeeds; // `successfullyRemovedIds` is populated as each removed-skill unregister // succeeds. These let rollback know exactly what to undo. const newHandles: { id: string; unregister: () => Promise }[] = []; const successfullyRemovedIds: string[] = []; try { // 1) Sync hidden-op registry. This throws if the bundle references an // unknown service / authBinding / operation, which trips the rollback // path below and keeps the previous bundle active. this.rebuildHiddenOps(bundle); // 2) Resolve dependency-respecting load order. Cycles + missing deps // trip the rollback path so a malformed bundle doesn't half-apply. let orderedSkills: ResolvedBundle['skills']; try { orderedSkills = resolveSkillLoadOrder(bundle.skills); } catch (e) { if (e instanceof SkillDependencyCycleError) { throw new Error( `[bundle-sync] dependency cycle in ${bundle.bundleId}@${bundle.version}: ${e.cycle.join(' -> ')}`, { cause: e }, ); } if (e instanceof SkillDependencyMissingError) { throw new Error( `[bundle-sync] missing dependency in ${bundle.bundleId}@${bundle.version}: skill "${e.skillId}" requires "${e.missingId}"`, { cause: e }, ); } throw e; } // 3) Stage all skill registrations BEFORE removing anything, in dep order. // registerSkillContent with an existing id replaces in-place inside // the SkillRegistry, so prior versions are not visible to consumers // once their replacement has been registered. If any registration // throws, the rollback path re-registers prior versions from // `priorContents`. // The prior bundle's skills not registered again yet are replaced or // dropped by the end of this apply, so they are named in `supersedes`: // the registry's id/path collision check leaves them out, and a // bundle free of collisions applies even when a new skill takes the // path or id a dropped or renamed skill holds until then. The registry // removes such a skill in the same commit and reports it in `removed`; // it counts as removed here, so rollback restores it. const pendingPriorIds = new Set(priorHandles.keys()); for (const skill of orderedSkills) { const content = this.toSkillContent(skill, bundle); const handle = await this.skillRegistry.registerSkillContent(content, { source: `skilled-openapi:${bundle.bundleId}`, supersedes: [...pendingPriorIds], }); pendingPriorIds.delete(content.id); for (const removedId of handle.removed ?? []) { pendingPriorIds.delete(removedId); successfullyRemovedIds.push(removedId); } newHandles.push(handle); } // 3) Now that every new/changed skill is in place, unregister anything // the new bundle drops. If a removal fails, rollback re-registers // those skills via `priorContents`. for (const removedId of diff.removedSkillIds) { const handle = priorHandles.get(removedId); if (handle && !successfullyRemovedIds.includes(removedId)) { await handle(); successfullyRemovedIds.push(removedId); } } // 4) Commit the tracking map. The new bundle's skills are the only // handles we care about going forward — anything not in `newHandles` // was either removed in step 3 or never registered by us. this.skillUnregisterByBundleId.clear(); for (const h of newHandles) { this.skillUnregisterByBundleId.set(h.id, h.unregister); } // 5) Sync per-operation internal tools (visibility: 'internal'). // This is best-effort: a registration error logs a warning and // continues — meta-tools still work, internal-tool composition for // that op is just unavailable until the next swap. if (this.options.exposeOperationsAsInternalTools && this.operationToolFactory) { try { this.operationToolFactory.unregisterAll(); // Precompute the set of path templates with a GET op in this // bundle so the per-op classification can decide // resource-vs-tool-vs-both + emit semantics. Done once before the // loop so we don't pay O(N) inside each register() call. const pathsWithGet = collectPathsWithGet(this.hiddenOps.values()); for (const entry of this.hiddenOps.values()) { try { this.operationToolFactory.register(entry, { pathsWithGet }); } catch (regErr) { this.logger.warn( `[bundle-sync] internal-tool register failed for ${entry.bundleId}.${entry.op.operationId}: ${(regErr as Error).message}`, ); } } } catch (factoryErr) { this.logger.warn( `[bundle-sync] internal-tool factory error: ${(factoryErr as Error).message}; continuing without internal tools`, ); } } // 6) Swap the active bundle pointer (this fires BundleStore listeners). this.bundleStore.swap(bundle); this.logger.info(`[bundle-sync] applied ${bundle.bundleId}@${bundle.version} (${formatDiffSummary(diff)})`); return { applied: true, bundleId: bundle.bundleId, bundleVersion: bundle.version, diff }; } catch (e) { this.logger.error( `[bundle-sync] apply failed for ${bundle.bundleId}@${bundle.version}: ${(e as Error).message}; rolling back`, ); // Rollback step A: restore the hidden-op registry to its pre-apply state. this.hiddenOps.clear(); for (const entry of snapshotEntries) { this.hiddenOps.set(entry); } // Rollback step A.1: undo any internal tool registrations from this // failed apply, then re-register the prior bundle's operations so // composition callers continue to see the previous bundle's surface. if (this.options.exposeOperationsAsInternalTools && this.operationToolFactory) { try { this.operationToolFactory.unregisterAll(); const pathsWithGet = collectPathsWithGet(snapshotEntries); for (const entry of snapshotEntries) { try { this.operationToolFactory.register(entry, { pathsWithGet }); } catch { // best-effort restore } } } catch { // best-effort } } // Rollback step B: tear down anything the staged apply registered. Each // unregister is best-effort; one failure must not stop the rest of the // rollback from running. for (const h of newHandles) { try { await h.unregister(); } catch { // best effort } } // Rollback step C: re-register prior content for every skill we either // replaced (id present in both prior and new bundle) or successfully // removed in step 3. This restores the previous bundle's contract from // the registry's perspective; the bundleStore pointer was never swapped, // so callers reading via bundleStore.current() also see the prior bundle. const toRestore = new Map(); for (const h of newHandles) { const prev = priorContents.get(h.id); if (prev) toRestore.set(h.id, prev); } for (const id of successfullyRemovedIds) { const prev = priorContents.get(id); if (prev) toRestore.set(id, prev); } // Start from the snapshot of prior unregister handles, then overwrite // any restored ids with the freshly-issued handle. this.skillUnregisterByBundleId = new Map(priorHandles); for (const [id, content] of toRestore) { try { const handle = await this.skillRegistry.registerSkillContent(content, { source: `skilled-openapi:${bundle.bundleId}:rollback`, }); this.skillUnregisterByBundleId.set(id, handle.unregister); } catch (restoreErr) { this.logger.error( `[bundle-sync] failed to restore prior skill ${id} during rollback: ${(restoreErr as Error).message}`, ); this.skillUnregisterByBundleId.delete(id); } } return { applied: false, reason: `rollback: ${(e as Error).message}`, bundleId: bundle.bundleId, bundleVersion: bundle.version, }; } } /** * Project a single BundledSkill into a SkillContent the SDK SkillRegistry * understands. The `actions[]` extension carries the per-op schemas the LLM * needs to know about; `bundleVersion` lets polling clients detect changes. */ private toSkillContent(skill: ResolvedBundle['skills'][number], bundle: ResolvedBundle): SkillContent { const actions = bundleSkillToActions(skill, bundle.operations); return { id: skill.id, name: skill.name, description: skill.description, instructions: skill.instructions, tools: [], ...(skill.tags?.length ? { tags: [...skill.tags] } : {}), actions, bundleVersion: bundle.version, }; } private rebuildHiddenOps(bundle: ResolvedBundle): void { this.hiddenOps.clear(); const servicesById = new Map(bundle.services.map((s) => [s.id, s] as const)); for (const skill of bundle.skills) { for (const opId of skill.operationIds) { const op: OperationDescriptor | undefined = bundle.operations[opId]; if (!op) { throw new Error( `[bundle-sync] malformed bundle ${bundle.bundleId}@${bundle.version}: skill "${skill.id}" references unknown operationId "${opId}"`, ); } const service = servicesById.get(op.serviceId); if (!service) { throw new Error( `[bundle-sync] malformed bundle ${bundle.bundleId}@${bundle.version}: operation "${opId}" references unknown serviceId "${op.serviceId}"`, ); } const authBinding: AuthBinding | undefined = bundle.authBindings[op.authBindingRef]; if (!authBinding) { throw new Error( `[bundle-sync] malformed bundle ${bundle.bundleId}@${bundle.version}: operation "${opId}" references unknown authBindingRef "${op.authBindingRef}"`, ); } const entry: HiddenOpEntry = { skillId: skill.id, // Pin the skill-level policy so it is AND-ed with the op-level policy // at execution time (C2 — previously this was silently dropped). ...(skill.requiredAuthorities !== undefined && { skillRequiredAuthorities: skill.requiredAuthorities, }), ...(skill.tags?.length ? { skillTags: skill.tags } : {}), op, service, authBinding, bundleId: bundle.bundleId, bundleVersion: bundle.version, }; this.hiddenOps.set(entry); } } } } /** * Build the set of path templates that have a GET operation across the given * bundle entries. Consumed by the OpenAPI -> MCP classifier to decide * resource-vs-tool-vs-both classification and emit semantics. Computed once * per bundle swap so the per-op `register()` call is O(1) instead of O(N). */ function collectPathsWithGet(entries: Iterable): ReadonlySet { const set = new Set(); for (const entry of entries) { if (typeof entry.op.httpMethod === 'string' && entry.op.httpMethod.toUpperCase() === 'GET') { set.add(entry.op.pathTemplate); } } return set; }