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