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