@frontmcp/plugin-skilled-openapi 1.4.0 → 1.5.0-rc.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 (39) hide show
  1. package/README.md +123 -17
  2. package/esm/index.mjs +640 -433
  3. package/esm/package.json +19 -8
  4. package/executor/execute-skill-action.d.ts +44 -0
  5. package/executor/execute-skill-action.d.ts.map +1 -0
  6. package/executor/host-concurrency.d.ts +9 -0
  7. package/executor/host-concurrency.d.ts.map +1 -0
  8. package/executor/openapi-runtime.d.ts.map +1 -1
  9. package/executor/ssrf-guard.d.ts +4 -0
  10. package/executor/ssrf-guard.d.ts.map +1 -1
  11. package/index.d.ts +1 -0
  12. package/index.d.ts.map +1 -1
  13. package/index.js +643 -421
  14. package/package.json +19 -8
  15. package/registry/hidden-op.registry.d.ts +15 -1
  16. package/registry/hidden-op.registry.d.ts.map +1 -1
  17. package/security/authority-guard.d.ts +35 -5
  18. package/security/authority-guard.d.ts.map +1 -1
  19. package/skilled-openapi.plugin.d.ts +41 -2
  20. package/skilled-openapi.plugin.d.ts.map +1 -1
  21. package/skilled-openapi.symbols.d.ts +3 -0
  22. package/skilled-openapi.symbols.d.ts.map +1 -1
  23. package/skilled-openapi.types.d.ts +22 -0
  24. package/skilled-openapi.types.d.ts.map +1 -1
  25. package/sync/bundle-sync.service.d.ts +1 -1
  26. package/sync/bundle-sync.service.d.ts.map +1 -1
  27. package/tools/operation-tool.factory.d.ts +5 -4
  28. package/tools/operation-tool.factory.d.ts.map +1 -1
  29. package/tools/run-workflow.schema.d.ts +28 -0
  30. package/tools/run-workflow.schema.d.ts.map +1 -0
  31. package/tools/run-workflow.tool.d.ts +6 -0
  32. package/tools/run-workflow.tool.d.ts.map +1 -0
  33. package/tools/search-skill.schema.d.ts +5 -1
  34. package/tools/search-skill.schema.d.ts.map +1 -1
  35. package/tools/search-skill.tool.d.ts.map +1 -1
  36. package/tools/execute-action.schema.d.ts +0 -26
  37. package/tools/execute-action.schema.d.ts.map +0 -1
  38. package/tools/execute-action.tool.d.ts +0 -6
  39. package/tools/execute-action.tool.d.ts.map +0 -1
package/esm/index.mjs CHANGED
@@ -1,11 +1,5 @@
1
1
  var __defProp = Object.defineProperty;
2
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
3
  var __decorateClass = (decorators, target, key2, kind) => {
10
4
  var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key2) : target;
11
5
  for (var i = decorators.length - 1, decorator; i >= 0; i--)
@@ -17,14 +11,16 @@ var __decorateClass = (decorators, target, key2, kind) => {
17
11
 
18
12
  // plugins/plugin-skilled-openapi/src/skilled-openapi.plugin.ts
19
13
  import {
20
- BundleStore as BundleStore2,
14
+ BundleStore,
21
15
  createBundleSource
22
16
  } from "@frontmcp/adapters/skills";
23
17
  import {
24
18
  DynamicPlugin,
25
19
  FrontMcpLogger,
20
+ ListToolsHook,
26
21
  Plugin,
27
- ScopeEntry as ScopeEntry3
22
+ ScopeEntry as ScopeEntry3,
23
+ buildSkillsCatalogSummary
28
24
  } from "@frontmcp/sdk";
29
25
 
30
26
  // plugins/plugin-skilled-openapi/src/executor/credential-resolver.ts
@@ -73,6 +69,19 @@ var HiddenOpRegistry = class {
73
69
  get(skillId, actionId) {
74
70
  return this.entries.get(key(skillId, actionId));
75
71
  }
72
+ /**
73
+ * Resolve an operation by actionId (operationId) ALONE — used by the enclave
74
+ * codecall (`run_workflow`), where a sandboxed script calls
75
+ * `callTool(actionId, input)` without naming the owning skill. operationIds
76
+ * are unique within a bundle, so the first match is authoritative; returns
77
+ * undefined when no loaded skill exposes the action.
78
+ */
79
+ getByActionId(actionId) {
80
+ for (const entry of this.entries.values()) {
81
+ if (entry.op.operationId === actionId) return entry;
82
+ }
83
+ return void 0;
84
+ }
76
85
  /** Set or replace the entry for `(skillId, actionId)`. */
77
86
  set(entry) {
78
87
  this.entries.set(key(entry.skillId, entry.op.operationId), entry);
@@ -125,13 +134,31 @@ var AuthorityGuard = class {
125
134
  this.logger = opts.logger;
126
135
  }
127
136
  async check(args) {
128
- const { policy, authInfo, input, env } = args;
129
- if (policy === void 0) {
137
+ const { policy, skillPolicy, isPublic, authInfo, input, env } = args;
138
+ const unprotectedOps = args.unprotectedOps ?? "allow";
139
+ const policies = [skillPolicy, policy].filter((p) => p !== void 0 && p !== null);
140
+ if (policies.length === 0) {
141
+ if (unprotectedOps === "deny" && isPublic !== true) {
142
+ return {
143
+ granted: false,
144
+ deniedBy: "unprotected_operation_denied",
145
+ message: 'operation has no required-authorities policy and is not marked public; blocked by unprotectedOps:"deny" (set the op `public: true` or attach a policy)',
146
+ evaluatedPolicies: []
147
+ };
148
+ }
130
149
  return { granted: true, evaluatedPolicies: [] };
131
150
  }
132
151
  try {
133
152
  const ctx = this.contextBuilder.build(authInfo, input, env);
134
- return await this.engine.evaluate(policy, ctx);
153
+ const evaluatedPolicies = [];
154
+ for (const p of policies) {
155
+ const res = await this.engine.evaluate(p, ctx);
156
+ evaluatedPolicies.push(...res.evaluatedPolicies ?? []);
157
+ if (!res.granted) {
158
+ return { ...res, evaluatedPolicies };
159
+ }
160
+ }
161
+ return { granted: true, evaluatedPolicies };
135
162
  } catch (e) {
136
163
  const message = normalizeCaughtMessage(e);
137
164
  this.logger?.error(`[skilled-openapi:authority] evaluation failed: ${message}`);
@@ -158,9 +185,14 @@ var SkilledOpenApiConfig = class {
158
185
  constructor(options) {
159
186
  this.options = options;
160
187
  }
188
+ options;
161
189
  get outbound() {
162
190
  return this.options.outbound;
163
191
  }
192
+ /** Default-deny policy for ops with no required-authorities (C1/C3). */
193
+ get unprotectedOps() {
194
+ return this.options.unprotectedOps;
195
+ }
164
196
  };
165
197
  var SkilledOpenApiCredentialResolver = class {
166
198
  };
@@ -223,6 +255,20 @@ var skilledOpenApiPluginOptionsObjectSchema = z.object({
223
255
  dev: z.boolean().default(false),
224
256
  /** Outbound HTTP / SSRF defenses for the executor. */
225
257
  outbound: outboundOptionsSchema,
258
+ /**
259
+ * How to treat operations that carry NO required-authorities policy (neither
260
+ * skill-level nor op-level) on the execution surface (`run_workflow` and the
261
+ * internal per-op tools):
262
+ *
263
+ * - `'allow'` (default, backward compatible): policy-less ops are callable —
264
+ * origin trust comes from the signed bundle. Appropriate when every op in
265
+ * the bundle is genuinely public (e.g. a public REST API).
266
+ * - `'deny'`: default-deny the execution surface — a policy-less op is blocked
267
+ * unless the bundle explicitly marks it `public: true`. **Recommended for
268
+ * production**: it ensures a single missing `requiredAuthorities` line can't
269
+ * silently expose a protected op (closes SECURITY-REVIEW C1/C3).
270
+ */
271
+ unprotectedOps: z.enum(["allow", "deny"]).default("allow"),
226
272
  /**
227
273
  * Source-conflict policy when more than one source registers a skill with
228
274
  * the same id. Default: locally-pinned static beats npm beats saas.
@@ -297,6 +343,12 @@ var BundleSyncService = class {
297
343
  this.logger = logger;
298
344
  this.operationToolFactory = operationToolFactory;
299
345
  }
346
+ skillRegistry;
347
+ hiddenOps;
348
+ bundleStore;
349
+ options;
350
+ logger;
351
+ operationToolFactory;
300
352
  skillUnregisterByBundleId = /* @__PURE__ */ new Map();
301
353
  /**
302
354
  * Validate signature and apply the bundle. Returns a structured result;
@@ -491,6 +543,11 @@ var BundleSyncService = class {
491
543
  }
492
544
  const entry = {
493
545
  skillId: skill.id,
546
+ // Pin the skill-level policy so it is AND-ed with the op-level policy
547
+ // at execution time (C2 — previously this was silently dropped).
548
+ ...skill.requiredAuthorities !== void 0 && {
549
+ skillRequiredAuthorities: skill.requiredAuthorities
550
+ },
494
551
  op,
495
552
  service,
496
553
  authBinding,
@@ -512,9 +569,93 @@ function collectPathsWithGet(entries) {
512
569
  return set;
513
570
  }
514
571
 
515
- // plugins/plugin-skilled-openapi/src/tools/execute-action.tool.ts
516
- import { BundleStore, SkillAuditWriterToken } from "@frontmcp/adapters/skills";
517
- import { Tool, ToolContext } from "@frontmcp/sdk";
572
+ // plugins/plugin-skilled-openapi/src/tools/load-skill.tool.ts
573
+ import { InternalMcpError, PublicMcpError, ScopeEntry, Tool, ToolContext } from "@frontmcp/sdk";
574
+
575
+ // plugins/plugin-skilled-openapi/src/tools/load-skill.schema.ts
576
+ import { z as z2 } from "@frontmcp/lazy-zod";
577
+ var loadSkillDescription = `Load the full instructions and executable actions for a specific skill.
578
+
579
+ Use this AFTER \`search_skill\` once you've identified the right skill for the
580
+ user's task. The returned object contains:
581
+ - \`instructions\`: markdown the LLM should read carefully before invoking
582
+ - \`actions\`: each action's input/output JSON Schema and required authorities
583
+ - \`bundleVersion\`: changes when the bundle is hot-swapped (use it to detect drift)
584
+
585
+ INPUT: { skillId }
586
+ OUTPUT: { skill: { id, name, description, instructions, actions[] }, isComplete }`;
587
+ var loadSkillInputSchema = {
588
+ skillId: z2.string().min(1).max(256).describe("Stable skill identifier (returned by search_skill)")
589
+ };
590
+ var loadSkillOutputSchema = {
591
+ skill: z2.object({
592
+ id: z2.string(),
593
+ name: z2.string(),
594
+ description: z2.string(),
595
+ instructions: z2.string(),
596
+ bundleVersion: z2.string().optional(),
597
+ actions: z2.array(
598
+ z2.object({
599
+ actionId: z2.string(),
600
+ summary: z2.string(),
601
+ description: z2.string().optional(),
602
+ inputJsonSchema: z2.record(z2.string(), z2.unknown()),
603
+ outputJsonSchema: z2.record(z2.string(), z2.unknown()),
604
+ requiredAuthorities: z2.record(z2.string(), z2.unknown()).optional()
605
+ })
606
+ ).optional()
607
+ }),
608
+ isComplete: z2.boolean(),
609
+ warning: z2.string().optional()
610
+ };
611
+
612
+ // plugins/plugin-skilled-openapi/src/tools/load-skill.tool.ts
613
+ var LoadSkillTool = class extends ToolContext {
614
+ async execute(input) {
615
+ this.get(BundleSyncService);
616
+ const scope = this.get(ScopeEntry);
617
+ const skillRegistry = scope.skills;
618
+ if (!skillRegistry) {
619
+ throw new InternalMcpError("SkillRegistry is not available on the active scope", "SKILL_REGISTRY_UNAVAILABLE");
620
+ }
621
+ const result = await skillRegistry.loadSkill(input.skillId);
622
+ if (!result) {
623
+ throw new PublicMcpError(`Skill "${input.skillId}" not found`, "SKILL_NOT_FOUND", 404);
624
+ }
625
+ const skill = result.skill;
626
+ return {
627
+ skill: {
628
+ id: skill.id,
629
+ name: skill.name,
630
+ description: skill.description,
631
+ instructions: skill.instructions,
632
+ ...skill.bundleVersion !== void 0 && { bundleVersion: skill.bundleVersion },
633
+ ...skill.actions ? { actions: skill.actions } : {}
634
+ },
635
+ isComplete: result.isComplete,
636
+ ...result.warning !== void 0 && { warning: result.warning }
637
+ };
638
+ }
639
+ };
640
+ LoadSkillTool = __decorateClass([
641
+ Tool({
642
+ name: "load_skill",
643
+ description: loadSkillDescription,
644
+ inputSchema: loadSkillInputSchema,
645
+ outputSchema: loadSkillOutputSchema,
646
+ annotations: {
647
+ readOnlyHint: true,
648
+ openWorldHint: false
649
+ }
650
+ })
651
+ ], LoadSkillTool);
652
+
653
+ // plugins/plugin-skilled-openapi/src/tools/operation-tool.factory.ts
654
+ import { classifyOne } from "@frontmcp/adapters/skills";
655
+ import {
656
+ ToolInstance,
657
+ ToolKind
658
+ } from "@frontmcp/sdk";
518
659
 
519
660
  // plugins/plugin-skilled-openapi/src/executor/openapi-runtime.ts
520
661
  import {
@@ -522,8 +663,38 @@ import {
522
663
  parseResponse
523
664
  } from "@frontmcp/adapters/openapi";
524
665
 
666
+ // plugins/plugin-skilled-openapi/src/executor/host-concurrency.ts
667
+ var gates = /* @__PURE__ */ new Map();
668
+ async function withHostConcurrency(host, limit, fn) {
669
+ if (!Number.isFinite(limit) || limit <= 0) {
670
+ return fn();
671
+ }
672
+ let gate = gates.get(host);
673
+ if (!gate) {
674
+ gate = { active: 0, queue: [] };
675
+ gates.set(host, gate);
676
+ }
677
+ if (gate.active >= limit) {
678
+ await new Promise((resolve) => gate.queue.push(resolve));
679
+ } else {
680
+ gate.active += 1;
681
+ }
682
+ try {
683
+ return await fn();
684
+ } finally {
685
+ const next = gate.queue.shift();
686
+ if (next) {
687
+ next();
688
+ } else {
689
+ gate.active -= 1;
690
+ if (gate.active <= 0 && gate.queue.length === 0) {
691
+ gates.delete(host);
692
+ }
693
+ }
694
+ }
695
+ }
696
+
525
697
  // plugins/plugin-skilled-openapi/src/executor/ssrf-guard.ts
526
- import { promises as dns } from "node:dns";
527
698
  var PRIVATE_IPV4_BLOCKS = [
528
699
  // RFC 1918
529
700
  { net: ipv4ToInt("10.0.0.0"), mask: 4278190080 },
@@ -531,9 +702,14 @@ var PRIVATE_IPV4_BLOCKS = [
531
702
  { net: ipv4ToInt("192.168.0.0"), mask: 4294901760 },
532
703
  // Loopback
533
704
  { net: ipv4ToInt("127.0.0.0"), mask: 4278190080 },
705
+ // Carrier-grade NAT (RFC 6598) — routable-looking but private; a common
706
+ // SSRF blind spot (SECURITY-REVIEW B4).
707
+ { net: ipv4ToInt("100.64.0.0"), mask: 4290772992 }
708
+ ];
709
+ var ALWAYS_FORBIDDEN_IPV4_BLOCKS = [
534
710
  // Link-local incl. AWS/GCP/Azure metadata 169.254.169.254
535
711
  { net: ipv4ToInt("169.254.0.0"), mask: 4294901760 },
536
- // 0.0.0.0/8
712
+ // "this host on this network" / unspecified
537
713
  { net: ipv4ToInt("0.0.0.0"), mask: 4278190080 }
538
714
  ];
539
715
  function ipv4ToInt(ip) {
@@ -542,25 +718,61 @@ function ipv4ToInt(ip) {
542
718
  const [a, b, c, d] = parts;
543
719
  return a * 16777216 + (b << 16 >>> 0) + (c << 8 >>> 0) + d >>> 0;
544
720
  }
545
- function isPrivateIPv4(ip) {
546
- if (!/^\d+\.\d+\.\d+\.\d+$/.test(ip)) return false;
547
- const parts = ip.split(".").map((n) => parseInt(n, 10));
548
- if (parts.some((p) => Number.isNaN(p) || p < 0 || p > 255)) return false;
721
+ function isValidIPv4(ip) {
722
+ if (!/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(ip)) return false;
723
+ return ip.split(".").every((p) => {
724
+ const n = Number(p);
725
+ return n >= 0 && n <= 255;
726
+ });
727
+ }
728
+ function matchesBlock(ip, blocks) {
549
729
  const v = ipv4ToInt(ip);
550
- return PRIVATE_IPV4_BLOCKS.some((b) => (v & b.mask) >>> 0 === b.net);
730
+ return blocks.some((b) => (v & b.mask) >>> 0 === b.net);
731
+ }
732
+ function isPrivateIPv4(ip) {
733
+ if (!isValidIPv4(ip)) return false;
734
+ return matchesBlock(ip, PRIVATE_IPV4_BLOCKS) || matchesBlock(ip, ALWAYS_FORBIDDEN_IPV4_BLOCKS);
735
+ }
736
+ function isAlwaysForbiddenIPv4(ip) {
737
+ if (!isValidIPv4(ip)) return false;
738
+ return matchesBlock(ip, ALWAYS_FORBIDDEN_IPV4_BLOCKS);
551
739
  }
552
740
  function isPrivateIPv6(ip) {
553
741
  const lower = ip.toLowerCase();
554
742
  if (lower === "::1" || lower === "::") return true;
555
743
  if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
556
- if (lower.startsWith("fe80:")) return true;
744
+ if (lower.startsWith("fe80:") || lower.startsWith("fe80::")) return true;
557
745
  if (lower.startsWith("::ffff:")) {
558
746
  const v4 = lower.slice("::ffff:".length);
559
747
  return isPrivateIPv4(v4);
560
748
  }
561
749
  return false;
562
750
  }
563
- var FORBIDDEN_METADATA_HOSTS = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.azure.com", "metadata.aws.com"]);
751
+ function isAlwaysForbiddenIPv6(ip) {
752
+ const lower = ip.toLowerCase();
753
+ if (lower.startsWith("fe80:") || lower.startsWith("fe80::")) return true;
754
+ if (lower === "fd00:ec2::254") return true;
755
+ if (lower === "::") return true;
756
+ if (lower.startsWith("::ffff:")) {
757
+ return isAlwaysForbiddenIPv4(lower.slice("::ffff:".length));
758
+ }
759
+ return false;
760
+ }
761
+ var FORBIDDEN_METADATA_HOSTS = /* @__PURE__ */ new Set([
762
+ "metadata.google.internal",
763
+ "metadata.azure.com",
764
+ "metadata.aws.com",
765
+ // GCP legacy + Alibaba/OpenStack-style metadata hostnames.
766
+ "metadata",
767
+ "metadata.goog"
768
+ ]);
769
+ function classifyHostLiteral(hostname) {
770
+ let h = hostname;
771
+ if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
772
+ if (isValidIPv4(h)) return { kind: "v4", ip: h };
773
+ if (h.includes(":")) return { kind: "v6", ip: h };
774
+ return { kind: "name", ip: h };
775
+ }
564
776
  async function checkOutboundUrl(target, allowedHosts, outbound) {
565
777
  let url;
566
778
  try {
@@ -578,21 +790,51 @@ async function checkOutboundUrl(target, allowedHosts, outbound) {
578
790
  if (!allowedHosts.has(hostname)) {
579
791
  return { ok: false, reason: `hostname "${hostname}" is not in the bundle's declared services` };
580
792
  }
581
- if (outbound.allowPrivateNetworks) {
793
+ const literal = classifyHostLiteral(hostname);
794
+ if (literal.kind === "v4") {
795
+ if (isAlwaysForbiddenIPv4(literal.ip)) {
796
+ return { ok: false, reason: `metadata/link-local IPv4 ${literal.ip} is always blocked` };
797
+ }
798
+ if (!outbound.allowPrivateNetworks && isPrivateIPv4(literal.ip)) {
799
+ return { ok: false, reason: `private/loopback IPv4 ${literal.ip} is blocked` };
800
+ }
801
+ return { ok: true };
802
+ }
803
+ if (literal.kind === "v6") {
804
+ if (isAlwaysForbiddenIPv6(literal.ip)) {
805
+ return { ok: false, reason: `metadata/link-local IPv6 ${literal.ip} is always blocked` };
806
+ }
807
+ if (!outbound.allowPrivateNetworks && isPrivateIPv6(literal.ip)) {
808
+ return { ok: false, reason: `private IPv6 ${literal.ip} is blocked` };
809
+ }
582
810
  return { ok: true };
583
811
  }
584
812
  let addresses;
585
813
  try {
586
- addresses = await dns.lookup(hostname, { all: true });
814
+ const { promises: dns } = await import("node:dns");
815
+ addresses = await dns.lookup(literal.ip, { all: true });
587
816
  } catch (e) {
588
- return { ok: false, reason: `DNS resolution failed for "${hostname}": ${e.message}` };
817
+ if (!outbound.allowPrivateNetworks) {
818
+ return { ok: false, reason: `DNS resolution failed for "${hostname}": ${e.message}` };
819
+ }
820
+ return { ok: true };
589
821
  }
590
822
  for (const a of addresses) {
591
- if (a.family === 4 && isPrivateIPv4(a.address)) {
592
- return { ok: false, reason: `host "${hostname}" resolved to private/loopback IPv4 ${a.address}` };
823
+ if (a.family === 4) {
824
+ if (isAlwaysForbiddenIPv4(a.address)) {
825
+ return { ok: false, reason: `host "${hostname}" resolved to metadata/link-local IPv4 ${a.address}` };
826
+ }
827
+ if (!outbound.allowPrivateNetworks && isPrivateIPv4(a.address)) {
828
+ return { ok: false, reason: `host "${hostname}" resolved to private/loopback IPv4 ${a.address}` };
829
+ }
593
830
  }
594
- if (a.family === 6 && isPrivateIPv6(a.address)) {
595
- return { ok: false, reason: `host "${hostname}" resolved to private IPv6 ${a.address}` };
831
+ if (a.family === 6) {
832
+ if (isAlwaysForbiddenIPv6(a.address)) {
833
+ return { ok: false, reason: `host "${hostname}" resolved to metadata/link-local IPv6 ${a.address}` };
834
+ }
835
+ if (!outbound.allowPrivateNetworks && isPrivateIPv6(a.address)) {
836
+ return { ok: false, reason: `host "${hostname}" resolved to private IPv6 ${a.address}` };
837
+ }
596
838
  }
597
839
  }
598
840
  return { ok: true };
@@ -677,7 +919,7 @@ function toMcpOpenAPITool(entry) {
677
919
  };
678
920
  }
679
921
  async function resolveSecurity(tool, ctx) {
680
- const { SecurityResolver } = __require("mcp-from-openapi");
922
+ const { SecurityResolver } = await import("mcp-from-openapi");
681
923
  const resolver = new SecurityResolver();
682
924
  return resolver.resolve(tool.mapper, ctx);
683
925
  }
@@ -720,79 +962,103 @@ async function executeOperation(args) {
720
962
  }
721
963
  const timeoutMs = entry.op.timeoutMs ?? outbound.defaultTimeoutMs;
722
964
  const maxBytes = entry.op.maxResponseBytes ?? outbound.defaultMaxResponseBytes;
723
- const ac = new AbortController();
724
- const timer = setTimeout(() => ac.abort(), timeoutMs);
725
- timer.unref?.();
726
- try {
727
- const response = await fetchImpl(req.url, {
728
- method: entry.op.httpMethod,
729
- headers: req.headers,
730
- body: req.body !== void 0 ? JSON.stringify(req.body) : void 0,
731
- signal: ac.signal
732
- });
733
- const contentType = response.headers.get("content-type") ?? void 0;
734
- const reader = response.body?.getReader();
735
- let received = 0;
736
- const chunks = [];
737
- if (reader) {
738
- for (; ; ) {
739
- const { value, done } = await reader.read();
740
- if (done) break;
741
- if (value) {
742
- received += value.byteLength;
743
- if (received > maxBytes) {
744
- return failure(response.status, `response exceeded maxResponseBytes (${maxBytes})`);
965
+ const host = (() => {
966
+ try {
967
+ return new URL(req.url).hostname.toLowerCase();
968
+ } catch {
969
+ return req.url;
970
+ }
971
+ })();
972
+ return withHostConcurrency(host, outbound.maxConcurrencyPerHost, async () => {
973
+ const ac = new AbortController();
974
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
975
+ timer.unref?.();
976
+ try {
977
+ const response = await fetchImpl(req.url, {
978
+ method: entry.op.httpMethod,
979
+ headers: req.headers,
980
+ body: req.body !== void 0 ? JSON.stringify(req.body) : void 0,
981
+ signal: ac.signal,
982
+ // SECURITY: never auto-follow redirects. `fetch` defaults to
983
+ // `redirect: 'follow'`, which re-sends the request — INCLUDING the
984
+ // vault-injected `Authorization`/api-key headers — to the redirect
985
+ // target, even cross-origin. An allowlisted-but-compromised (or
986
+ // open-redirecting) upstream could thus exfiltrate the credential to an
987
+ // attacker host, and the SSRF allowlist (validated only on the initial
988
+ // URL) would never re-evaluate the hop. `manual` makes the 3xx visible
989
+ // here so we can refuse it instead of following blindly.
990
+ redirect: "manual"
991
+ });
992
+ if (response.status >= 300 && response.status < 400) {
993
+ return failure(
994
+ response.status,
995
+ `upstream returned a redirect (${response.status}); not followed to protect injected credentials`
996
+ );
997
+ }
998
+ const contentType = response.headers.get("content-type") ?? void 0;
999
+ const reader = response.body?.getReader();
1000
+ let received = 0;
1001
+ const chunks = [];
1002
+ if (reader) {
1003
+ for (; ; ) {
1004
+ const { value, done } = await reader.read();
1005
+ if (done) break;
1006
+ if (value) {
1007
+ received += value.byteLength;
1008
+ if (received > maxBytes) {
1009
+ return failure(response.status, `response exceeded maxResponseBytes (${maxBytes})`);
1010
+ }
1011
+ chunks.push(value);
745
1012
  }
746
- chunks.push(value);
747
1013
  }
748
1014
  }
1015
+ const buf = Buffer.concat(chunks.map((c) => Buffer.from(c)));
1016
+ const synthetic = new Response(buf, {
1017
+ status: response.status,
1018
+ headers: response.headers
1019
+ });
1020
+ const parsed = await parseResponse(synthetic);
1021
+ void logger;
1022
+ return {
1023
+ ok: response.ok,
1024
+ status: response.status,
1025
+ contentType,
1026
+ data: parsed.data,
1027
+ responseBytes: received
1028
+ };
1029
+ } catch (e) {
1030
+ const err = e;
1031
+ return failure(0, err.name === "AbortError" ? `timeout after ${timeoutMs}ms` : err.message);
1032
+ } finally {
1033
+ clearTimeout(timer);
749
1034
  }
750
- const buf = Buffer.concat(chunks.map((c) => Buffer.from(c)));
751
- const synthetic = new Response(buf, {
752
- status: response.status,
753
- headers: response.headers
754
- });
755
- const parsed = await parseResponse(synthetic);
756
- void logger;
757
- return {
758
- ok: response.ok,
759
- status: response.status,
760
- contentType,
761
- data: parsed.data,
762
- responseBytes: received
763
- };
764
- } catch (e) {
765
- const err = e;
766
- return failure(0, err.name === "AbortError" ? `timeout after ${timeoutMs}ms` : err.message);
767
- } finally {
768
- clearTimeout(timer);
769
- }
1035
+ });
770
1036
  }
771
1037
  function failure(status, error) {
772
1038
  return { ok: false, status, data: null, error, responseBytes: 0 };
773
1039
  }
774
1040
 
775
1041
  // plugins/plugin-skilled-openapi/src/executor/schema-cache.ts
776
- import { z as z2 } from "@frontmcp/lazy-zod";
1042
+ import { z as z3 } from "@frontmcp/lazy-zod";
777
1043
  var compiled = /* @__PURE__ */ new Map();
778
1044
  var cacheKey = (bundleVersion, opId) => `${bundleVersion}\0${opId}`;
779
1045
  function compileOne(jsonSchema) {
780
1046
  if (!jsonSchema || typeof jsonSchema !== "object") {
781
- return { schema: z2.looseObject({}), failed: true };
1047
+ return { schema: z3.looseObject({}), failed: true };
782
1048
  }
783
1049
  try {
784
- const zodSchema = z2.fromJSONSchema(
1050
+ const zodSchema = z3.fromJSONSchema(
785
1051
  jsonSchema,
786
1052
  {
787
1053
  defaultTarget: "draft-2020-12"
788
1054
  }
789
1055
  );
790
1056
  if (typeof zodSchema?.parse !== "function") {
791
- return { schema: z2.looseObject({}), failed: true };
1057
+ return { schema: z3.looseObject({}), failed: true };
792
1058
  }
793
1059
  return { schema: zodSchema, failed: false };
794
1060
  } catch {
795
- return { schema: z2.looseObject({}), failed: true };
1061
+ return { schema: z3.looseObject({}), failed: true };
796
1062
  }
797
1063
  }
798
1064
  function getCompiledOpSchemas(args) {
@@ -811,348 +1077,7 @@ function getCompiledOpSchemas(args) {
811
1077
  return value;
812
1078
  }
813
1079
 
814
- // plugins/plugin-skilled-openapi/src/tools/execute-action.schema.ts
815
- import { z as z3 } from "@frontmcp/lazy-zod";
816
- var executeActionDescription = `Execute one action of a previously loaded skill.
817
-
818
- Pipeline:
819
- 1. Resolve (skillId, actionId) \u2192 bundled OpenAPI operation
820
- 2. Authorize: caller's authInfo is checked against the action's required authorities (if any)
821
- 3. Validate: the input is validated against the action's inputJsonSchema by the underlying executor
822
- 4. Outbound: an HTTPS request is built and sent to the service the action belongs to,
823
- with credentials injected from the configured vault (never echoed back to you)
824
- 5. Response: the response body is validated against outputJsonSchema and returned in
825
- a structured envelope. Failures (auth, schema, network) are returned as ok:false
826
- with a structured error string \u2014 they DO NOT throw.
827
-
828
- INPUT: { skillId, actionId, input }
829
- OUTPUT: { ok, status, data?, contentType?, error? }`;
830
- var executeActionInputSchema = {
831
- skillId: z3.string().min(1).max(256).describe("Skill that owns the action"),
832
- actionId: z3.string().min(1).max(256).describe("Action id (operationId) within the skill"),
833
- input: z3.record(z3.string(), z3.unknown()).optional().describe("Flat input object; keys correspond to the action inputJsonSchema properties")
834
- };
835
- var executeActionOutputSchema = {
836
- ok: z3.boolean(),
837
- status: z3.number().int(),
838
- data: z3.unknown().optional(),
839
- contentType: z3.string().optional(),
840
- error: z3.string().optional()
841
- };
842
-
843
- // plugins/plugin-skilled-openapi/src/tools/execute-action.tool.ts
844
- var ExecuteActionTool = class extends ToolContext {
845
- async execute(input) {
846
- this.get(BundleSyncService);
847
- const config = this.get(SkilledOpenApiConfig);
848
- const hiddenOps = this.get(HiddenOpRegistry);
849
- const bundleStore = this.get(BundleStore);
850
- const guard = this.get(AuthorityGuard);
851
- const resolver = this.get(SkilledOpenApiCredentialResolver);
852
- const auditWriter = this.tryGet(SkillAuditWriterToken);
853
- const auditSubject = this.authInfo?.user?.sub ?? "anonymous";
854
- const detachAudit = (op, phase) => {
855
- op.catch((error) => {
856
- this.logger.warn(
857
- `[skill-audit] detached ${phase} write failed: ${error instanceof Error ? error.message : String(error)}`
858
- );
859
- });
860
- };
861
- const TOTAL_STEPS = 5;
862
- const tick = (step, message) => this.progress(step, TOTAL_STEPS, message);
863
- const TELEMETRY_ACCESSOR_TOKEN = /* @__PURE__ */ Symbol.for("frontmcp:observability:telemetry-accessor");
864
- const tel = this.tryGet(TELEMETRY_ACCESSOR_TOKEN);
865
- const phaseEvent = (phase, attrs) => {
866
- tel?.addEvent("skill_action.phase", {
867
- phase,
868
- skillId: input.skillId,
869
- actionId: input.actionId,
870
- ...attrs ?? {}
871
- });
872
- };
873
- await tick(1, "resolve-action");
874
- phaseEvent("resolve-action");
875
- const entry = hiddenOps.get(input.skillId, input.actionId);
876
- if (!entry) {
877
- return {
878
- ok: false,
879
- status: 0,
880
- error: `unknown action "${input.skillId}/${input.actionId}" \u2014 search_skill / load_skill first`
881
- };
882
- }
883
- const pinned = entry;
884
- const bundleId = pinned.bundleId;
885
- const bundle = bundleStore.current();
886
- await tick(2, "authority-check");
887
- phaseEvent("authority-check", { bundleVersion: pinned.bundleVersion });
888
- const policy = pinned.op.requiredAuthorities;
889
- const authResult = await guard.check({
890
- policy,
891
- authInfo: this.authInfo ?? {},
892
- input: input.input ?? {}
893
- });
894
- if (!authResult.granted) {
895
- if (auditWriter) {
896
- detachAudit(
897
- auditWriter.writeAuthorityFail(
898
- {
899
- subject: auditSubject,
900
- skillId: input.skillId,
901
- actionId: input.actionId,
902
- bundleId,
903
- bundleVersion: pinned.bundleVersion,
904
- input: input.input ?? {}
905
- },
906
- { reason: authResult.deniedBy ?? "policy not satisfied" }
907
- ),
908
- "authority-check-fail"
909
- );
910
- }
911
- return {
912
- ok: false,
913
- status: 0,
914
- error: `authority denied: ${authResult.deniedBy ?? "policy not satisfied"}`
915
- };
916
- }
917
- if (auditWriter) {
918
- detachAudit(
919
- auditWriter.writeAuthorityPass({
920
- subject: auditSubject,
921
- skillId: input.skillId,
922
- actionId: input.actionId,
923
- bundleId,
924
- bundleVersion: pinned.bundleVersion,
925
- input: input.input ?? {}
926
- }),
927
- "authority-check-pass"
928
- );
929
- }
930
- const schemas = getCompiledOpSchemas({
931
- bundleVersion: pinned.bundleVersion,
932
- operationId: pinned.op.operationId,
933
- inputSchema: pinned.op.inputSchema,
934
- outputSchema: pinned.op.outputSchema
935
- });
936
- await tick(3, "input-validate");
937
- phaseEvent("input-validate", { bundleVersion: pinned.bundleVersion });
938
- const inputParse = schemas.input.safeParse(input.input ?? {});
939
- if (!inputParse.success) {
940
- return {
941
- ok: false,
942
- status: 0,
943
- error: `input validation failed: ${formatZodIssues(inputParse.error.issues)}`
944
- };
945
- }
946
- const allowedHosts = /* @__PURE__ */ new Set();
947
- try {
948
- allowedHosts.add(new URL(pinned.service.baseUrl).hostname.toLowerCase());
949
- } catch {
950
- }
951
- if (bundle) {
952
- for (const svc of bundle.services) {
953
- try {
954
- allowedHosts.add(new URL(svc.baseUrl).hostname.toLowerCase());
955
- } catch {
956
- }
957
- }
958
- }
959
- const deps = {
960
- outbound: config.outbound,
961
- resolver: { resolve: (ref, opts) => resolver.resolve(ref, opts) },
962
- allowedHosts,
963
- logger: this.logger
964
- };
965
- await tick(4, "http-call");
966
- phaseEvent("http-call", { bundleVersion: pinned.bundleVersion });
967
- let result;
968
- try {
969
- result = await executeOperation({
970
- entry: pinned,
971
- bundleId,
972
- input: inputParse.data,
973
- deps
974
- });
975
- } catch (e) {
976
- if (auditWriter) {
977
- detachAudit(
978
- auditWriter.writeHttpCallFailure(
979
- {
980
- subject: auditSubject,
981
- skillId: input.skillId,
982
- actionId: input.actionId,
983
- bundleId,
984
- bundleVersion: pinned.bundleVersion,
985
- input: input.input ?? {}
986
- },
987
- { status: 0, error: e }
988
- ),
989
- "http-call-failure"
990
- );
991
- }
992
- throw e;
993
- }
994
- if (auditWriter) {
995
- const auditCtx = {
996
- subject: auditSubject,
997
- skillId: input.skillId,
998
- actionId: input.actionId,
999
- bundleId,
1000
- bundleVersion: pinned.bundleVersion,
1001
- input: input.input ?? {}
1002
- };
1003
- if (result.ok) {
1004
- detachAudit(
1005
- auditWriter.writeHttpCallSuccess(auditCtx, {
1006
- status: result.status,
1007
- output: result.data ?? null
1008
- }),
1009
- "http-call-success"
1010
- );
1011
- } else {
1012
- detachAudit(
1013
- auditWriter.writeHttpCallFailure(auditCtx, {
1014
- status: result.status,
1015
- error: result.error ?? `http call failed with status ${result.status}`
1016
- }),
1017
- "http-call-failure"
1018
- );
1019
- }
1020
- }
1021
- const isJsonResponse = (result.contentType ?? "").toLowerCase().includes("application/json");
1022
- if (result.ok && isJsonResponse && result.data !== void 0 && result.data !== null) {
1023
- const outputParse = schemas.output.safeParse(result.data);
1024
- if (!outputParse.success) {
1025
- return {
1026
- ok: false,
1027
- status: result.status,
1028
- ...result.contentType ? { contentType: result.contentType } : {},
1029
- error: `upstream response failed output schema: ${formatZodIssues(outputParse.error.issues)}`
1030
- };
1031
- }
1032
- }
1033
- await tick(5, "done");
1034
- phaseEvent("done", { bundleVersion: pinned.bundleVersion });
1035
- tel?.setAttributes({
1036
- "skill_action.status": result.status,
1037
- "skill_action.ok": result.ok,
1038
- "skill_action.skill_id": input.skillId,
1039
- "skill_action.action_id": input.actionId,
1040
- "skill_action.bundle_version": pinned.bundleVersion
1041
- });
1042
- return {
1043
- ok: result.ok,
1044
- status: result.status,
1045
- ...result.data !== void 0 && result.data !== null ? { data: result.data } : {},
1046
- ...result.contentType ? { contentType: result.contentType } : {},
1047
- ...result.error ? { error: result.error } : {}
1048
- };
1049
- }
1050
- };
1051
- ExecuteActionTool = __decorateClass([
1052
- Tool({
1053
- name: "execute_action",
1054
- description: executeActionDescription,
1055
- inputSchema: executeActionInputSchema,
1056
- outputSchema: executeActionOutputSchema,
1057
- annotations: {
1058
- readOnlyHint: false,
1059
- destructiveHint: true,
1060
- openWorldHint: true
1061
- }
1062
- })
1063
- ], ExecuteActionTool);
1064
- function formatZodIssues(issues) {
1065
- if (!issues.length) return "unspecified validation error";
1066
- return issues.slice(0, 3).map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`).join("; ");
1067
- }
1068
-
1069
- // plugins/plugin-skilled-openapi/src/tools/load-skill.tool.ts
1070
- import { InternalMcpError, PublicMcpError, ScopeEntry, Tool as Tool2, ToolContext as ToolContext2 } from "@frontmcp/sdk";
1071
-
1072
- // plugins/plugin-skilled-openapi/src/tools/load-skill.schema.ts
1073
- import { z as z4 } from "@frontmcp/lazy-zod";
1074
- var loadSkillDescription = `Load the full instructions and executable actions for a specific skill.
1075
-
1076
- Use this AFTER \`search_skill\` once you've identified the right skill for the
1077
- user's task. The returned object contains:
1078
- - \`instructions\`: markdown the LLM should read carefully before invoking
1079
- - \`actions\`: each action's input/output JSON Schema and required authorities
1080
- - \`bundleVersion\`: changes when the bundle is hot-swapped (use it to detect drift)
1081
-
1082
- INPUT: { skillId }
1083
- OUTPUT: { skill: { id, name, description, instructions, actions[] }, isComplete }`;
1084
- var loadSkillInputSchema = {
1085
- skillId: z4.string().min(1).max(256).describe("Stable skill identifier (returned by search_skill)")
1086
- };
1087
- var loadSkillOutputSchema = {
1088
- skill: z4.object({
1089
- id: z4.string(),
1090
- name: z4.string(),
1091
- description: z4.string(),
1092
- instructions: z4.string(),
1093
- bundleVersion: z4.string().optional(),
1094
- actions: z4.array(
1095
- z4.object({
1096
- actionId: z4.string(),
1097
- summary: z4.string(),
1098
- description: z4.string().optional(),
1099
- inputJsonSchema: z4.record(z4.string(), z4.unknown()),
1100
- outputJsonSchema: z4.record(z4.string(), z4.unknown()),
1101
- requiredAuthorities: z4.record(z4.string(), z4.unknown()).optional()
1102
- })
1103
- ).optional()
1104
- }),
1105
- isComplete: z4.boolean(),
1106
- warning: z4.string().optional()
1107
- };
1108
-
1109
- // plugins/plugin-skilled-openapi/src/tools/load-skill.tool.ts
1110
- var LoadSkillTool = class extends ToolContext2 {
1111
- async execute(input) {
1112
- this.get(BundleSyncService);
1113
- const scope = this.get(ScopeEntry);
1114
- const skillRegistry = scope.skills;
1115
- if (!skillRegistry) {
1116
- throw new InternalMcpError("SkillRegistry is not available on the active scope", "SKILL_REGISTRY_UNAVAILABLE");
1117
- }
1118
- const result = await skillRegistry.loadSkill(input.skillId);
1119
- if (!result) {
1120
- throw new PublicMcpError(`Skill "${input.skillId}" not found`, "SKILL_NOT_FOUND", 404);
1121
- }
1122
- const skill = result.skill;
1123
- return {
1124
- skill: {
1125
- id: skill.id,
1126
- name: skill.name,
1127
- description: skill.description,
1128
- instructions: skill.instructions,
1129
- ...skill.bundleVersion !== void 0 && { bundleVersion: skill.bundleVersion },
1130
- ...skill.actions ? { actions: skill.actions } : {}
1131
- },
1132
- isComplete: result.isComplete,
1133
- ...result.warning !== void 0 && { warning: result.warning }
1134
- };
1135
- }
1136
- };
1137
- LoadSkillTool = __decorateClass([
1138
- Tool2({
1139
- name: "load_skill",
1140
- description: loadSkillDescription,
1141
- inputSchema: loadSkillInputSchema,
1142
- outputSchema: loadSkillOutputSchema,
1143
- annotations: {
1144
- readOnlyHint: true,
1145
- openWorldHint: false
1146
- }
1147
- })
1148
- ], LoadSkillTool);
1149
-
1150
1080
  // plugins/plugin-skilled-openapi/src/tools/operation-tool.factory.ts
1151
- import { classifyOne } from "@frontmcp/adapters/skills";
1152
- import {
1153
- ToolInstance,
1154
- ToolKind
1155
- } from "@frontmcp/sdk";
1156
1081
  var OPERATION_TOOL_OWNER_TOKEN = /* @__PURE__ */ Symbol.for("skilled-openapi:operation-tool-owner");
1157
1082
  var OPERATION_TOOL_OWNER = {
1158
1083
  kind: "plugin",
@@ -1189,6 +1114,7 @@ var OperationToolFactory = class {
1189
1114
  constructor(deps) {
1190
1115
  this.deps = deps;
1191
1116
  }
1117
+ deps;
1192
1118
  /** Map (bundleId|opId) → the executor function that doubles as registry token. */
1193
1119
  registered = /* @__PURE__ */ new Map();
1194
1120
  /** Tool names this factory has pushed into the classification registry. */
@@ -1265,19 +1191,22 @@ var OperationToolFactory = class {
1265
1191
  return this.registered.size;
1266
1192
  }
1267
1193
  /**
1268
- * Build the actual function executor for one op. Mirrors `ExecuteActionTool.execute`:
1194
+ * Build the actual function executor for one op. Mirrors the skill-action
1195
+ * executor (`executeSkillAction`):
1269
1196
  * authority check + input validation + `executeOperation` — so callers
1270
1197
  * reaching the op via `callTool` get the same security gates as callers
1271
- * going through `execute_action`.
1198
+ * going through `run_workflow`.
1272
1199
  */
1273
1200
  makeExecutor(entry) {
1274
1201
  return async (input, ctx) => {
1275
1202
  const config = ctx.get(SkilledOpenApiConfig);
1276
1203
  const guard = ctx.get(AuthorityGuard);
1277
1204
  const resolver = ctx.get(SkilledOpenApiCredentialResolver);
1278
- const policy = entry.op.requiredAuthorities;
1279
1205
  const authResult = await guard.check({
1280
- policy,
1206
+ policy: entry.op.requiredAuthorities,
1207
+ skillPolicy: entry.skillRequiredAuthorities,
1208
+ isPublic: entry.op.public,
1209
+ unprotectedOps: config.unprotectedOps,
1281
1210
  authInfo: ctx.authInfo ?? {},
1282
1211
  input: input ?? {}
1283
1212
  });
@@ -1330,6 +1259,233 @@ var OperationToolFactory = class {
1330
1259
  }
1331
1260
  };
1332
1261
 
1262
+ // plugins/plugin-skilled-openapi/src/tools/run-workflow.tool.ts
1263
+ import { SkillAuditWriterToken } from "@frontmcp/adapters/skills";
1264
+ import { Tool as Tool2, ToolContext as ToolContext2 } from "@frontmcp/sdk";
1265
+
1266
+ // plugins/plugin-skilled-openapi/src/executor/execute-skill-action.ts
1267
+ function formatZodIssues(issues) {
1268
+ if (!issues.length) return "unspecified validation error";
1269
+ return issues.slice(0, 3).map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`).join("; ");
1270
+ }
1271
+ async function executeSkillAction(args) {
1272
+ const { entry, input, authInfo, deps } = args;
1273
+ const { config, resolver, guard, logger, audit } = deps;
1274
+ const auditCtx = {
1275
+ subject: audit?.subject ?? "anonymous",
1276
+ skillId: entry.skillId,
1277
+ actionId: entry.op.operationId,
1278
+ bundleId: entry.bundleId,
1279
+ bundleVersion: entry.bundleVersion,
1280
+ input: input ?? {}
1281
+ };
1282
+ const detach = (op, phase) => {
1283
+ op?.catch((e) => logger.warn(`[skill-audit] ${phase} write failed: ${e instanceof Error ? e.message : String(e)}`));
1284
+ };
1285
+ const authResult = await guard.check({
1286
+ policy: entry.op.requiredAuthorities,
1287
+ skillPolicy: entry.skillRequiredAuthorities,
1288
+ isPublic: entry.op.public,
1289
+ unprotectedOps: config.unprotectedOps,
1290
+ authInfo: authInfo ?? {},
1291
+ input: input ?? {}
1292
+ });
1293
+ if (!authResult.granted) {
1294
+ detach(
1295
+ audit?.writer.writeAuthorityFail(auditCtx, { reason: authResult.deniedBy ?? "policy not satisfied" }),
1296
+ "authority-fail"
1297
+ );
1298
+ return { ok: false, status: 0, error: `authority denied: ${authResult.deniedBy ?? "policy not satisfied"}` };
1299
+ }
1300
+ detach(audit?.writer.writeAuthorityPass(auditCtx), "authority-pass");
1301
+ const schemas = getCompiledOpSchemas({
1302
+ bundleVersion: entry.bundleVersion,
1303
+ operationId: entry.op.operationId,
1304
+ inputSchema: entry.op.inputSchema,
1305
+ outputSchema: entry.op.outputSchema
1306
+ });
1307
+ const inputParse = schemas.input.safeParse(input ?? {});
1308
+ if (!inputParse.success) {
1309
+ return { ok: false, status: 0, error: `input validation failed: ${formatZodIssues(inputParse.error.issues)}` };
1310
+ }
1311
+ const allowedHosts = /* @__PURE__ */ new Set();
1312
+ try {
1313
+ allowedHosts.add(new URL(entry.service.baseUrl).hostname.toLowerCase());
1314
+ } catch {
1315
+ }
1316
+ const runtimeDeps = {
1317
+ outbound: config.outbound,
1318
+ resolver: { resolve: (ref, opts) => resolver.resolve(ref, opts) },
1319
+ allowedHosts,
1320
+ logger
1321
+ };
1322
+ let result;
1323
+ try {
1324
+ result = await executeOperation({
1325
+ entry,
1326
+ bundleId: entry.bundleId,
1327
+ input: inputParse.data,
1328
+ deps: runtimeDeps
1329
+ });
1330
+ } catch (e) {
1331
+ detach(audit?.writer.writeHttpCallFailure(auditCtx, { status: 0, error: e }), "http-call-failure");
1332
+ throw e;
1333
+ }
1334
+ if (result.ok) {
1335
+ detach(
1336
+ audit?.writer.writeHttpCallSuccess(auditCtx, { status: result.status, output: result.data ?? null }),
1337
+ "http-call-success"
1338
+ );
1339
+ } else {
1340
+ detach(
1341
+ audit?.writer.writeHttpCallFailure(auditCtx, {
1342
+ status: result.status,
1343
+ error: result.error ?? `http call failed with status ${result.status}`
1344
+ }),
1345
+ "http-call-failure"
1346
+ );
1347
+ }
1348
+ const isJsonResponse = (result.contentType ?? "").toLowerCase().includes("application/json");
1349
+ if (result.ok && isJsonResponse && result.data !== void 0 && result.data !== null) {
1350
+ const outputParse = schemas.output.safeParse(result.data);
1351
+ if (!outputParse.success) {
1352
+ return {
1353
+ ok: false,
1354
+ status: result.status,
1355
+ ...result.contentType ? { contentType: result.contentType } : {},
1356
+ error: `upstream response failed output schema: ${formatZodIssues(outputParse.error.issues)}`
1357
+ };
1358
+ }
1359
+ }
1360
+ return {
1361
+ ok: result.ok,
1362
+ status: result.status,
1363
+ ...result.data !== void 0 && result.data !== null ? { data: result.data } : {},
1364
+ ...result.contentType ? { contentType: result.contentType } : {},
1365
+ ...result.error ? { error: result.error } : {}
1366
+ };
1367
+ }
1368
+
1369
+ // plugins/plugin-skilled-openapi/src/tools/run-workflow.schema.ts
1370
+ import { z as z4 } from "@frontmcp/lazy-zod";
1371
+ var runWorkflowDescription = `Execute a task by running a short AgentScript program in a secure sandbox (enclave).
1372
+
1373
+ This is the EXECUTE step of the skill mechanism. After \`search_skill\` (to find a
1374
+ skill) and \`load_skill\` (to read its instructions + the actions it offers), write
1375
+ a program that calls those actions to accomplish the user's task \u2014 chaining
1376
+ several calls in ONE round-trip, with each call authorized, schema-validated, and
1377
+ sent to the real upstream API (credentials injected from the vault, never echoed).
1378
+
1379
+ Inside the script:
1380
+ - \`await callTool(actionId, input)\` invokes a loaded skill's action (the
1381
+ \`actionId\` is what \`load_skill\` lists under \`actions[]\`). It returns the
1382
+ action's response \`data\`, or throws if the action is unknown / unauthorized /
1383
+ fails validation.
1384
+ - \`Math\` and \`JSON\` are available. There is NO host access and NO network
1385
+ except through \`callTool\`.
1386
+ - The script's final \`return <value>\` is the result.
1387
+
1388
+ Example (after loading a skill exposing getTodo + getUser):
1389
+ const t = await callTool("getTodo", { id: 1 });
1390
+ const u = await callTool("getUser", { id: t.userId });
1391
+ return { todo: t.title, owner: u.name };
1392
+
1393
+ OUTPUT: { success, value?, error?, stats }`;
1394
+ var runWorkflowInputSchema = {
1395
+ script: z4.string().min(1).max(2e4).describe("AgentScript source. Use `await callTool(actionId, input)` for loaded skill actions; end with `return <value>`.")
1396
+ };
1397
+ var runWorkflowOutputSchema = {
1398
+ success: z4.boolean(),
1399
+ value: z4.unknown().optional(),
1400
+ error: z4.string().optional(),
1401
+ stats: z4.object({
1402
+ durationMs: z4.number(),
1403
+ toolCalls: z4.number(),
1404
+ steps: z4.number()
1405
+ }).optional()
1406
+ };
1407
+
1408
+ // plugins/plugin-skilled-openapi/src/tools/run-workflow.tool.ts
1409
+ var MAX_STEPS = 2e6;
1410
+ var MAX_TOOL_CALLS = 25;
1411
+ var TIMEOUT_MS = 8e3;
1412
+ var RunWorkflowTool = class extends ToolContext2 {
1413
+ async execute(input) {
1414
+ this.get(BundleSyncService);
1415
+ const config = this.get(SkilledOpenApiConfig);
1416
+ const hiddenOps = this.get(HiddenOpRegistry);
1417
+ const guard = this.get(AuthorityGuard);
1418
+ const resolver = this.get(SkilledOpenApiCredentialResolver);
1419
+ let transformAgentScript;
1420
+ let InterpreterAdapter;
1421
+ try {
1422
+ ({ transformAgentScript } = await import("@enclave-vm/ast"));
1423
+ ({ InterpreterAdapter } = await import("@enclave-vm/core/worker"));
1424
+ } catch {
1425
+ return {
1426
+ success: false,
1427
+ error: "workflow execution unavailable: the @enclave-vm sandbox is not installed. Install @enclave-vm/core and @enclave-vm/ast to enable run_workflow."
1428
+ };
1429
+ }
1430
+ const auditWriter = this.tryGet(SkillAuditWriterToken);
1431
+ const deps = {
1432
+ config: { outbound: config.outbound, unprotectedOps: config.unprotectedOps },
1433
+ resolver: { resolve: (ref, opts) => resolver.resolve(ref, opts) },
1434
+ guard,
1435
+ logger: this.logger,
1436
+ ...auditWriter ? { audit: { writer: auditWriter, subject: this.authInfo?.user?.sub ?? "anonymous" } } : {}
1437
+ };
1438
+ const toolHandler = async (actionId, actionInput) => {
1439
+ const entry = hiddenOps.getByActionId(actionId);
1440
+ if (!entry) {
1441
+ throw new Error(`unknown action "${actionId}" \u2014 run search_skill then load_skill to discover available actions`);
1442
+ }
1443
+ const res = await executeSkillAction({ entry, input: actionInput ?? {}, authInfo: this.authInfo, deps });
1444
+ if (!res.ok) {
1445
+ throw new Error(res.error ?? `action "${actionId}" failed with status ${res.status}`);
1446
+ }
1447
+ return res.data;
1448
+ };
1449
+ let transformed;
1450
+ try {
1451
+ transformed = transformAgentScript(input.script, { transformLoops: false });
1452
+ } catch (error) {
1453
+ return { success: false, error: `AgentScript rejected: ${error instanceof Error ? error.message : String(error)}` };
1454
+ }
1455
+ const adapter = new InterpreterAdapter({ maxSteps: MAX_STEPS });
1456
+ const context = {
1457
+ config: { maxToolCalls: MAX_TOOL_CALLS, timeout: TIMEOUT_MS },
1458
+ stats: { duration: 0, toolCallCount: 0, iterationCount: 0, startTime: 0 },
1459
+ abortController: new AbortController(),
1460
+ aborted: false,
1461
+ toolHandler
1462
+ };
1463
+ const result = await adapter.execute(transformed, context);
1464
+ const stats = {
1465
+ durationMs: result.stats.duration,
1466
+ toolCalls: result.stats.toolCallCount,
1467
+ steps: result.stats.iterationCount
1468
+ };
1469
+ if (!result.success) {
1470
+ return { success: false, error: result.error?.message ?? "workflow failed", stats };
1471
+ }
1472
+ return { success: true, value: result.value, stats };
1473
+ }
1474
+ };
1475
+ RunWorkflowTool = __decorateClass([
1476
+ Tool2({
1477
+ name: "run_workflow",
1478
+ description: runWorkflowDescription,
1479
+ inputSchema: runWorkflowInputSchema,
1480
+ outputSchema: runWorkflowOutputSchema,
1481
+ annotations: {
1482
+ readOnlyHint: false,
1483
+ destructiveHint: true,
1484
+ openWorldHint: true
1485
+ }
1486
+ })
1487
+ ], RunWorkflowTool);
1488
+
1333
1489
  // plugins/plugin-skilled-openapi/src/tools/search-skill.tool.ts
1334
1490
  import { ScopeEntry as ScopeEntry2, Tool as Tool3, ToolContext as ToolContext3 } from "@frontmcp/sdk";
1335
1491
 
@@ -1341,19 +1497,25 @@ A "skill" is a curated bundle of REST operations exposed to you behind a single
1341
1497
  named capability \u2014 instead of seeing each individual API endpoint, you see one
1342
1498
  skill that knows how to do something useful (e.g. "billing", "customers"). Use
1343
1499
  this tool first to discover what skills exist for the user's request, then call
1344
- \`load_skill\` to read its instructions + the actions it offers, and \`execute_action\`
1345
- to actually invoke one.
1500
+ \`load_skill\` to read its instructions + the actions it offers, and \`run_workflow\`
1501
+ to execute \u2014 a short sandboxed script that calls the skill's actions via
1502
+ \`callTool(actionId, input)\` (chaining several in one round-trip).
1346
1503
 
1347
1504
  INPUT:
1348
1505
  - query: short natural-language description of what you want to do
1349
1506
  - limit?: max results (default 20, max 50)
1350
1507
  - tags?: filter to skills carrying these tags
1508
+ - notQuery?: anti-query \u2014 describe what you do NOT want; matching skills are
1509
+ demoted (e.g. query "rate limiting", notQuery "enforcement" to prefer guidance
1510
+ over enforcement skills)
1351
1511
 
1352
1512
  OUTPUT: { skills: Array<{ skillId, name, description, score }> }`;
1353
1513
  var searchSkillInputSchema = {
1354
1514
  query: z5.string().min(1).max(2048).describe("Natural-language search query"),
1355
1515
  limit: z5.number().int().positive().max(50).optional().describe("Max results (default 20)"),
1356
- tags: z5.array(z5.string().min(1).max(64)).max(16).optional().describe("Filter by tags")
1516
+ tags: z5.array(z5.string().min(1).max(64)).max(16).optional().describe("Filter by tags"),
1517
+ notQuery: z5.union([z5.string().min(1).max(2048), z5.array(z5.string().min(1).max(2048)).max(8)]).optional().describe("Anti-query: skills matching this are demoted in ranking"),
1518
+ notWeight: z5.number().positive().max(10).optional().describe("Strength of the anti-query demotion (default 1)")
1357
1519
  };
1358
1520
  var searchSkillOutputSchema = {
1359
1521
  skills: z5.array(
@@ -1380,7 +1542,11 @@ var SearchSkillTool = class extends ToolContext3 {
1380
1542
  const tags = input.tags;
1381
1543
  const results = await skillRegistry.search(input.query, {
1382
1544
  topK: limit,
1383
- ...tags ? { tags } : {}
1545
+ ...tags ? { tags } : {},
1546
+ // Anti-query demotion (honored when the skill index supports it) — surfaces
1547
+ // what the user wants while pushing down what they explicitly do not.
1548
+ ...input.notQuery !== void 0 ? { negativeQuery: input.notQuery } : {},
1549
+ ...input.notWeight !== void 0 ? { negativeWeight: input.notWeight } : {}
1384
1550
  });
1385
1551
  return {
1386
1552
  skills: results.map((r) => ({
@@ -1408,6 +1574,17 @@ SearchSkillTool = __decorateClass([
1408
1574
 
1409
1575
  // plugins/plugin-skilled-openapi/src/skilled-openapi.plugin.ts
1410
1576
  var TELEMETRY_FACTORY_TOKEN = /* @__PURE__ */ Symbol.for("frontmcp:observability:telemetry-factory");
1577
+ var SKILLED_OPENAPI_RUNTIME_DEPS_TOKEN = /* @__PURE__ */ Symbol.for("frontmcp:skilled-openapi:runtime-deps");
1578
+ function resolveRuntimeDeps(scope) {
1579
+ const providers = scope?.providers;
1580
+ if (!providers || typeof providers.get !== "function") return void 0;
1581
+ try {
1582
+ const deps = providers.get(SKILLED_OPENAPI_RUNTIME_DEPS_TOKEN);
1583
+ return deps && typeof deps === "object" ? deps : void 0;
1584
+ } catch {
1585
+ return void 0;
1586
+ }
1587
+ }
1411
1588
  function resolveBundleTelemetry(scope) {
1412
1589
  const providers = scope?.providers;
1413
1590
  if (!providers || typeof providers.get !== "function") return void 0;
@@ -1437,6 +1614,24 @@ var SkilledOpenApiPlugin = class extends DynamicPlugin {
1437
1614
  }
1438
1615
  return this.cachedLogger;
1439
1616
  }
1617
+ async injectSkillCatalogIntoSearchTool(flowCtx) {
1618
+ const { tools } = flowCtx.state;
1619
+ if (!tools || tools.length === 0) return;
1620
+ const target = tools.find((item) => item.tool?.metadata?.name === "search_skill");
1621
+ if (!target) return;
1622
+ try {
1623
+ this.get(BundleSyncService);
1624
+ } catch {
1625
+ }
1626
+ const scope = this.get(ScopeEntry3);
1627
+ const catalog = buildSkillsCatalogSummary(scope.skills);
1628
+ const description = catalog ? `${searchSkillDescription}
1629
+
1630
+ ---
1631
+
1632
+ ${catalog}` : searchSkillDescription;
1633
+ target.tool.metadata.description = description;
1634
+ }
1440
1635
  warnIfInsecureConfig() {
1441
1636
  if (this.options.dev) {
1442
1637
  console.warn(
@@ -1457,7 +1652,7 @@ var SkilledOpenApiPlugin = class extends DynamicPlugin {
1457
1652
  { name: "skilled-openapi:hidden-ops", provide: HiddenOpRegistry, useValue: new HiddenOpRegistry() },
1458
1653
  {
1459
1654
  name: "skilled-openapi:bundle-store",
1460
- provide: BundleStore2,
1655
+ provide: BundleStore,
1461
1656
  // Resolve TelemetryAccessor from the scope's provider registry so the
1462
1657
  // bundle-pulls counter and `skill.bundle.swap` span actually export.
1463
1658
  // The accessor is structurally compatible with `BundleStoreTelemetry`.
@@ -1465,7 +1660,7 @@ var SkilledOpenApiPlugin = class extends DynamicPlugin {
1465
1660
  // we resolve `undefined` and the BundleStore falls through its zero-cost
1466
1661
  // no-telemetry path.
1467
1662
  inject: () => [ScopeEntry3],
1468
- useFactory: (scope) => new BundleStore2({ telemetry: resolveBundleTelemetry(scope) })
1663
+ useFactory: (scope) => new BundleStore({ telemetry: resolveBundleTelemetry(scope) })
1469
1664
  },
1470
1665
  {
1471
1666
  name: "skilled-openapi:credential-resolver",
@@ -1481,7 +1676,7 @@ var SkilledOpenApiPlugin = class extends DynamicPlugin {
1481
1676
  {
1482
1677
  name: "skilled-openapi:bundle-sync",
1483
1678
  provide: BundleSyncService,
1484
- inject: () => [ScopeEntry3, HiddenOpRegistry, BundleStore2],
1679
+ inject: () => [ScopeEntry3, HiddenOpRegistry, BundleStore],
1485
1680
  useFactory: async (scope, hiddenOps, bundleStore) => {
1486
1681
  const logger = scope.logger.child("skilled-openapi:sync");
1487
1682
  const lazySkillRegistry = new Proxy({}, {
@@ -1536,13 +1731,21 @@ var SkilledOpenApiPlugin = class extends DynamicPlugin {
1536
1731
  logger,
1537
1732
  opToolFactory
1538
1733
  );
1734
+ const runtimeDeps = resolveRuntimeDeps(scope);
1539
1735
  let source;
1540
1736
  try {
1541
- source = createBundleSource(parsed.source, parsed.bundleCacheDir, logger);
1737
+ source = createBundleSource(parsed.source, parsed.bundleCacheDir, logger, runtimeDeps);
1542
1738
  } catch (e) {
1543
1739
  logger.error(`failed to construct bundle source: ${e.message}`);
1544
1740
  return sync;
1545
1741
  }
1742
+ if (runtimeDeps?.attach) {
1743
+ try {
1744
+ runtimeDeps.attach(source);
1745
+ } catch (e) {
1746
+ logger.warn(`runtime-deps attach threw: ${e.message}`);
1747
+ }
1748
+ }
1546
1749
  source.onChange((bundle) => {
1547
1750
  void sync.apply(bundle).then((result) => {
1548
1751
  if (!result.applied) {
@@ -1564,18 +1767,22 @@ var SkilledOpenApiPlugin = class extends DynamicPlugin {
1564
1767
  ];
1565
1768
  }
1566
1769
  };
1770
+ __decorateClass([
1771
+ ListToolsHook.Did("findTools", { priority: 50 })
1772
+ ], SkilledOpenApiPlugin.prototype, "injectSkillCatalogIntoSearchTool", 1);
1567
1773
  SkilledOpenApiPlugin = __decorateClass([
1568
1774
  Plugin({
1569
1775
  name: "skilled-openapi",
1570
1776
  description: "Serve a customer's OpenAPI spec as signed skill bundles with hidden per-operation tools mediated by 3 meta-tools.",
1571
1777
  providers: [],
1572
- tools: [SearchSkillTool, LoadSkillTool, ExecuteActionTool]
1778
+ tools: [SearchSkillTool, LoadSkillTool, RunWorkflowTool]
1573
1779
  })
1574
1780
  ], SkilledOpenApiPlugin);
1575
1781
 
1576
1782
  // plugins/plugin-skilled-openapi/src/index.ts
1577
1783
  var index_default = SkilledOpenApiPlugin;
1578
1784
  export {
1785
+ SKILLED_OPENAPI_RUNTIME_DEPS_TOKEN,
1579
1786
  SkilledOpenApiConfig,
1580
1787
  SkilledOpenApiCredentialResolver,
1581
1788
  SkilledOpenApiPlugin,