@frontmcp/plugin-skilled-openapi 0.0.1

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 (42) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +40 -0
  3. package/esm/index.mjs +1536 -0
  4. package/esm/package.json +60 -0
  5. package/executor/credential-resolver.d.ts +49 -0
  6. package/executor/credential-resolver.d.ts.map +1 -0
  7. package/executor/openapi-runtime.d.ts +33 -0
  8. package/executor/openapi-runtime.d.ts.map +1 -0
  9. package/executor/schema-cache.d.ts +30 -0
  10. package/executor/schema-cache.d.ts.map +1 -0
  11. package/executor/ssrf-guard.d.ts +16 -0
  12. package/executor/ssrf-guard.d.ts.map +1 -0
  13. package/index.d.ts +7 -0
  14. package/index.d.ts.map +1 -0
  15. package/index.js +1524 -0
  16. package/package.json +60 -0
  17. package/registry/hidden-op.registry.d.ts +52 -0
  18. package/registry/hidden-op.registry.d.ts.map +1 -0
  19. package/security/authority-guard.d.ts +34 -0
  20. package/security/authority-guard.d.ts.map +1 -0
  21. package/skilled-openapi.plugin.d.ts +24 -0
  22. package/skilled-openapi.plugin.d.ts.map +1 -0
  23. package/skilled-openapi.symbols.d.ts +25 -0
  24. package/skilled-openapi.symbols.d.ts.map +1 -0
  25. package/skilled-openapi.types.d.ts +189 -0
  26. package/skilled-openapi.types.d.ts.map +1 -0
  27. package/sync/bundle-sync.service.d.ts +87 -0
  28. package/sync/bundle-sync.service.d.ts.map +1 -0
  29. package/tools/execute-action.schema.d.ts +26 -0
  30. package/tools/execute-action.schema.d.ts.map +1 -0
  31. package/tools/execute-action.tool.d.ts +6 -0
  32. package/tools/execute-action.tool.d.ts.map +1 -0
  33. package/tools/load-skill.schema.d.ts +46 -0
  34. package/tools/load-skill.schema.d.ts.map +1 -0
  35. package/tools/load-skill.tool.d.ts +6 -0
  36. package/tools/load-skill.tool.d.ts.map +1 -0
  37. package/tools/operation-tool.factory.d.ts +60 -0
  38. package/tools/operation-tool.factory.d.ts.map +1 -0
  39. package/tools/search-skill.schema.d.ts +30 -0
  40. package/tools/search-skill.schema.d.ts.map +1 -0
  41. package/tools/search-skill.tool.d.ts +6 -0
  42. package/tools/search-skill.tool.d.ts.map +1 -0
package/esm/index.mjs ADDED
@@ -0,0 +1,1536 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
4
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
5
+ }) : x)(function(x) {
6
+ if (typeof require !== "undefined") return require.apply(this, arguments);
7
+ throw Error('Dynamic require of "' + x + '" is not supported');
8
+ });
9
+ var __decorateClass = (decorators, target, key2, kind) => {
10
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key2) : target;
11
+ for (var i = decorators.length - 1, decorator; i >= 0; i--)
12
+ if (decorator = decorators[i])
13
+ result = (kind ? decorator(target, key2, result) : decorator(result)) || result;
14
+ if (kind && result) __defProp(target, key2, result);
15
+ return result;
16
+ };
17
+
18
+ // plugins/plugin-skilled-openapi/src/skilled-openapi.plugin.ts
19
+ import {
20
+ BundleStore as BundleStore2,
21
+ createBundleSource
22
+ } from "@frontmcp/adapters/skills";
23
+ import {
24
+ DynamicPlugin,
25
+ FrontMcpLogger,
26
+ Plugin,
27
+ ScopeEntry as ScopeEntry3
28
+ } from "@frontmcp/sdk";
29
+
30
+ // plugins/plugin-skilled-openapi/src/executor/credential-resolver.ts
31
+ var MemoryCredentialResolver = class {
32
+ defaults;
33
+ perBundle = /* @__PURE__ */ new Map();
34
+ constructor(defaults = {}) {
35
+ this.defaults = new Map(Object.entries(defaults).map(([k, v]) => [k, normalizeSecret(v)]));
36
+ }
37
+ /**
38
+ * Override (or add) a credential for a specific bundle. Per-bundle entries
39
+ * take precedence over the tenant-wide defaults supplied to the constructor.
40
+ */
41
+ setForBundle(bundleId, ref, value) {
42
+ let bundle = this.perBundle.get(bundleId);
43
+ if (!bundle) {
44
+ bundle = /* @__PURE__ */ new Map();
45
+ this.perBundle.set(bundleId, bundle);
46
+ }
47
+ bundle.set(ref, normalizeSecret(value));
48
+ }
49
+ async resolve(ref, opts) {
50
+ const scoped = this.perBundle.get(opts.bundleId)?.get(ref);
51
+ if (typeof scoped === "string") return scoped;
52
+ const fallback = this.defaults.get(ref);
53
+ return typeof fallback === "string" ? fallback : void 0;
54
+ }
55
+ };
56
+ function normalizeSecret(value) {
57
+ return value.replace(/\r?\n$/, "");
58
+ }
59
+
60
+ // plugins/plugin-skilled-openapi/src/registry/hidden-op.registry.ts
61
+ var key = (skillId, actionId) => `${skillId}\0${actionId}`;
62
+ var HiddenOpRegistry = class {
63
+ entries = /* @__PURE__ */ new Map();
64
+ /** Number of entries currently registered. Used for `/healthz`. */
65
+ get size() {
66
+ return this.entries.size;
67
+ }
68
+ /**
69
+ * Look up the operation registered for `(skillId, actionId)`. Returns
70
+ * undefined if the skill or action is unknown — the meta-tool surfaces this
71
+ * as a structured error to the caller.
72
+ */
73
+ get(skillId, actionId) {
74
+ return this.entries.get(key(skillId, actionId));
75
+ }
76
+ /** Set or replace the entry for `(skillId, actionId)`. */
77
+ set(entry) {
78
+ this.entries.set(key(entry.skillId, entry.op.operationId), entry);
79
+ }
80
+ /** Remove the entry for `(skillId, actionId)`. Returns true if anything was removed. */
81
+ delete(skillId, actionId) {
82
+ return this.entries.delete(key(skillId, actionId));
83
+ }
84
+ /**
85
+ * Remove every entry belonging to `skillId`. Used when a skill is removed
86
+ * by a bundle swap. Returns the number of entries removed.
87
+ */
88
+ deleteSkill(skillId) {
89
+ const prefix = `${skillId}\0`;
90
+ let removed = 0;
91
+ for (const k of [...this.entries.keys()]) {
92
+ if (k.startsWith(prefix)) {
93
+ this.entries.delete(k);
94
+ removed++;
95
+ }
96
+ }
97
+ return removed;
98
+ }
99
+ /** Iterate all entries. Used by `/healthz` and audit log. */
100
+ values() {
101
+ return this.entries.values();
102
+ }
103
+ /** Reset to empty state (used by tests and panic-button). */
104
+ clear() {
105
+ this.entries.clear();
106
+ }
107
+ };
108
+
109
+ // plugins/plugin-skilled-openapi/src/security/authority-guard.ts
110
+ import {
111
+ AuthoritiesContextBuilder,
112
+ AuthoritiesEngine,
113
+ AuthoritiesEvaluatorRegistry,
114
+ AuthoritiesProfileRegistry
115
+ } from "@frontmcp/auth";
116
+ var AuthorityGuard = class {
117
+ engine;
118
+ contextBuilder;
119
+ logger;
120
+ constructor(opts = {}) {
121
+ const profiles = opts.profiles ?? new AuthoritiesProfileRegistry();
122
+ const evaluators = opts.evaluators ?? new AuthoritiesEvaluatorRegistry();
123
+ this.engine = new AuthoritiesEngine(profiles, evaluators);
124
+ this.contextBuilder = new AuthoritiesContextBuilder();
125
+ this.logger = opts.logger;
126
+ }
127
+ async check(args) {
128
+ const { policy, authInfo, input, env } = args;
129
+ if (policy === void 0) {
130
+ return { granted: true, evaluatedPolicies: [] };
131
+ }
132
+ try {
133
+ const ctx = this.contextBuilder.build(authInfo, input, env);
134
+ return await this.engine.evaluate(policy, ctx);
135
+ } catch (e) {
136
+ const message = normalizeCaughtMessage(e);
137
+ this.logger?.error(`[skilled-openapi:authority] evaluation failed: ${message}`);
138
+ return {
139
+ granted: false,
140
+ deniedBy: "authority_evaluation_failed",
141
+ message,
142
+ evaluatedPolicies: []
143
+ };
144
+ }
145
+ }
146
+ };
147
+ function normalizeCaughtMessage(e) {
148
+ if (e instanceof Error) return e.message || "authority evaluation threw";
149
+ if (typeof e === "string") return e;
150
+ if (e !== null && typeof e === "object" && typeof e.message === "string") {
151
+ return e.message;
152
+ }
153
+ return String(e ?? "authority evaluation threw");
154
+ }
155
+
156
+ // plugins/plugin-skilled-openapi/src/skilled-openapi.symbols.ts
157
+ var SkilledOpenApiConfig = class {
158
+ constructor(options) {
159
+ this.options = options;
160
+ }
161
+ get outbound() {
162
+ return this.options.outbound;
163
+ }
164
+ };
165
+ var SkilledOpenApiCredentialResolver = class {
166
+ };
167
+
168
+ // plugins/plugin-skilled-openapi/src/skilled-openapi.types.ts
169
+ import { bundleSourceSchema, signatureKeySchema } from "@frontmcp/adapters/skills";
170
+ import { z } from "@frontmcp/lazy-zod";
171
+ import {
172
+ bundleSourceSchema as bundleSourceSchema2,
173
+ npmSourceSchema,
174
+ saasSourceSchema,
175
+ signatureKeySchema as signatureKeySchema2,
176
+ staticSourceSchema
177
+ } from "@frontmcp/adapters/skills";
178
+ var outboundOptionsSchema = z.object({
179
+ /**
180
+ * Allow outbound connections to private/loopback/link-local IPs.
181
+ * MUST stay false in production. Set true only for self-hosted scenarios
182
+ * where the customer's REST API legitimately lives on a private network.
183
+ */
184
+ allowPrivateNetworks: z.boolean().default(false),
185
+ /** Optional egress proxy URL (honors HTTPS_PROXY env if not set). */
186
+ egressProxy: z.string().url().optional(),
187
+ /** Per-host concurrent request cap. */
188
+ maxConcurrencyPerHost: z.number().int().positive().default(10),
189
+ /** Default per-op HTTP timeout in milliseconds. */
190
+ defaultTimeoutMs: z.number().int().positive().default(3e4),
191
+ /** Default response size cap in bytes. Per-op overrides apply. */
192
+ defaultMaxResponseBytes: z.number().int().positive().default(256 * 1024),
193
+ /**
194
+ * Permit `http:` URLs in addition to `https:`. Off by default; only enable
195
+ * for local dev where the mock REST server doesn't have a TLS cert.
196
+ */
197
+ allowHttp: z.boolean().default(false)
198
+ }).default({
199
+ allowPrivateNetworks: false,
200
+ maxConcurrencyPerHost: 10,
201
+ defaultTimeoutMs: 3e4,
202
+ defaultMaxResponseBytes: 256 * 1024,
203
+ allowHttp: false
204
+ });
205
+ var skilledOpenApiPluginOptionsObjectSchema = z.object({
206
+ /** Bundle source — static, npm, or saas. */
207
+ source: bundleSourceSchema,
208
+ /**
209
+ * Require a valid bundle signature. Defaults to true. Setting `dev: true`
210
+ * disables the requirement and emits a startup warning. Never set false
211
+ * (unsigned mode) silently in production.
212
+ */
213
+ requireSignature: z.boolean().default(true),
214
+ /**
215
+ * Trusted public keys for bundle signature verification. At least one must
216
+ * match the bundle's signed `kid` for the bundle to load.
217
+ */
218
+ trustedKeys: z.array(signatureKeySchema).default([]),
219
+ /**
220
+ * Development mode: bypass signature verification and relax some defaults
221
+ * (e.g. allow http:). Loud startup warning. Never true in production.
222
+ */
223
+ dev: z.boolean().default(false),
224
+ /** Outbound HTTP / SSRF defenses for the executor. */
225
+ outbound: outboundOptionsSchema,
226
+ /**
227
+ * Source-conflict policy when more than one source registers a skill with
228
+ * the same id. Default: locally-pinned static beats npm beats saas.
229
+ */
230
+ sourceConflictPolicy: z.enum(["static-wins", "last-wins", "reject"]).default("static-wins"),
231
+ /**
232
+ * Cache directory for the last successfully loaded SaaS bundle, used as a
233
+ * fallback if a fresh pull fails at boot. Defaults to `.frontmcp/skilled-openapi/`.
234
+ * Only applies to source.type === 'saas'.
235
+ */
236
+ bundleCacheDir: z.string().optional(),
237
+ /**
238
+ * Static credential map seeded into the in-memory `CredentialResolver` for
239
+ * dev / demo / single-tenant deployments. Keys match `vaultRef` strings on
240
+ * the bundle's `authBindings`. Production deployments should override the
241
+ * `SkilledOpenApiCredentialResolver` provider with a libs/auth-vault-backed
242
+ * resolver instead of using this option.
243
+ */
244
+ credentials: z.record(z.string().min(1).max(256), z.string().min(1)).optional(),
245
+ /**
246
+ * Register each bundle operation as an internal SDK tool (visibility:
247
+ * 'internal') so other tools, agents, CodeCall scripts, and jobs can
248
+ * compose with it via `this.callTool('<bundleId>.<operationId>', args)`.
249
+ *
250
+ * Internal tools are excluded from `tools/list` and rejected for external
251
+ * `tools/call` requests — only callable in-process via the SDK helper.
252
+ *
253
+ * Default: true. Disable for very large bundles where the additional tool
254
+ * registry pressure outweighs the composition convenience, or when the
255
+ * three meta-tools are sufficient.
256
+ */
257
+ exposeOperationsAsInternalTools: z.boolean().default(true)
258
+ });
259
+ var DEFAULT_OUTBOUND_OPTIONS = {
260
+ allowPrivateNetworks: false,
261
+ maxConcurrencyPerHost: 10,
262
+ defaultTimeoutMs: 3e4,
263
+ defaultMaxResponseBytes: 256 * 1024,
264
+ allowHttp: false
265
+ };
266
+ var skilledOpenApiPluginOptionsSchema = skilledOpenApiPluginOptionsObjectSchema.transform((opts) => {
267
+ const outbound = {
268
+ ...DEFAULT_OUTBOUND_OPTIONS,
269
+ ...opts.outbound ?? {}
270
+ };
271
+ if (opts.dev) {
272
+ outbound.allowHttp = true;
273
+ }
274
+ return {
275
+ ...opts,
276
+ outbound,
277
+ bundleCacheDir: opts.bundleCacheDir ?? ".frontmcp/skilled-openapi/"
278
+ };
279
+ });
280
+
281
+ // plugins/plugin-skilled-openapi/src/sync/bundle-sync.service.ts
282
+ import {
283
+ bundleSkillToActions,
284
+ diffBundles,
285
+ formatDiffSummary,
286
+ resolveSkillLoadOrder,
287
+ SkillDependencyCycleError,
288
+ SkillDependencyMissingError,
289
+ verifyBundleSignature
290
+ } from "@frontmcp/adapters/skills";
291
+ var BundleSyncService = class {
292
+ constructor(skillRegistry, hiddenOps, bundleStore, options, logger, operationToolFactory) {
293
+ this.skillRegistry = skillRegistry;
294
+ this.hiddenOps = hiddenOps;
295
+ this.bundleStore = bundleStore;
296
+ this.options = options;
297
+ this.logger = logger;
298
+ this.operationToolFactory = operationToolFactory;
299
+ }
300
+ skillUnregisterByBundleId = /* @__PURE__ */ new Map();
301
+ /**
302
+ * Validate signature and apply the bundle. Returns a structured result;
303
+ * never throws on validation/registration failure (caller logs + ignores).
304
+ * Throws ONLY on programmer error (e.g. invalid argument).
305
+ */
306
+ async apply(bundle) {
307
+ if (!bundle) throw new Error("apply: bundle is required");
308
+ if (this.bundleStore.isPinned()) {
309
+ const pinned = this.bundleStore.pinned();
310
+ return {
311
+ applied: false,
312
+ reason: pinned === bundle.version ? `bundle store pinned to ${pinned}; ${bundle.version} already active` : `bundle store pinned to ${pinned}; ${bundle.version} not applied`,
313
+ bundleId: bundle.bundleId,
314
+ bundleVersion: bundle.version
315
+ };
316
+ }
317
+ if (this.options.requireSignature) {
318
+ const verifyResult = verifyBundleSignature(bundle, this.options.trustedKeys, this.options.telemetry);
319
+ if (!verifyResult.ok) {
320
+ this.logger.warn(`[bundle-sync] rejected bundle ${bundle.bundleId}@${bundle.version}: ${verifyResult.reason}`);
321
+ return {
322
+ applied: false,
323
+ reason: verifyResult.reason,
324
+ bundleId: bundle.bundleId,
325
+ bundleVersion: bundle.version
326
+ };
327
+ }
328
+ }
329
+ const previous = this.bundleStore.current();
330
+ const diff = diffBundles(previous, bundle);
331
+ const snapshotEntries = [...this.hiddenOps.values()];
332
+ const priorHandles = new Map(this.skillUnregisterByBundleId);
333
+ const priorContents = previous ? new Map(previous.skills.map((s) => [s.id, this.toSkillContent(s, previous)])) : /* @__PURE__ */ new Map();
334
+ const newHandles = [];
335
+ const successfullyRemovedIds = [];
336
+ try {
337
+ this.rebuildHiddenOps(bundle);
338
+ let orderedSkills;
339
+ try {
340
+ orderedSkills = resolveSkillLoadOrder(bundle.skills);
341
+ } catch (e) {
342
+ if (e instanceof SkillDependencyCycleError) {
343
+ throw new Error(
344
+ `[bundle-sync] dependency cycle in ${bundle.bundleId}@${bundle.version}: ${e.cycle.join(" -> ")}`
345
+ );
346
+ }
347
+ if (e instanceof SkillDependencyMissingError) {
348
+ throw new Error(
349
+ `[bundle-sync] missing dependency in ${bundle.bundleId}@${bundle.version}: skill "${e.skillId}" requires "${e.missingId}"`
350
+ );
351
+ }
352
+ throw e;
353
+ }
354
+ for (const skill of orderedSkills) {
355
+ const content = this.toSkillContent(skill, bundle);
356
+ const handle = await this.skillRegistry.registerSkillContent(content, {
357
+ source: `skilled-openapi:${bundle.bundleId}`
358
+ });
359
+ newHandles.push(handle);
360
+ }
361
+ for (const removedId of diff.removedSkillIds) {
362
+ const handle = priorHandles.get(removedId);
363
+ if (handle) {
364
+ await handle();
365
+ successfullyRemovedIds.push(removedId);
366
+ }
367
+ }
368
+ this.skillUnregisterByBundleId.clear();
369
+ for (const h of newHandles) {
370
+ this.skillUnregisterByBundleId.set(h.id, h.unregister);
371
+ }
372
+ if (this.options.exposeOperationsAsInternalTools && this.operationToolFactory) {
373
+ try {
374
+ this.operationToolFactory.unregisterAll();
375
+ for (const entry of this.hiddenOps.values()) {
376
+ try {
377
+ this.operationToolFactory.register(entry);
378
+ } catch (regErr) {
379
+ this.logger.warn(
380
+ `[bundle-sync] internal-tool register failed for ${entry.bundleId}.${entry.op.operationId}: ${regErr.message}`
381
+ );
382
+ }
383
+ }
384
+ } catch (factoryErr) {
385
+ this.logger.warn(
386
+ `[bundle-sync] internal-tool factory error: ${factoryErr.message}; continuing without internal tools`
387
+ );
388
+ }
389
+ }
390
+ this.bundleStore.swap(bundle);
391
+ this.logger.info(`[bundle-sync] applied ${bundle.bundleId}@${bundle.version} (${formatDiffSummary(diff)})`);
392
+ return { applied: true, bundleId: bundle.bundleId, bundleVersion: bundle.version, diff };
393
+ } catch (e) {
394
+ this.logger.error(
395
+ `[bundle-sync] apply failed for ${bundle.bundleId}@${bundle.version}: ${e.message}; rolling back`
396
+ );
397
+ this.hiddenOps.clear();
398
+ for (const entry of snapshotEntries) {
399
+ this.hiddenOps.set(entry);
400
+ }
401
+ if (this.options.exposeOperationsAsInternalTools && this.operationToolFactory) {
402
+ try {
403
+ this.operationToolFactory.unregisterAll();
404
+ for (const entry of snapshotEntries) {
405
+ try {
406
+ this.operationToolFactory.register(entry);
407
+ } catch {
408
+ }
409
+ }
410
+ } catch {
411
+ }
412
+ }
413
+ for (const h of newHandles) {
414
+ try {
415
+ await h.unregister();
416
+ } catch {
417
+ }
418
+ }
419
+ const toRestore = /* @__PURE__ */ new Map();
420
+ for (const h of newHandles) {
421
+ const prev = priorContents.get(h.id);
422
+ if (prev) toRestore.set(h.id, prev);
423
+ }
424
+ for (const id of successfullyRemovedIds) {
425
+ const prev = priorContents.get(id);
426
+ if (prev) toRestore.set(id, prev);
427
+ }
428
+ this.skillUnregisterByBundleId = new Map(priorHandles);
429
+ for (const [id, content] of toRestore) {
430
+ try {
431
+ const handle = await this.skillRegistry.registerSkillContent(content, {
432
+ source: `skilled-openapi:${bundle.bundleId}:rollback`
433
+ });
434
+ this.skillUnregisterByBundleId.set(id, handle.unregister);
435
+ } catch (restoreErr) {
436
+ this.logger.error(
437
+ `[bundle-sync] failed to restore prior skill ${id} during rollback: ${restoreErr.message}`
438
+ );
439
+ this.skillUnregisterByBundleId.delete(id);
440
+ }
441
+ }
442
+ return {
443
+ applied: false,
444
+ reason: `rollback: ${e.message}`,
445
+ bundleId: bundle.bundleId,
446
+ bundleVersion: bundle.version
447
+ };
448
+ }
449
+ }
450
+ /**
451
+ * Project a single BundledSkill into a SkillContent the SDK SkillRegistry
452
+ * understands. The `actions[]` extension carries the per-op schemas the LLM
453
+ * needs to know about; `bundleVersion` lets polling clients detect changes.
454
+ */
455
+ toSkillContent(skill, bundle) {
456
+ const actions = bundleSkillToActions(skill, bundle.operations);
457
+ return {
458
+ id: skill.id,
459
+ name: skill.name,
460
+ description: skill.description,
461
+ instructions: skill.instructions,
462
+ tools: [],
463
+ actions,
464
+ bundleVersion: bundle.version
465
+ };
466
+ }
467
+ rebuildHiddenOps(bundle) {
468
+ this.hiddenOps.clear();
469
+ const servicesById = new Map(bundle.services.map((s) => [s.id, s]));
470
+ for (const skill of bundle.skills) {
471
+ for (const opId of skill.operationIds) {
472
+ const op = bundle.operations[opId];
473
+ if (!op) {
474
+ throw new Error(
475
+ `[bundle-sync] malformed bundle ${bundle.bundleId}@${bundle.version}: skill "${skill.id}" references unknown operationId "${opId}"`
476
+ );
477
+ }
478
+ const service = servicesById.get(op.serviceId);
479
+ if (!service) {
480
+ throw new Error(
481
+ `[bundle-sync] malformed bundle ${bundle.bundleId}@${bundle.version}: operation "${opId}" references unknown serviceId "${op.serviceId}"`
482
+ );
483
+ }
484
+ const authBinding = bundle.authBindings[op.authBindingRef];
485
+ if (!authBinding) {
486
+ throw new Error(
487
+ `[bundle-sync] malformed bundle ${bundle.bundleId}@${bundle.version}: operation "${opId}" references unknown authBindingRef "${op.authBindingRef}"`
488
+ );
489
+ }
490
+ const entry = {
491
+ skillId: skill.id,
492
+ op,
493
+ service,
494
+ authBinding,
495
+ bundleId: bundle.bundleId,
496
+ bundleVersion: bundle.version
497
+ };
498
+ this.hiddenOps.set(entry);
499
+ }
500
+ }
501
+ }
502
+ };
503
+
504
+ // plugins/plugin-skilled-openapi/src/tools/execute-action.tool.ts
505
+ import { BundleStore, SkillAuditWriterToken } from "@frontmcp/adapters/skills";
506
+ import { Tool, ToolContext } from "@frontmcp/sdk";
507
+
508
+ // plugins/plugin-skilled-openapi/src/executor/openapi-runtime.ts
509
+ import {
510
+ buildRequest,
511
+ parseResponse
512
+ } from "@frontmcp/adapters/openapi";
513
+
514
+ // plugins/plugin-skilled-openapi/src/executor/ssrf-guard.ts
515
+ import { promises as dns } from "node:dns";
516
+ var PRIVATE_IPV4_BLOCKS = [
517
+ // RFC 1918
518
+ { net: ipv4ToInt("10.0.0.0"), mask: 4278190080 },
519
+ { net: ipv4ToInt("172.16.0.0"), mask: 4293918720 },
520
+ { net: ipv4ToInt("192.168.0.0"), mask: 4294901760 },
521
+ // Loopback
522
+ { net: ipv4ToInt("127.0.0.0"), mask: 4278190080 },
523
+ // Link-local incl. AWS/GCP/Azure metadata 169.254.169.254
524
+ { net: ipv4ToInt("169.254.0.0"), mask: 4294901760 },
525
+ // 0.0.0.0/8
526
+ { net: ipv4ToInt("0.0.0.0"), mask: 4278190080 }
527
+ ];
528
+ function ipv4ToInt(ip) {
529
+ const parts = ip.split(".").map((n) => parseInt(n, 10));
530
+ if (parts.length !== 4 || parts.some((p) => Number.isNaN(p))) return 0;
531
+ const [a, b, c, d] = parts;
532
+ return a * 16777216 + (b << 16 >>> 0) + (c << 8 >>> 0) + d >>> 0;
533
+ }
534
+ function isPrivateIPv4(ip) {
535
+ if (!/^\d+\.\d+\.\d+\.\d+$/.test(ip)) return false;
536
+ const parts = ip.split(".").map((n) => parseInt(n, 10));
537
+ if (parts.some((p) => Number.isNaN(p) || p < 0 || p > 255)) return false;
538
+ const v = ipv4ToInt(ip);
539
+ return PRIVATE_IPV4_BLOCKS.some((b) => (v & b.mask) >>> 0 === b.net);
540
+ }
541
+ function isPrivateIPv6(ip) {
542
+ const lower = ip.toLowerCase();
543
+ if (lower === "::1" || lower === "::") return true;
544
+ if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
545
+ if (lower.startsWith("fe80:")) return true;
546
+ if (lower.startsWith("::ffff:")) {
547
+ const v4 = lower.slice("::ffff:".length);
548
+ return isPrivateIPv4(v4);
549
+ }
550
+ return false;
551
+ }
552
+ var FORBIDDEN_METADATA_HOSTS = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.azure.com", "metadata.aws.com"]);
553
+ async function checkOutboundUrl(target, allowedHosts, outbound) {
554
+ let url;
555
+ try {
556
+ url = new URL(target);
557
+ } catch {
558
+ return { ok: false, reason: `invalid URL: ${target}` };
559
+ }
560
+ if (url.protocol !== "https:" && !(outbound.allowHttp && url.protocol === "http:")) {
561
+ return { ok: false, reason: `forbidden scheme "${url.protocol}" (https: required)` };
562
+ }
563
+ const hostname = url.hostname.toLowerCase();
564
+ if (FORBIDDEN_METADATA_HOSTS.has(hostname)) {
565
+ return { ok: false, reason: `cloud metadata hostname "${hostname}" is blocked` };
566
+ }
567
+ if (!allowedHosts.has(hostname)) {
568
+ return { ok: false, reason: `hostname "${hostname}" is not in the bundle's declared services` };
569
+ }
570
+ if (outbound.allowPrivateNetworks) {
571
+ return { ok: true };
572
+ }
573
+ let addresses;
574
+ try {
575
+ addresses = await dns.lookup(hostname, { all: true });
576
+ } catch (e) {
577
+ return { ok: false, reason: `DNS resolution failed for "${hostname}": ${e.message}` };
578
+ }
579
+ for (const a of addresses) {
580
+ if (a.family === 4 && isPrivateIPv4(a.address)) {
581
+ return { ok: false, reason: `host "${hostname}" resolved to private/loopback IPv4 ${a.address}` };
582
+ }
583
+ if (a.family === 6 && isPrivateIPv6(a.address)) {
584
+ return { ok: false, reason: `host "${hostname}" resolved to private IPv6 ${a.address}` };
585
+ }
586
+ }
587
+ return { ok: true };
588
+ }
589
+
590
+ // plugins/plugin-skilled-openapi/src/executor/openapi-runtime.ts
591
+ async function buildSecurityContext(args) {
592
+ const { binding, bundleId, resolver, callerToken } = args;
593
+ const ctx = {};
594
+ switch (binding.kind) {
595
+ case "none":
596
+ return ctx;
597
+ case "bearer": {
598
+ let token;
599
+ if (binding.passthroughCallerToken) {
600
+ token = callerToken;
601
+ if (!token) throw new Error("passthrough caller token requested but not supplied");
602
+ } else {
603
+ token = await resolver.resolve(binding.vaultRef, { bundleId });
604
+ if (!token) throw new Error(`bearer vaultRef "${binding.vaultRef}" did not resolve`);
605
+ }
606
+ ctx.jwt = token;
607
+ return ctx;
608
+ }
609
+ case "apiKey": {
610
+ const value = await resolver.resolve(binding.vaultRef, { bundleId });
611
+ if (!value) throw new Error(`apiKey vaultRef "${binding.vaultRef}" did not resolve`);
612
+ ctx.apiKeys = { [binding.name]: value };
613
+ ctx.apiKey = value;
614
+ return ctx;
615
+ }
616
+ case "oauth2": {
617
+ const token = await resolver.resolve(binding.vaultRef, { bundleId });
618
+ if (!token) throw new Error(`oauth2 vaultRef "${binding.vaultRef}" did not resolve`);
619
+ ctx.oauth2Token = token;
620
+ return ctx;
621
+ }
622
+ }
623
+ }
624
+ function toMcpOpenAPITool(entry) {
625
+ const { op, service, authBinding } = entry;
626
+ const securityMapper = [];
627
+ if (authBinding.kind === "apiKey") {
628
+ securityMapper.push({
629
+ inputKey: `__sec_${authBinding.name}`,
630
+ type: authBinding.in,
631
+ key: authBinding.name,
632
+ required: false,
633
+ security: { scheme: authBinding.name, type: "apiKey", name: authBinding.name, in: authBinding.in }
634
+ });
635
+ } else if (authBinding.kind === "bearer") {
636
+ securityMapper.push({
637
+ inputKey: "__sec_bearer",
638
+ type: "header",
639
+ key: "Authorization",
640
+ required: false,
641
+ security: { scheme: "bearer", type: "http", httpScheme: "bearer" }
642
+ });
643
+ } else if (authBinding.kind === "oauth2") {
644
+ securityMapper.push({
645
+ inputKey: "__sec_oauth2",
646
+ type: "header",
647
+ key: "Authorization",
648
+ required: false,
649
+ security: { scheme: "oauth2", type: "oauth2" }
650
+ });
651
+ }
652
+ return {
653
+ name: op.operationId,
654
+ description: op.description ?? op.summary ?? `${op.httpMethod} ${op.pathTemplate}`,
655
+ inputSchema: op.inputSchema,
656
+ outputSchema: op.outputSchema,
657
+ mapper: [...op.mapper, ...securityMapper],
658
+ metadata: {
659
+ path: op.pathTemplate,
660
+ method: op.httpMethod,
661
+ operationId: op.operationId,
662
+ operationSummary: op.summary,
663
+ operationDescription: op.description,
664
+ servers: [{ url: service.baseUrl }]
665
+ }
666
+ };
667
+ }
668
+ async function resolveSecurity(tool, ctx) {
669
+ const { SecurityResolver } = __require("mcp-from-openapi");
670
+ const resolver = new SecurityResolver();
671
+ return resolver.resolve(tool.mapper, ctx);
672
+ }
673
+ async function executeOperation(args) {
674
+ const { entry, bundleId, input, callerToken, deps } = args;
675
+ const { outbound, resolver, allowedHosts, logger } = deps;
676
+ const fetchImpl = deps.fetchImpl ?? fetch;
677
+ let mcpTool;
678
+ try {
679
+ mcpTool = toMcpOpenAPITool(entry);
680
+ } catch (e) {
681
+ return failure(0, `tool projection failed: ${e.message}`);
682
+ }
683
+ let securityContext;
684
+ try {
685
+ securityContext = await buildSecurityContext({
686
+ binding: entry.authBinding,
687
+ bundleId,
688
+ resolver,
689
+ callerToken
690
+ });
691
+ } catch (e) {
692
+ return failure(0, `auth resolution failed: ${e.message}`);
693
+ }
694
+ let security;
695
+ try {
696
+ security = await resolveSecurity(mcpTool, securityContext);
697
+ } catch (e) {
698
+ return failure(0, `security resolve failed: ${e.message}`);
699
+ }
700
+ let req;
701
+ try {
702
+ req = buildRequest(mcpTool, input, security, entry.service.baseUrl);
703
+ } catch (e) {
704
+ return failure(0, `request build failed: ${e.message}`);
705
+ }
706
+ const ssrf = await checkOutboundUrl(req.url, allowedHosts, outbound);
707
+ if (!ssrf.ok) {
708
+ return failure(0, `ssrf check rejected request: ${ssrf.reason}`);
709
+ }
710
+ const timeoutMs = entry.op.timeoutMs ?? outbound.defaultTimeoutMs;
711
+ const maxBytes = entry.op.maxResponseBytes ?? outbound.defaultMaxResponseBytes;
712
+ const ac = new AbortController();
713
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
714
+ timer.unref?.();
715
+ try {
716
+ const response = await fetchImpl(req.url, {
717
+ method: entry.op.httpMethod,
718
+ headers: req.headers,
719
+ body: req.body !== void 0 ? JSON.stringify(req.body) : void 0,
720
+ signal: ac.signal
721
+ });
722
+ const contentType = response.headers.get("content-type") ?? void 0;
723
+ const reader = response.body?.getReader();
724
+ let received = 0;
725
+ const chunks = [];
726
+ if (reader) {
727
+ for (; ; ) {
728
+ const { value, done } = await reader.read();
729
+ if (done) break;
730
+ if (value) {
731
+ received += value.byteLength;
732
+ if (received > maxBytes) {
733
+ return failure(response.status, `response exceeded maxResponseBytes (${maxBytes})`);
734
+ }
735
+ chunks.push(value);
736
+ }
737
+ }
738
+ }
739
+ const buf = Buffer.concat(chunks.map((c) => Buffer.from(c)));
740
+ const synthetic = new Response(buf, {
741
+ status: response.status,
742
+ headers: response.headers
743
+ });
744
+ const parsed = await parseResponse(synthetic);
745
+ void logger;
746
+ return {
747
+ ok: response.ok,
748
+ status: response.status,
749
+ contentType,
750
+ data: parsed.data,
751
+ responseBytes: received
752
+ };
753
+ } catch (e) {
754
+ const err = e;
755
+ return failure(0, err.name === "AbortError" ? `timeout after ${timeoutMs}ms` : err.message);
756
+ } finally {
757
+ clearTimeout(timer);
758
+ }
759
+ }
760
+ function failure(status, error) {
761
+ return { ok: false, status, data: null, error, responseBytes: 0 };
762
+ }
763
+
764
+ // plugins/plugin-skilled-openapi/src/executor/schema-cache.ts
765
+ import { z as z2 } from "@frontmcp/lazy-zod";
766
+ var compiled = /* @__PURE__ */ new Map();
767
+ var cacheKey = (bundleVersion, opId) => `${bundleVersion}\0${opId}`;
768
+ function compileOne(jsonSchema) {
769
+ if (!jsonSchema || typeof jsonSchema !== "object") {
770
+ return { schema: z2.looseObject({}), failed: true };
771
+ }
772
+ try {
773
+ const zodSchema = z2.fromJSONSchema(
774
+ jsonSchema,
775
+ {
776
+ defaultTarget: "draft-2020-12"
777
+ }
778
+ );
779
+ if (typeof zodSchema?.parse !== "function") {
780
+ return { schema: z2.looseObject({}), failed: true };
781
+ }
782
+ return { schema: zodSchema, failed: false };
783
+ } catch {
784
+ return { schema: z2.looseObject({}), failed: true };
785
+ }
786
+ }
787
+ function getCompiledOpSchemas(args) {
788
+ const key2 = cacheKey(args.bundleVersion, args.operationId);
789
+ const hit = compiled.get(key2);
790
+ if (hit) return hit;
791
+ const input = compileOne(args.inputSchema);
792
+ const output = compileOne(args.outputSchema);
793
+ const value = {
794
+ input: input.schema,
795
+ output: output.schema,
796
+ inputConversionFailed: input.failed,
797
+ outputConversionFailed: output.failed
798
+ };
799
+ compiled.set(key2, value);
800
+ return value;
801
+ }
802
+
803
+ // plugins/plugin-skilled-openapi/src/tools/execute-action.schema.ts
804
+ import { z as z3 } from "@frontmcp/lazy-zod";
805
+ var executeActionDescription = `Execute one action of a previously loaded skill.
806
+
807
+ Pipeline:
808
+ 1. Resolve (skillId, actionId) \u2192 bundled OpenAPI operation
809
+ 2. Authorize: caller's authInfo is checked against the action's required authorities (if any)
810
+ 3. Validate: the input is validated against the action's inputJsonSchema by the underlying executor
811
+ 4. Outbound: an HTTPS request is built and sent to the service the action belongs to,
812
+ with credentials injected from the configured vault (never echoed back to you)
813
+ 5. Response: the response body is validated against outputJsonSchema and returned in
814
+ a structured envelope. Failures (auth, schema, network) are returned as ok:false
815
+ with a structured error string \u2014 they DO NOT throw.
816
+
817
+ INPUT: { skillId, actionId, input }
818
+ OUTPUT: { ok, status, data?, contentType?, error? }`;
819
+ var executeActionInputSchema = {
820
+ skillId: z3.string().min(1).max(256).describe("Skill that owns the action"),
821
+ actionId: z3.string().min(1).max(256).describe("Action id (operationId) within the skill"),
822
+ input: z3.record(z3.string(), z3.unknown()).optional().describe("Flat input object; keys correspond to the action inputJsonSchema properties")
823
+ };
824
+ var executeActionOutputSchema = {
825
+ ok: z3.boolean(),
826
+ status: z3.number().int(),
827
+ data: z3.unknown().optional(),
828
+ contentType: z3.string().optional(),
829
+ error: z3.string().optional()
830
+ };
831
+
832
+ // plugins/plugin-skilled-openapi/src/tools/execute-action.tool.ts
833
+ var ExecuteActionTool = class extends ToolContext {
834
+ async execute(input) {
835
+ this.get(BundleSyncService);
836
+ const config = this.get(SkilledOpenApiConfig);
837
+ const hiddenOps = this.get(HiddenOpRegistry);
838
+ const bundleStore = this.get(BundleStore);
839
+ const guard = this.get(AuthorityGuard);
840
+ const resolver = this.get(SkilledOpenApiCredentialResolver);
841
+ const auditWriter = this.tryGet(SkillAuditWriterToken);
842
+ const auditSubject = this.authInfo?.user?.sub ?? "anonymous";
843
+ const detachAudit = (op, phase) => {
844
+ op.catch((error) => {
845
+ this.logger.warn(
846
+ `[skill-audit] detached ${phase} write failed: ${error instanceof Error ? error.message : String(error)}`
847
+ );
848
+ });
849
+ };
850
+ const TOTAL_STEPS = 5;
851
+ const tick = (step, message) => this.progress(step, TOTAL_STEPS, message);
852
+ const TELEMETRY_ACCESSOR_TOKEN = /* @__PURE__ */ Symbol.for("frontmcp:observability:telemetry-accessor");
853
+ const tel = this.tryGet(TELEMETRY_ACCESSOR_TOKEN);
854
+ const phaseEvent = (phase, attrs) => {
855
+ tel?.addEvent("skill_action.phase", {
856
+ phase,
857
+ skillId: input.skillId,
858
+ actionId: input.actionId,
859
+ ...attrs ?? {}
860
+ });
861
+ };
862
+ await tick(1, "resolve-action");
863
+ phaseEvent("resolve-action");
864
+ const entry = hiddenOps.get(input.skillId, input.actionId);
865
+ if (!entry) {
866
+ return {
867
+ ok: false,
868
+ status: 0,
869
+ error: `unknown action "${input.skillId}/${input.actionId}" \u2014 search_skill / load_skill first`
870
+ };
871
+ }
872
+ const pinned = entry;
873
+ const bundleId = pinned.bundleId;
874
+ const bundle = bundleStore.current();
875
+ await tick(2, "authority-check");
876
+ phaseEvent("authority-check", { bundleVersion: pinned.bundleVersion });
877
+ const policy = pinned.op.requiredAuthorities;
878
+ const authResult = await guard.check({
879
+ policy,
880
+ authInfo: this.authInfo ?? {},
881
+ input: input.input ?? {}
882
+ });
883
+ if (!authResult.granted) {
884
+ if (auditWriter) {
885
+ detachAudit(
886
+ auditWriter.writeAuthorityFail(
887
+ {
888
+ subject: auditSubject,
889
+ skillId: input.skillId,
890
+ actionId: input.actionId,
891
+ bundleId,
892
+ bundleVersion: pinned.bundleVersion,
893
+ input: input.input ?? {}
894
+ },
895
+ { reason: authResult.deniedBy ?? "policy not satisfied" }
896
+ ),
897
+ "authority-check-fail"
898
+ );
899
+ }
900
+ return {
901
+ ok: false,
902
+ status: 0,
903
+ error: `authority denied: ${authResult.deniedBy ?? "policy not satisfied"}`
904
+ };
905
+ }
906
+ if (auditWriter) {
907
+ detachAudit(
908
+ auditWriter.writeAuthorityPass({
909
+ subject: auditSubject,
910
+ skillId: input.skillId,
911
+ actionId: input.actionId,
912
+ bundleId,
913
+ bundleVersion: pinned.bundleVersion,
914
+ input: input.input ?? {}
915
+ }),
916
+ "authority-check-pass"
917
+ );
918
+ }
919
+ const schemas = getCompiledOpSchemas({
920
+ bundleVersion: pinned.bundleVersion,
921
+ operationId: pinned.op.operationId,
922
+ inputSchema: pinned.op.inputSchema,
923
+ outputSchema: pinned.op.outputSchema
924
+ });
925
+ await tick(3, "input-validate");
926
+ phaseEvent("input-validate", { bundleVersion: pinned.bundleVersion });
927
+ const inputParse = schemas.input.safeParse(input.input ?? {});
928
+ if (!inputParse.success) {
929
+ return {
930
+ ok: false,
931
+ status: 0,
932
+ error: `input validation failed: ${formatZodIssues(inputParse.error.issues)}`
933
+ };
934
+ }
935
+ const allowedHosts = /* @__PURE__ */ new Set();
936
+ try {
937
+ allowedHosts.add(new URL(pinned.service.baseUrl).hostname.toLowerCase());
938
+ } catch {
939
+ }
940
+ if (bundle) {
941
+ for (const svc of bundle.services) {
942
+ try {
943
+ allowedHosts.add(new URL(svc.baseUrl).hostname.toLowerCase());
944
+ } catch {
945
+ }
946
+ }
947
+ }
948
+ const deps = {
949
+ outbound: config.outbound,
950
+ resolver: { resolve: (ref, opts) => resolver.resolve(ref, opts) },
951
+ allowedHosts,
952
+ logger: this.logger
953
+ };
954
+ await tick(4, "http-call");
955
+ phaseEvent("http-call", { bundleVersion: pinned.bundleVersion });
956
+ let result;
957
+ try {
958
+ result = await executeOperation({
959
+ entry: pinned,
960
+ bundleId,
961
+ input: inputParse.data,
962
+ deps
963
+ });
964
+ } catch (e) {
965
+ if (auditWriter) {
966
+ detachAudit(
967
+ auditWriter.writeHttpCallFailure(
968
+ {
969
+ subject: auditSubject,
970
+ skillId: input.skillId,
971
+ actionId: input.actionId,
972
+ bundleId,
973
+ bundleVersion: pinned.bundleVersion,
974
+ input: input.input ?? {}
975
+ },
976
+ { status: 0, error: e }
977
+ ),
978
+ "http-call-failure"
979
+ );
980
+ }
981
+ throw e;
982
+ }
983
+ if (auditWriter) {
984
+ const auditCtx = {
985
+ subject: auditSubject,
986
+ skillId: input.skillId,
987
+ actionId: input.actionId,
988
+ bundleId,
989
+ bundleVersion: pinned.bundleVersion,
990
+ input: input.input ?? {}
991
+ };
992
+ if (result.ok) {
993
+ detachAudit(
994
+ auditWriter.writeHttpCallSuccess(auditCtx, {
995
+ status: result.status,
996
+ output: result.data ?? null
997
+ }),
998
+ "http-call-success"
999
+ );
1000
+ } else {
1001
+ detachAudit(
1002
+ auditWriter.writeHttpCallFailure(auditCtx, {
1003
+ status: result.status,
1004
+ error: result.error ?? `http call failed with status ${result.status}`
1005
+ }),
1006
+ "http-call-failure"
1007
+ );
1008
+ }
1009
+ }
1010
+ const isJsonResponse = (result.contentType ?? "").toLowerCase().includes("application/json");
1011
+ if (result.ok && isJsonResponse && result.data !== void 0 && result.data !== null) {
1012
+ const outputParse = schemas.output.safeParse(result.data);
1013
+ if (!outputParse.success) {
1014
+ return {
1015
+ ok: false,
1016
+ status: result.status,
1017
+ ...result.contentType ? { contentType: result.contentType } : {},
1018
+ error: `upstream response failed output schema: ${formatZodIssues(outputParse.error.issues)}`
1019
+ };
1020
+ }
1021
+ }
1022
+ await tick(5, "done");
1023
+ phaseEvent("done", { bundleVersion: pinned.bundleVersion });
1024
+ tel?.setAttributes({
1025
+ "skill_action.status": result.status,
1026
+ "skill_action.ok": result.ok,
1027
+ "skill_action.skill_id": input.skillId,
1028
+ "skill_action.action_id": input.actionId,
1029
+ "skill_action.bundle_version": pinned.bundleVersion
1030
+ });
1031
+ return {
1032
+ ok: result.ok,
1033
+ status: result.status,
1034
+ ...result.data !== void 0 && result.data !== null ? { data: result.data } : {},
1035
+ ...result.contentType ? { contentType: result.contentType } : {},
1036
+ ...result.error ? { error: result.error } : {}
1037
+ };
1038
+ }
1039
+ };
1040
+ ExecuteActionTool = __decorateClass([
1041
+ Tool({
1042
+ name: "execute_action",
1043
+ description: executeActionDescription,
1044
+ inputSchema: executeActionInputSchema,
1045
+ outputSchema: executeActionOutputSchema,
1046
+ annotations: {
1047
+ readOnlyHint: false,
1048
+ destructiveHint: true,
1049
+ openWorldHint: true
1050
+ }
1051
+ })
1052
+ ], ExecuteActionTool);
1053
+ function formatZodIssues(issues) {
1054
+ if (!issues.length) return "unspecified validation error";
1055
+ return issues.slice(0, 3).map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`).join("; ");
1056
+ }
1057
+
1058
+ // plugins/plugin-skilled-openapi/src/tools/load-skill.tool.ts
1059
+ import { InternalMcpError, PublicMcpError, ScopeEntry, Tool as Tool2, ToolContext as ToolContext2 } from "@frontmcp/sdk";
1060
+
1061
+ // plugins/plugin-skilled-openapi/src/tools/load-skill.schema.ts
1062
+ import { z as z4 } from "@frontmcp/lazy-zod";
1063
+ var loadSkillDescription = `Load the full instructions and executable actions for a specific skill.
1064
+
1065
+ Use this AFTER \`search_skill\` once you've identified the right skill for the
1066
+ user's task. The returned object contains:
1067
+ - \`instructions\`: markdown the LLM should read carefully before invoking
1068
+ - \`actions\`: each action's input/output JSON Schema and required authorities
1069
+ - \`bundleVersion\`: changes when the bundle is hot-swapped (use it to detect drift)
1070
+
1071
+ INPUT: { skillId }
1072
+ OUTPUT: { skill: { id, name, description, instructions, actions[] }, isComplete }`;
1073
+ var loadSkillInputSchema = {
1074
+ skillId: z4.string().min(1).max(256).describe("Stable skill identifier (returned by search_skill)")
1075
+ };
1076
+ var loadSkillOutputSchema = {
1077
+ skill: z4.object({
1078
+ id: z4.string(),
1079
+ name: z4.string(),
1080
+ description: z4.string(),
1081
+ instructions: z4.string(),
1082
+ bundleVersion: z4.string().optional(),
1083
+ actions: z4.array(
1084
+ z4.object({
1085
+ actionId: z4.string(),
1086
+ summary: z4.string(),
1087
+ description: z4.string().optional(),
1088
+ inputJsonSchema: z4.record(z4.string(), z4.unknown()),
1089
+ outputJsonSchema: z4.record(z4.string(), z4.unknown()),
1090
+ requiredAuthorities: z4.record(z4.string(), z4.unknown()).optional()
1091
+ })
1092
+ ).optional()
1093
+ }),
1094
+ isComplete: z4.boolean(),
1095
+ warning: z4.string().optional()
1096
+ };
1097
+
1098
+ // plugins/plugin-skilled-openapi/src/tools/load-skill.tool.ts
1099
+ var LoadSkillTool = class extends ToolContext2 {
1100
+ async execute(input) {
1101
+ this.get(BundleSyncService);
1102
+ const scope = this.get(ScopeEntry);
1103
+ const skillRegistry = scope.skills;
1104
+ if (!skillRegistry) {
1105
+ throw new InternalMcpError("SkillRegistry is not available on the active scope", "SKILL_REGISTRY_UNAVAILABLE");
1106
+ }
1107
+ const result = await skillRegistry.loadSkill(input.skillId);
1108
+ if (!result) {
1109
+ throw new PublicMcpError(`Skill "${input.skillId}" not found`, "SKILL_NOT_FOUND", 404);
1110
+ }
1111
+ const skill = result.skill;
1112
+ return {
1113
+ skill: {
1114
+ id: skill.id,
1115
+ name: skill.name,
1116
+ description: skill.description,
1117
+ instructions: skill.instructions,
1118
+ ...skill.bundleVersion !== void 0 && { bundleVersion: skill.bundleVersion },
1119
+ ...skill.actions ? { actions: skill.actions } : {}
1120
+ },
1121
+ isComplete: result.isComplete,
1122
+ ...result.warning !== void 0 && { warning: result.warning }
1123
+ };
1124
+ }
1125
+ };
1126
+ LoadSkillTool = __decorateClass([
1127
+ Tool2({
1128
+ name: "load_skill",
1129
+ description: loadSkillDescription,
1130
+ inputSchema: loadSkillInputSchema,
1131
+ outputSchema: loadSkillOutputSchema,
1132
+ annotations: {
1133
+ readOnlyHint: true,
1134
+ openWorldHint: false
1135
+ }
1136
+ })
1137
+ ], LoadSkillTool);
1138
+
1139
+ // plugins/plugin-skilled-openapi/src/tools/operation-tool.factory.ts
1140
+ import {
1141
+ ToolInstance,
1142
+ ToolKind
1143
+ } from "@frontmcp/sdk";
1144
+ var OPERATION_TOOL_OWNER_TOKEN = /* @__PURE__ */ Symbol.for("skilled-openapi:operation-tool-owner");
1145
+ var OPERATION_TOOL_OWNER = {
1146
+ kind: "plugin",
1147
+ id: "skilled-openapi",
1148
+ ref: OPERATION_TOOL_OWNER_TOKEN
1149
+ };
1150
+ function operationToolName(bundleId, operationId) {
1151
+ const namespaced = `${bundleId}.${operationId}`;
1152
+ if (namespaced.length <= 64) return namespaced;
1153
+ return operationId.length <= 64 ? operationId : operationId.slice(0, 64);
1154
+ }
1155
+ function buildOperationMetadata(entry) {
1156
+ const name = operationToolName(entry.bundleId, entry.op.operationId);
1157
+ const summary = entry.op.summary ?? `${entry.op.httpMethod} ${entry.op.pathTemplate}`;
1158
+ const description = entry.op.description ? `${summary}
1159
+
1160
+ ${entry.op.description}` : summary;
1161
+ const meta = {
1162
+ name,
1163
+ description,
1164
+ inputSchema: {},
1165
+ outputSchema: void 0,
1166
+ visibility: "internal",
1167
+ annotations: {
1168
+ readOnlyHint: entry.op.httpMethod === "GET" || entry.op.httpMethod === "HEAD",
1169
+ destructiveHint: entry.op.httpMethod !== "GET" && entry.op.httpMethod !== "HEAD",
1170
+ openWorldHint: true
1171
+ }
1172
+ };
1173
+ meta.rawInputSchema = entry.op.inputSchema;
1174
+ return meta;
1175
+ }
1176
+ var OperationToolFactory = class {
1177
+ constructor(deps) {
1178
+ this.deps = deps;
1179
+ }
1180
+ /** Map (bundleId|opId) → the executor function that doubles as registry token. */
1181
+ registered = /* @__PURE__ */ new Map();
1182
+ /**
1183
+ * Register one operation as an internal tool. Idempotent — calling twice
1184
+ * with the same `(bundleId, operationId)` is a no-op (the existing
1185
+ * registration is left in place).
1186
+ */
1187
+ register(entry) {
1188
+ const key2 = `${entry.bundleId}|${entry.op.operationId}`;
1189
+ if (this.registered.has(key2)) return;
1190
+ const executor = this.makeExecutor(entry);
1191
+ const metadata = buildOperationMetadata(entry);
1192
+ const record = {
1193
+ kind: ToolKind.FUNCTION,
1194
+ provide: executor,
1195
+ metadata
1196
+ };
1197
+ const instance = new ToolInstance(record, this.deps.providers, OPERATION_TOOL_OWNER);
1198
+ this.deps.toolRegistry.registerToolInstance(instance);
1199
+ this.registered.set(key2, executor);
1200
+ }
1201
+ /**
1202
+ * Unregister every tool this factory has registered. Used on bundle swap
1203
+ * before re-registering the new bundle's operations.
1204
+ */
1205
+ unregisterAll() {
1206
+ for (const executor of this.registered.values()) {
1207
+ try {
1208
+ this.deps.toolRegistry.unregisterToolInstance(executor);
1209
+ } catch (e) {
1210
+ this.deps.logger.warn(`unregister failed: ${e.message}`);
1211
+ }
1212
+ }
1213
+ this.registered.clear();
1214
+ }
1215
+ /** Number of currently-registered internal operation tools (test/audit hook). */
1216
+ get size() {
1217
+ return this.registered.size;
1218
+ }
1219
+ /**
1220
+ * Build the actual function executor for one op. Mirrors `ExecuteActionTool.execute`:
1221
+ * authority check + input validation + `executeOperation` — so callers
1222
+ * reaching the op via `callTool` get the same security gates as callers
1223
+ * going through `execute_action`.
1224
+ */
1225
+ makeExecutor(entry) {
1226
+ return async (input, ctx) => {
1227
+ const config = ctx.get(SkilledOpenApiConfig);
1228
+ const guard = ctx.get(AuthorityGuard);
1229
+ const resolver = ctx.get(SkilledOpenApiCredentialResolver);
1230
+ const policy = entry.op.requiredAuthorities;
1231
+ const authResult = await guard.check({
1232
+ policy,
1233
+ authInfo: ctx.authInfo ?? {},
1234
+ input: input ?? {}
1235
+ });
1236
+ if (!authResult.granted) {
1237
+ return {
1238
+ ok: false,
1239
+ status: 0,
1240
+ error: `authority denied: ${authResult.deniedBy ?? "policy not satisfied"}`
1241
+ };
1242
+ }
1243
+ const schemas = getCompiledOpSchemas({
1244
+ bundleVersion: entry.bundleVersion,
1245
+ operationId: entry.op.operationId,
1246
+ inputSchema: entry.op.inputSchema,
1247
+ outputSchema: entry.op.outputSchema
1248
+ });
1249
+ const inputParse = schemas.input.safeParse(input ?? {});
1250
+ if (!inputParse.success) {
1251
+ return {
1252
+ ok: false,
1253
+ status: 0,
1254
+ error: `input validation failed: ${inputParse.error.issues.slice(0, 3).map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`).join("; ")}`
1255
+ };
1256
+ }
1257
+ const allowedHosts = /* @__PURE__ */ new Set();
1258
+ try {
1259
+ allowedHosts.add(new URL(entry.service.baseUrl).hostname.toLowerCase());
1260
+ } catch {
1261
+ }
1262
+ const deps = {
1263
+ outbound: config.outbound,
1264
+ resolver: { resolve: (ref, opts) => resolver.resolve(ref, opts) },
1265
+ allowedHosts,
1266
+ logger: ctx.logger
1267
+ };
1268
+ const result = await executeOperation({
1269
+ entry,
1270
+ bundleId: entry.bundleId,
1271
+ input: inputParse.data,
1272
+ deps
1273
+ });
1274
+ return {
1275
+ ok: result.ok,
1276
+ status: result.status,
1277
+ ...result.data !== void 0 && result.data !== null ? { data: result.data } : {},
1278
+ ...result.contentType ? { contentType: result.contentType } : {},
1279
+ ...result.error ? { error: result.error } : {}
1280
+ };
1281
+ };
1282
+ }
1283
+ };
1284
+
1285
+ // plugins/plugin-skilled-openapi/src/tools/search-skill.tool.ts
1286
+ import { ScopeEntry as ScopeEntry2, Tool as Tool3, ToolContext as ToolContext3 } from "@frontmcp/sdk";
1287
+
1288
+ // plugins/plugin-skilled-openapi/src/tools/search-skill.schema.ts
1289
+ import { z as z5 } from "@frontmcp/lazy-zod";
1290
+ var searchSkillDescription = `Search the available skills by free-form query.
1291
+
1292
+ A "skill" is a curated bundle of REST operations exposed to you behind a single
1293
+ named capability \u2014 instead of seeing each individual API endpoint, you see one
1294
+ skill that knows how to do something useful (e.g. "billing", "customers"). Use
1295
+ this tool first to discover what skills exist for the user's request, then call
1296
+ \`load_skill\` to read its instructions + the actions it offers, and \`execute_action\`
1297
+ to actually invoke one.
1298
+
1299
+ INPUT:
1300
+ - query: short natural-language description of what you want to do
1301
+ - limit?: max results (default 20, max 50)
1302
+ - tags?: filter to skills carrying these tags
1303
+
1304
+ OUTPUT: { skills: Array<{ skillId, name, description, score }> }`;
1305
+ var searchSkillInputSchema = {
1306
+ query: z5.string().min(1).max(2048).describe("Natural-language search query"),
1307
+ limit: z5.number().int().positive().max(50).optional().describe("Max results (default 20)"),
1308
+ tags: z5.array(z5.string().min(1).max(64)).max(16).optional().describe("Filter by tags")
1309
+ };
1310
+ var searchSkillOutputSchema = {
1311
+ skills: z5.array(
1312
+ z5.object({
1313
+ skillId: z5.string(),
1314
+ name: z5.string(),
1315
+ description: z5.string(),
1316
+ score: z5.number(),
1317
+ bundleVersion: z5.string().optional()
1318
+ })
1319
+ )
1320
+ };
1321
+
1322
+ // plugins/plugin-skilled-openapi/src/tools/search-skill.tool.ts
1323
+ var SearchSkillTool = class extends ToolContext3 {
1324
+ async execute(input) {
1325
+ this.get(BundleSyncService);
1326
+ const scope = this.get(ScopeEntry2);
1327
+ const skillRegistry = scope.skills;
1328
+ if (!skillRegistry || !skillRegistry.hasAny()) {
1329
+ return { skills: [] };
1330
+ }
1331
+ const limit = input.limit ?? 20;
1332
+ const tags = input.tags;
1333
+ const results = await skillRegistry.search(input.query, {
1334
+ topK: limit,
1335
+ ...tags ? { tags } : {}
1336
+ });
1337
+ return {
1338
+ skills: results.map((r) => ({
1339
+ skillId: r.metadata.id ?? r.metadata.name,
1340
+ name: r.metadata.name,
1341
+ description: r.metadata.description ?? "",
1342
+ score: r.score,
1343
+ ...r.metadata.bundleVersion ? { bundleVersion: r.metadata.bundleVersion } : {}
1344
+ }))
1345
+ };
1346
+ }
1347
+ };
1348
+ SearchSkillTool = __decorateClass([
1349
+ Tool3({
1350
+ name: "search_skill",
1351
+ description: searchSkillDescription,
1352
+ inputSchema: searchSkillInputSchema,
1353
+ outputSchema: searchSkillOutputSchema,
1354
+ annotations: {
1355
+ readOnlyHint: true,
1356
+ openWorldHint: false
1357
+ }
1358
+ })
1359
+ ], SearchSkillTool);
1360
+
1361
+ // plugins/plugin-skilled-openapi/src/skilled-openapi.plugin.ts
1362
+ var TELEMETRY_FACTORY_TOKEN = /* @__PURE__ */ Symbol.for("frontmcp:observability:telemetry-factory");
1363
+ function resolveBundleTelemetry(scope) {
1364
+ const providers = scope?.providers;
1365
+ if (!providers || typeof providers.get !== "function") return void 0;
1366
+ let factory;
1367
+ try {
1368
+ factory = providers.get(TELEMETRY_FACTORY_TOKEN);
1369
+ } catch {
1370
+ return void 0;
1371
+ }
1372
+ if (!factory || typeof factory.createCounter !== "function") return void 0;
1373
+ return {
1374
+ createCounter: (name, description) => factory.createCounter(name, description),
1375
+ startSpan: (name, attributes) => factory.startSpan(name, attributes)
1376
+ };
1377
+ }
1378
+ var SkilledOpenApiPlugin = class extends DynamicPlugin {
1379
+ options;
1380
+ cachedLogger;
1381
+ constructor(options) {
1382
+ super();
1383
+ this.options = skilledOpenApiPluginOptionsSchema.parse(options);
1384
+ this.warnIfInsecureConfig();
1385
+ }
1386
+ getLogger() {
1387
+ if (!this.cachedLogger) {
1388
+ this.cachedLogger = this.get(FrontMcpLogger).child("skilled-openapi");
1389
+ }
1390
+ return this.cachedLogger;
1391
+ }
1392
+ warnIfInsecureConfig() {
1393
+ if (this.options.dev) {
1394
+ console.warn(
1395
+ "[skilled-openapi] dev=true: signature verification BYPASSED and http:// URLs allowed. NEVER use this in production."
1396
+ );
1397
+ }
1398
+ if (!this.options.requireSignature && !this.options.dev) {
1399
+ console.warn(
1400
+ "[skilled-openapi] requireSignature=false without dev=true: bundle signing is OFF. This violates the v1.2 security baseline."
1401
+ );
1402
+ }
1403
+ }
1404
+ static dynamicProviders(options) {
1405
+ const parsed = skilledOpenApiPluginOptionsSchema.parse(options);
1406
+ const config = new SkilledOpenApiConfig(parsed);
1407
+ return [
1408
+ { name: "skilled-openapi:config", provide: SkilledOpenApiConfig, useValue: config },
1409
+ { name: "skilled-openapi:hidden-ops", provide: HiddenOpRegistry, useValue: new HiddenOpRegistry() },
1410
+ {
1411
+ name: "skilled-openapi:bundle-store",
1412
+ provide: BundleStore2,
1413
+ // Resolve TelemetryAccessor from the scope's provider registry so the
1414
+ // bundle-pulls counter and `skill.bundle.swap` span actually export.
1415
+ // The accessor is structurally compatible with `BundleStoreTelemetry`.
1416
+ // ObservabilityPlugin is an optional peer dep — when it isn't installed
1417
+ // we resolve `undefined` and the BundleStore falls through its zero-cost
1418
+ // no-telemetry path.
1419
+ inject: () => [ScopeEntry3],
1420
+ useFactory: (scope) => new BundleStore2({ telemetry: resolveBundleTelemetry(scope) })
1421
+ },
1422
+ {
1423
+ name: "skilled-openapi:credential-resolver",
1424
+ provide: SkilledOpenApiCredentialResolver,
1425
+ useValue: new MemoryCredentialResolver(parsed.credentials ?? {})
1426
+ },
1427
+ {
1428
+ name: "skilled-openapi:authority-guard",
1429
+ provide: AuthorityGuard,
1430
+ inject: () => [ScopeEntry3],
1431
+ useFactory: (scope) => new AuthorityGuard({ logger: scope.logger.child("skilled-openapi:authority") })
1432
+ },
1433
+ {
1434
+ name: "skilled-openapi:bundle-sync",
1435
+ provide: BundleSyncService,
1436
+ inject: () => [ScopeEntry3, HiddenOpRegistry, BundleStore2],
1437
+ useFactory: async (scope, hiddenOps, bundleStore) => {
1438
+ const logger = scope.logger.child("skilled-openapi:sync");
1439
+ const lazySkillRegistry = new Proxy({}, {
1440
+ get(_t, prop) {
1441
+ const reg = scope.skills;
1442
+ if (!reg) {
1443
+ throw new Error(`[skilled-openapi] scope.skills not available when accessing "${String(prop)}"`);
1444
+ }
1445
+ const v = reg[prop];
1446
+ return typeof v === "function" ? v.bind(reg) : v;
1447
+ }
1448
+ });
1449
+ let opToolFactory;
1450
+ if (parsed.exposeOperationsAsInternalTools) {
1451
+ try {
1452
+ const toolRegistry = scope.tools;
1453
+ if (toolRegistry) {
1454
+ opToolFactory = new OperationToolFactory({
1455
+ toolRegistry,
1456
+ // ScopeEntry exposes a ProviderRegistryInterface; the factory
1457
+ // needs the concrete ProviderRegistry to construct ToolInstance.
1458
+ // The runtime is the same class — the cast is safe by construction.
1459
+ providers: scope.providers,
1460
+ logger: logger.child("op-tool")
1461
+ });
1462
+ } else {
1463
+ logger.warn(
1464
+ "exposeOperationsAsInternalTools=true but scope.tools is unavailable; per-op internal tools disabled"
1465
+ );
1466
+ }
1467
+ } catch (e) {
1468
+ logger.warn(
1469
+ `failed to build OperationToolFactory: ${e.message}; per-op internal tools disabled`
1470
+ );
1471
+ }
1472
+ }
1473
+ const sync = new BundleSyncService(
1474
+ lazySkillRegistry,
1475
+ hiddenOps,
1476
+ bundleStore,
1477
+ {
1478
+ requireSignature: parsed.requireSignature,
1479
+ trustedKeys: parsed.trustedKeys,
1480
+ exposeOperationsAsInternalTools: parsed.exposeOperationsAsInternalTools,
1481
+ // Reuse the same telemetry adapter the BundleStore uses so the
1482
+ // signature verification counters are wired through the same
1483
+ // optional ObservabilityPlugin lookup. When observability isn't
1484
+ // installed this is `undefined` and verifyBundleSignature skips
1485
+ // the counter lookup entirely.
1486
+ telemetry: resolveBundleTelemetry(scope)
1487
+ },
1488
+ logger,
1489
+ opToolFactory
1490
+ );
1491
+ let source;
1492
+ try {
1493
+ source = createBundleSource(parsed.source, parsed.bundleCacheDir, logger);
1494
+ } catch (e) {
1495
+ logger.error(`failed to construct bundle source: ${e.message}`);
1496
+ return sync;
1497
+ }
1498
+ source.onChange((bundle) => {
1499
+ void sync.apply(bundle).then((result) => {
1500
+ if (!result.applied) {
1501
+ logger.warn(`bundle ${bundle.bundleId}@${bundle.version} not applied: ${result.reason}`);
1502
+ }
1503
+ }).catch((e) => {
1504
+ logger.error(`bundle ${bundle.bundleId}@${bundle.version} apply threw: ${e.message}`);
1505
+ });
1506
+ });
1507
+ const startedSource = source;
1508
+ setImmediate(() => {
1509
+ startedSource.start().catch((e) => {
1510
+ logger.error(`bundle source failed to start: ${e.message}`);
1511
+ });
1512
+ });
1513
+ return sync;
1514
+ }
1515
+ }
1516
+ ];
1517
+ }
1518
+ };
1519
+ SkilledOpenApiPlugin = __decorateClass([
1520
+ Plugin({
1521
+ name: "skilled-openapi",
1522
+ description: "Serve a customer's OpenAPI spec as signed skill bundles with hidden per-operation tools mediated by 3 meta-tools.",
1523
+ providers: [],
1524
+ tools: [SearchSkillTool, LoadSkillTool, ExecuteActionTool]
1525
+ })
1526
+ ], SkilledOpenApiPlugin);
1527
+
1528
+ // plugins/plugin-skilled-openapi/src/index.ts
1529
+ var index_default = SkilledOpenApiPlugin;
1530
+ export {
1531
+ SkilledOpenApiConfig,
1532
+ SkilledOpenApiCredentialResolver,
1533
+ SkilledOpenApiPlugin,
1534
+ index_default as default,
1535
+ skilledOpenApiPluginOptionsSchema
1536
+ };