@frontmcp/plugin-skilled-openapi 1.4.1 → 1.5.0
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.
- package/README.md +123 -17
- package/esm/index.mjs +640 -433
- package/esm/package.json +16 -8
- package/executor/execute-skill-action.d.ts +44 -0
- package/executor/execute-skill-action.d.ts.map +1 -0
- package/executor/host-concurrency.d.ts +9 -0
- package/executor/host-concurrency.d.ts.map +1 -0
- package/executor/openapi-runtime.d.ts.map +1 -1
- package/executor/ssrf-guard.d.ts +4 -0
- package/executor/ssrf-guard.d.ts.map +1 -1
- package/index.d.ts +1 -0
- package/index.d.ts.map +1 -1
- package/index.js +643 -421
- package/package.json +16 -8
- package/registry/hidden-op.registry.d.ts +15 -1
- package/registry/hidden-op.registry.d.ts.map +1 -1
- package/security/authority-guard.d.ts +35 -5
- package/security/authority-guard.d.ts.map +1 -1
- package/skilled-openapi.plugin.d.ts +41 -2
- package/skilled-openapi.plugin.d.ts.map +1 -1
- package/skilled-openapi.symbols.d.ts +3 -0
- package/skilled-openapi.symbols.d.ts.map +1 -1
- package/skilled-openapi.types.d.ts +22 -0
- package/skilled-openapi.types.d.ts.map +1 -1
- package/sync/bundle-sync.service.d.ts +1 -1
- package/sync/bundle-sync.service.d.ts.map +1 -1
- package/tools/operation-tool.factory.d.ts +5 -4
- package/tools/operation-tool.factory.d.ts.map +1 -1
- package/tools/run-workflow.schema.d.ts +28 -0
- package/tools/run-workflow.schema.d.ts.map +1 -0
- package/tools/run-workflow.tool.d.ts +6 -0
- package/tools/run-workflow.tool.d.ts.map +1 -0
- package/tools/search-skill.schema.d.ts +5 -1
- package/tools/search-skill.schema.d.ts.map +1 -1
- package/tools/search-skill.tool.d.ts.map +1 -1
- package/tools/execute-action.schema.d.ts +0 -26
- package/tools/execute-action.schema.d.ts.map +0 -1
- package/tools/execute-action.tool.d.ts +0 -6
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
516
|
-
import {
|
|
517
|
-
|
|
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
|
-
//
|
|
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
|
|
546
|
-
if (!/^\d
|
|
547
|
-
|
|
548
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
814
|
+
const { promises: dns } = await import("node:dns");
|
|
815
|
+
addresses = await dns.lookup(literal.ip, { all: true });
|
|
587
816
|
} catch (e) {
|
|
588
|
-
|
|
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
|
|
592
|
-
|
|
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
|
|
595
|
-
|
|
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 } =
|
|
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
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
1047
|
+
return { schema: z3.looseObject({}), failed: true };
|
|
782
1048
|
}
|
|
783
1049
|
try {
|
|
784
|
-
const zodSchema =
|
|
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:
|
|
1057
|
+
return { schema: z3.looseObject({}), failed: true };
|
|
792
1058
|
}
|
|
793
1059
|
return { schema: zodSchema, failed: false };
|
|
794
1060
|
} catch {
|
|
795
|
-
return { schema:
|
|
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
|
|
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 `
|
|
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 \`
|
|
1345
|
-
to
|
|
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:
|
|
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
|
|
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,
|
|
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,
|
|
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,
|